add ProxyJump function
This commit is contained in:
1 parent
56e20e5464
commit
7f0e0fdb23
48 files changed
+3875
-44
No files matched your search
@@ -1,14 +1,21 @@
|
||||
# ssh-access-tui
|
||||
|
||||
Консольный (TUI) инструмент для администрирования Linux jump-хоста: заводит
|
||||
пользователей, у которых есть **только** проброс портов (`LocalForward`,
|
||||
одно соединение сразу открывает все разрешённые сервисы) к явно
|
||||
разрешённому списку внутренних хостов — без интерактивного шелла. Хосты и
|
||||
сервисы описываются один раз в общем каталоге, группы просто выбирают из
|
||||
него. Генерирует и раздаёт зашифрованные архивы с ключом/конфигом/
|
||||
инструкцией, блокирует уволенных, пишет журнал подключений, хранит всю
|
||||
конфигурацию в git для бэкапа/переезда, и требует явное подтверждение
|
||||
(ввод случайной строки) для любого деструктивного действия.
|
||||
пользователей, у которых есть **только** проброс портов к явно разрешённому
|
||||
списку внутренних хостов — без интерактивного шелла. Клиентский конфиг
|
||||
выдаётся в одном из двух режимов на выбор администратора (переключается на
|
||||
пользователя, по умолчанию — первый): `LocalForward` (одно всегда открытое
|
||||
соединение `jump` сразу открывает все разрешённые сервисы на локальных
|
||||
портах) или `ProxyJump` (отдельный `Host`-алиас на каждый сервис, `ssh
|
||||
<алиас>` по требованию, без постоянно открытого туннеля и локальных портов).
|
||||
Оба режима — только формат клиентского конфига; на сервере в обоих случаях
|
||||
одинаково работает `permitopen=`-ограничение в `authorized_keys` (см.
|
||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)). Хосты и сервисы описываются
|
||||
один раз в общем каталоге, группы просто выбирают из него. Генерирует и
|
||||
раздаёт зашифрованные архивы с ключом/конфигом/инструкцией, блокирует
|
||||
уволенных, пишет журнал подключений, хранит всю конфигурацию в git для
|
||||
бэкапа/переезда, и требует явное подтверждение (ввод случайной строки) для
|
||||
любого деструктивного действия.
|
||||
|
||||
Почему не веб-панель: инструмент требует root (создание пользователей,
|
||||
правка `authorized_keys` и `sshd_config`). Веб-бэкенд с такими правами —
|
||||
|
||||
+23
-4
@@ -96,7 +96,16 @@ sudo ssh-access-tui
|
||||
сохранить.
|
||||
6. **Пользователи** → `n` → имя пользователя (латиницей, будущий Linux-логин)
|
||||
и комментарий (например ФИО) → `enter`.
|
||||
7. Отметить `space` нужные группы (можно несколько) → `enter`.
|
||||
7. Отметить `space` нужные группы (можно несколько). Ниже списка групп на
|
||||
этом же экране — чекбокс «ProxyJump вместо LocalForward»: по умолчанию
|
||||
выключен (клиентский конфиг — один `Host jump` с `LocalForward` на
|
||||
каждый сервис, всегда открытое соединение); включите его, если вместо
|
||||
этого нужен конфиг с отдельным `Host <alias>` на каждый сервис
|
||||
(`ProxyJump jump`, обычная команда `ssh <alias>` без постоянно
|
||||
открытого туннеля и без локальных портов) — например, для пользователей
|
||||
с привычными SSH-клиентами не-Windows или когда локальные порты
|
||||
неудобны. Права доступа на сервере не зависят от этого выбора — это
|
||||
только формат выдаваемого файла `config`. → `enter`.
|
||||
|
||||
Это уже создаёт системный Linux-аккаунт (без пароля, без интерактивного
|
||||
шелла) и запись в состоянии. **Ключ ещё не выдан** — статус в списке
|
||||
@@ -128,11 +137,21 @@ sudo ssh-access-tui
|
||||
меняется немедленно, без переотправки архива и **без замены ключа**
|
||||
(изменение групп никогда не ротирует ключ).
|
||||
|
||||
На этом же экране есть чекбокс «ProxyJump вместо LocalForward» (см.
|
||||
сценарий 1, шаг 7) — можно переключить режим клиентского конфига и
|
||||
существующему пользователю. В отличие от смены групп, переключение этого
|
||||
чекбокса **не применяется автоматически**: инструмент не отслеживает смену
|
||||
стиля конфига как «устаревший» (только смену состава сервисов), поэтому
|
||||
после переключения нужно вручную прогнать «Обновить конфиги» (см. ниже),
|
||||
иначе у пользователя на диске просто останется конфиг в старом формате —
|
||||
доступ на сервере это никак не ограничивает.
|
||||
|
||||
Если хотите, чтобы у пользователя появился ещё и обновлённый клиентский
|
||||
`config` с новыми адресами для новых сервисов (иначе он про них просто не
|
||||
узнает, хотя доступ уже есть) — **Пользователи** → выбрать пользователя →
|
||||
`c` («обновить конфиг»), либо (то же самое, но сразу для многих)
|
||||
**Обновить конфиги** в главном меню. Это генерирует архив только с
|
||||
узнает, хотя доступ уже есть) — или чтобы применить только что
|
||||
переключённый режим ProxyJump/LocalForward — **Пользователи** → выбрать
|
||||
пользователя → `c` («обновить конфиг»), либо (то же самое, но сразу для
|
||||
многих) **Обновить конфиги** в главном меню. Это генерирует архив только с
|
||||
`config` + `INSTRUCTIONS.txt` (без ключа — ключ у пользователя уже есть и
|
||||
остаётся тем же), пользователю нужно только подменить файл `config`. Не
|
||||
требует подтверждения — ключ и доступ этим действием не меняются, это не
|
||||
|
||||
+55
-15
@@ -46,13 +46,20 @@ TUI работает поверх уже существующего SSH+`sudo`-
|
||||
(`State.DeleteHost`, `State.RemoveServiceRefs`) — переименование/удаление
|
||||
одного сервиса никогда не задевает остальные сервисы того же хоста.
|
||||
|
||||
Ключевой момент: пользователь **никогда не подключается по SSH к целевому
|
||||
хосту напрямую** (ни через `ProxyJump`, ни как-то ещё) — он открывает одно
|
||||
соединение к самому jump-хосту и локально пробрасывает (`LocalForward`)
|
||||
порты к каждому нужному сервису через это соединение. Не важно, слушает ли
|
||||
на целевом порту настоящий SSH (22) или произвольный TCP-сервис (7888) —
|
||||
для форвардинга это не имеет значения, оба варианта — это просто
|
||||
"локальный порт → host:port через jump".
|
||||
Ключевой момент: и в `LocalForward`, и в `ProxyJump`-режиме клиентского
|
||||
конфига (`model.User.ProxyJump`, см. ниже) канал до целевого хоста —
|
||||
это всегда один и тот же серверный примитив, `direct-tcpip` через
|
||||
jump-хост: пользователь никогда не получает интерактивный шелл на целевом
|
||||
хосте через jump, только форвардинг порта до него. В `LocalForward`-режиме
|
||||
он открывает одно соединение к самому jump-хосту и локально пробрасывает
|
||||
порты к каждому нужному сервису через это соединение; в `ProxyJump`-режиме
|
||||
клиентский `ssh` сам открывает по одному `direct-tcpip`-каналу через то же
|
||||
соединение к jump-хосту на время каждой команды (`ssh <alias>`), без
|
||||
постоянно висящего туннеля и без локальных портов — разница только в том,
|
||||
как клиент организует один и тот же тип канала, а не в том, что jump-хост
|
||||
разрешает. Не важно, слушает ли на целевом порту настоящий SSH (22) или
|
||||
произвольный TCP-сервис (7888) — для форвардинга это не имеет значения,
|
||||
оба варианта — это просто "порт → host:port через jump".
|
||||
|
||||
## Как на самом деле работает ограничение "только прыжок/форвардинг"
|
||||
|
||||
@@ -84,14 +91,41 @@ TUI работает поверх уже существующего SSH+`sudo`-
|
||||
повреждены/потеряны, а не основной механизм.
|
||||
|
||||
2. **Клиент (только удобство).** Файл `~/.ssh/config`, который уезжает
|
||||
пользователю в архиве, — это один блок `Host jump` с набором строк
|
||||
`LocalForward <локальный_порт> <host>:<port>` (`internal/keymgr/clientconfig.go`).
|
||||
Он ничего не запрещает: пользователь может отредактировать его или
|
||||
вызвать `ssh` руками с любым хостом. Единственная причина, по которой это
|
||||
не работает, — сервер отклонит `permitopen`, которого нет в списке.
|
||||
Список форвардов в клиентском конфиге должен генерироваться из того же
|
||||
источника правды (группы в `state.yaml`), что и `permitopen=`, — иначе
|
||||
конфиг и реальные права разойдутся.
|
||||
пользователю в архиве, зависит от `model.User.ProxyJump` (переключается
|
||||
чекбоксом на экране групп пользователя, `internal/ui/users.go`) и
|
||||
генерируется одним из двух рендереров
|
||||
(`internal/keymgr/clientconfig.go`), но оба **одинаково не являются
|
||||
границей безопасности**:
|
||||
- `false` (по умолчанию, `LocalForward`) — один блок `Host jump` с
|
||||
набором строк `LocalForward <локальный_порт> <host>:<port>`, одно
|
||||
всегда открытое соединение сразу даёт локальные порты на все сервисы
|
||||
(`RenderClientConfig`).
|
||||
- `true` (`ProxyJump`) — отдельный блок `Host <alias>` на каждый
|
||||
разрешённый сервис с `ProxyJump jump` вместо `HostName jump`, без
|
||||
локальных портов и без постоянно открытого соединения — каждый вызов
|
||||
`ssh <alias>` сам поднимает и закрывает канал по требованию
|
||||
(`RenderClientConfigProxyJump`).
|
||||
|
||||
Ни один из двух вариантов ничего не запрещает: пользователь может
|
||||
отредактировать любой из них или вызвать `ssh` руками с любым хостом.
|
||||
Единственная причина, по которой это не работает, — сервер отклонит
|
||||
`permitopen`, которого нет в списке, и это ограничение **идентично для
|
||||
обоих режимов**: `BuildUserArchive`/`BuildConfigOnlyArchive` в обоих
|
||||
случаях берут один и тот же `[]model.Forward` (`State.AllowedForwards`)
|
||||
и не трогают `internal/sysadm/authorizedkeys.go` — переключение
|
||||
`ProxyJump` меняет только то, как выглядит клиентский конфиг, а не какие
|
||||
`permitopen=` попадают в `authorized_keys`. Список форвардов в
|
||||
клиентском конфиге (в любом из двух форматов) должен генерироваться из
|
||||
того же источника правды (группы в `state.yaml`), что и `permitopen=`, —
|
||||
иначе конфиг и реальные права разойдутся.
|
||||
|
||||
Переключение режима у уже существующего пользователя не применяется
|
||||
автоматически: `ForwardsHash`/`IsConfigStale` отслеживают только
|
||||
изменения состава форвардов, а не смену стиля конфига (само
|
||||
переключение `ProxyJump` без изменения групп не помечает конфиг как
|
||||
устаревший) — администратор должен вручную прогнать «Обновить конфиги»
|
||||
после переключения, если хочет, чтобы у пользователя оказался конфиг
|
||||
нового формата.
|
||||
|
||||
Reverse/remote forwarding (`-R`) в этой модели не выдаётся вообще
|
||||
(`AllowStreamLocalForwarding no`, никаких `permitlisten=`) — по вашему же
|
||||
@@ -249,3 +283,9 @@ session" вместо тихой порчи данных. Если реальн
|
||||
получение.
|
||||
- Полностью параллельная работа нескольких админов не поддерживается
|
||||
(см. «Конкурентность»).
|
||||
- Переключение `ProxyJump`/`LocalForward` у уже существующего пользователя
|
||||
не переиздаёт и не помечает его конфиг устаревшим автоматически (см.
|
||||
выше, «Клиент (только удобство)») — после переключения нужно вручную
|
||||
прогнать «Обновить конфиги» для этого пользователя, иначе у него на
|
||||
диске останется конфиг старого формата (что ничего не ломает: доступ на
|
||||
сервере не зависит от этого режима, только удобство пользователя).
|
||||
@@ -3,6 +3,8 @@ package keymgr
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
)
|
||||
|
||||
// RenderClientConfig produces an OpenSSH client config (works identically on
|
||||
@@ -37,3 +39,58 @@ func RenderClientConfig(jumpHost string, jumpPort int, username, keyFileName str
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// RenderClientConfigProxyJump is the ProxyJump-mode counterpart of
|
||||
// RenderClientConfig: instead of one "jump" connection carrying a
|
||||
// LocalForward per service, each allowed service gets its own "Host <alias>"
|
||||
// block that reaches it by proxying through "jump" (ProxyJump), so the user
|
||||
// runs "ssh <alias>" directly per service rather than opening one
|
||||
// always-open tunnel and pointing local tools at 127.0.0.1:<port>.
|
||||
//
|
||||
// Same non-boundary caveat as RenderClientConfig: nothing here is enforced —
|
||||
// the actual restriction to these destinations is authorized_keys
|
||||
// permitopen= (see internal/sysadm/authorizedkeys.go), unaffected by which
|
||||
// client config style is handed out.
|
||||
func RenderClientConfigProxyJump(jumpHost string, jumpPort int, username, keyFileName string, forwards []model.Forward) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# ssh-access-tui client config for %s\n", username)
|
||||
fmt.Fprintf(&b, "# Generated %s. Regenerated whenever your allowed services change.\n\n", genTimestamp())
|
||||
|
||||
fmt.Fprintf(&b, "Host jump\n")
|
||||
fmt.Fprintf(&b, " HostName %s\n", jumpHost)
|
||||
fmt.Fprintf(&b, " Port %d\n", jumpPort)
|
||||
fmt.Fprintf(&b, " User %s\n", username)
|
||||
fmt.Fprintf(&b, " IdentityFile ~/.ssh/%s\n", keyFileName)
|
||||
fmt.Fprintf(&b, " IdentitiesOnly yes\n\n")
|
||||
|
||||
aliases := hostAliases(forwards)
|
||||
for i, f := range forwards {
|
||||
fmt.Fprintf(&b, "Host %s\n", aliases[i])
|
||||
fmt.Fprintf(&b, " HostName %s\n", f.Address)
|
||||
fmt.Fprintf(&b, " Port %d\n", f.Port)
|
||||
fmt.Fprintf(&b, " User %s\n", username)
|
||||
fmt.Fprintf(&b, " ProxyJump jump\n")
|
||||
fmt.Fprintf(&b, " IdentityFile ~/.ssh/%s\n", keyFileName)
|
||||
fmt.Fprintf(&b, " IdentitiesOnly yes\n\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// hostAliases assigns each forward a unique ssh_config "Host" alias: just
|
||||
// the host name when it's the only service of that host present, otherwise
|
||||
// "<host>-<service>" so multiple services on one host don't collide.
|
||||
func hostAliases(forwards []model.Forward) []string {
|
||||
countByHost := map[string]int{}
|
||||
for _, f := range forwards {
|
||||
countByHost[f.HostName]++
|
||||
}
|
||||
out := make([]string, len(forwards))
|
||||
for i, f := range forwards {
|
||||
if countByHost[f.HostName] > 1 {
|
||||
out[i] = f.HostName + "-" + f.ServiceName
|
||||
} else {
|
||||
out[i] = f.HostName
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package keymgr
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
)
|
||||
|
||||
func TestHostAliases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
forwards []model.Forward
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
forwards: nil,
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "single service per host uses bare host name",
|
||||
forwards: []model.Forward{
|
||||
{HostName: "db-prod-1", ServiceName: "ssh"},
|
||||
{HostName: "web-1", ServiceName: "https"},
|
||||
},
|
||||
want: []string{"db-prod-1", "web-1"},
|
||||
},
|
||||
{
|
||||
name: "multiple services on same host get host-service alias",
|
||||
forwards: []model.Forward{
|
||||
{HostName: "db-prod-1", ServiceName: "ssh"},
|
||||
{HostName: "db-prod-1", ServiceName: "web-admin"},
|
||||
},
|
||||
want: []string{"db-prod-1-ssh", "db-prod-1-web-admin"},
|
||||
},
|
||||
{
|
||||
name: "mixed: one host with a single service, another with several",
|
||||
forwards: []model.Forward{
|
||||
{HostName: "db-prod-1", ServiceName: "ssh"},
|
||||
{HostName: "db-prod-1", ServiceName: "web-admin"},
|
||||
{HostName: "cache-1", ServiceName: "redis"},
|
||||
},
|
||||
want: []string{"db-prod-1-ssh", "db-prod-1-web-admin", "cache-1"},
|
||||
},
|
||||
{
|
||||
name: "order is preserved, not sorted",
|
||||
forwards: []model.Forward{
|
||||
{HostName: "zzz-host", ServiceName: "ssh"},
|
||||
{HostName: "aaa-host", ServiceName: "ssh"},
|
||||
},
|
||||
want: []string{"zzz-host", "aaa-host"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := hostAliases(tt.forwards)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("hostAliases() = %v, want %v", got, tt.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("hostAliases()[%d] = %q, want %q (full: got=%v want=%v)", i, got[i], tt.want[i], got, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderClientConfigProxyJump(t *testing.T) {
|
||||
forwards := []model.Forward{
|
||||
{HostName: "db-prod-1", Address: "10.0.0.5", Port: 22, ServiceName: "ssh", Description: "primary db"},
|
||||
{HostName: "db-prod-1", Address: "10.0.0.5", Port: 8080, ServiceName: "web-admin"},
|
||||
{HostName: "cache-1", Address: "10.0.0.9", Port: 6379, ServiceName: "redis"},
|
||||
}
|
||||
|
||||
out := RenderClientConfigProxyJump("bastion.example.com", 2222, "a-makarov", "a-makarov_jump_key", forwards)
|
||||
|
||||
// The one always-present "jump" block.
|
||||
for _, want := range []string{
|
||||
"Host jump\n",
|
||||
" HostName bastion.example.com\n",
|
||||
" Port 2222\n",
|
||||
" User a-makarov\n",
|
||||
" IdentityFile ~/.ssh/a-makarov_jump_key\n",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing jump block line %q\nfull output:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
// LocalForward is the LocalForward-mode marker; it must never appear here.
|
||||
if strings.Contains(out, "LocalForward") {
|
||||
t.Errorf("ProxyJump-mode output must not contain LocalForward, got:\n%s", out)
|
||||
}
|
||||
|
||||
// Every forward gets its own Host block, with the expected alias
|
||||
// (deduplicated per hostAliases), ProxyJump jump, and no local port.
|
||||
wantBlocks := []struct {
|
||||
alias string
|
||||
addr string
|
||||
port string
|
||||
}{
|
||||
{"db-prod-1-ssh", "10.0.0.5", "22"},
|
||||
{"db-prod-1-web-admin", "10.0.0.5", "8080"},
|
||||
{"cache-1", "10.0.0.9", "6379"},
|
||||
}
|
||||
for _, wb := range wantBlocks {
|
||||
if !strings.Contains(out, "Host "+wb.alias+"\n") {
|
||||
t.Errorf("output missing %q Host block, got:\n%s", wb.alias, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, " ProxyJump jump\n") {
|
||||
t.Errorf("output missing ProxyJump jump directive, got:\n%s", out)
|
||||
}
|
||||
proxyJumpCount := strings.Count(out, "ProxyJump jump")
|
||||
if proxyJumpCount != len(forwards) {
|
||||
t.Errorf("expected %d ProxyJump jump directives (one per forward), got %d", len(forwards), proxyJumpCount)
|
||||
}
|
||||
identitiesOnlyCount := strings.Count(out, "IdentitiesOnly yes")
|
||||
// One for the "jump" block plus one per forward block.
|
||||
if identitiesOnlyCount != len(forwards)+1 {
|
||||
t.Errorf("expected %d IdentitiesOnly yes lines, got %d", len(forwards)+1, identitiesOnlyCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderClientConfigProxyJump_NoForwards(t *testing.T) {
|
||||
out := RenderClientConfigProxyJump("bastion.example.com", 22, "nobody", "nobody_jump_key", nil)
|
||||
|
||||
if !strings.Contains(out, "Host jump\n") {
|
||||
t.Errorf("expected jump block even with no forwards, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "ProxyJump jump") {
|
||||
t.Errorf("no forwards means no per-service blocks, but found ProxyJump jump in:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderClientConfig_LocalForwardModeUnaffected pins down that the
|
||||
// pre-existing LocalForward-mode renderer still behaves as before adding
|
||||
// ProxyJump mode alongside it (regression guard).
|
||||
func TestRenderClientConfig_LocalForwardModeUnaffected(t *testing.T) {
|
||||
forwards := []AllocatedForward{
|
||||
{Forward: model.Forward{HostName: "db-prod-1", Address: "10.0.0.5", Port: 22, ServiceName: "ssh", Description: "primary db"}, LocalPort: 20000},
|
||||
{Forward: model.Forward{HostName: "cache-1", Address: "10.0.0.9", Port: 6379, ServiceName: "redis"}, LocalPort: 20001},
|
||||
}
|
||||
|
||||
out := RenderClientConfig("bastion.example.com", 2222, "a-makarov", "a-makarov_jump_key", forwards)
|
||||
|
||||
if strings.Contains(out, "ProxyJump") {
|
||||
t.Errorf("LocalForward-mode output must not contain ProxyJump, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Host jump\n") {
|
||||
t.Errorf("expected single jump block, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "Host db-prod-1\n") || strings.Contains(out, "Host cache-1\n") {
|
||||
t.Errorf("LocalForward mode must not emit per-service Host blocks, got:\n%s", out)
|
||||
}
|
||||
wantLines := []string{
|
||||
" LocalForward 20000 10.0.0.5:22 # db-prod-1/ssh: primary db\n",
|
||||
" LocalForward 20001 10.0.0.9:6379 # cache-1/redis\n",
|
||||
}
|
||||
for _, want := range wantLines {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing expected LocalForward line %q, got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ type ConfigOnlyResult struct {
|
||||
// needless key material handed around). The config's IdentityFile line
|
||||
// references the same deterministic filename GeneratePair always used
|
||||
// (username + "_jump_key"), so it matches the key the user already has.
|
||||
func BuildConfigOnlyArchive(scratchDir, outDir, jumpHost string, jumpPort int, username, password string, forwards []model.Forward) (*ConfigOnlyResult, error) {
|
||||
func BuildConfigOnlyArchive(scratchDir, outDir, jumpHost string, jumpPort int, username, password string, forwards []model.Forward, proxyJump bool) (*ConfigOnlyResult, error) {
|
||||
userScratch := filepath.Join(scratchDir, username+"_configonly")
|
||||
if err := os.MkdirAll(userScratch, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create scratch dir: %w", err)
|
||||
@@ -36,14 +36,21 @@ func BuildConfigOnlyArchive(scratchDir, outDir, jumpHost string, jumpPort int, u
|
||||
defer Wipe(userScratch)
|
||||
|
||||
keyFileName := username + "_jump_key"
|
||||
allocated := AllocateLocalPorts(forwards)
|
||||
|
||||
cfg := RenderClientConfig(jumpHost, jumpPort, username, keyFileName, allocated)
|
||||
var cfg, instr string
|
||||
if proxyJump {
|
||||
cfg = RenderClientConfigProxyJump(jumpHost, jumpPort, username, keyFileName, forwards)
|
||||
instr = RenderConfigUpdateInstructionsProxyJump(username, forwards)
|
||||
} else {
|
||||
allocated := AllocateLocalPorts(forwards)
|
||||
cfg = RenderClientConfig(jumpHost, jumpPort, username, keyFileName, allocated)
|
||||
instr = RenderConfigUpdateInstructions(username, allocated)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(userScratch, "config"), []byte(cfg), 0o600); err != nil {
|
||||
return nil, fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
|
||||
instr := RenderConfigUpdateInstructions(username, allocated)
|
||||
if err := os.WriteFile(filepath.Join(userScratch, "INSTRUCTIONS.txt"), []byte(instr), 0o600); err != nil {
|
||||
return nil, fmt.Errorf("write instructions: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package keymgr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
)
|
||||
|
||||
// TestBuildConfigOnlyArchive_ProxyJumpSelectsRenderer mirrors
|
||||
// TestBuildUserArchive_ProxyJumpSelectsRenderer for the config-only refresh
|
||||
// path (no keypair generated here, just config + instructions).
|
||||
func TestBuildConfigOnlyArchive_ProxyJumpSelectsRenderer(t *testing.T) {
|
||||
if _, err := exec.LookPath("7z"); err != nil {
|
||||
t.Skip("7z not available in PATH")
|
||||
}
|
||||
|
||||
forwards := []model.Forward{
|
||||
{HostName: "cache-1", Address: "10.0.0.9", Port: 6379, ServiceName: "redis"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
proxyJump bool
|
||||
}{
|
||||
{"LocalForward mode", false},
|
||||
{"ProxyJump mode", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
scratch := t.TempDir()
|
||||
outDir := t.TempDir()
|
||||
username := "test-user"
|
||||
password := "TestPassw0rd123"
|
||||
|
||||
res, err := BuildConfigOnlyArchive(scratch, outDir, "bastion.example.com", 2222, username, password, forwards, tt.proxyJump)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildConfigOnlyArchive: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(res.ArchivePath); err != nil {
|
||||
t.Fatalf("archive not found on disk: %v", err)
|
||||
}
|
||||
|
||||
if entries, err := os.ReadDir(filepath.Join(scratch, username+"_configonly")); err == nil {
|
||||
t.Fatalf("expected scratch dir to be wiped, still has entries: %v", entries)
|
||||
}
|
||||
|
||||
extractedDir := extract7z(t, res.ArchivePath, password)
|
||||
cfgBytes, err := os.ReadFile(filepath.Join(extractedDir, "config"))
|
||||
if err != nil {
|
||||
t.Fatalf("read extracted config: %v", err)
|
||||
}
|
||||
cfg := string(cfgBytes)
|
||||
|
||||
instrBytes, err := os.ReadFile(filepath.Join(extractedDir, "INSTRUCTIONS.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read extracted instructions: %v", err)
|
||||
}
|
||||
instr := string(instrBytes)
|
||||
|
||||
if tt.proxyJump {
|
||||
if strings.Contains(cfg, "LocalForward") {
|
||||
t.Errorf("proxyJump=true config must not contain LocalForward, got:\n%s", cfg)
|
||||
}
|
||||
if !strings.Contains(cfg, "ProxyJump jump") {
|
||||
t.Errorf("proxyJump=true config must contain ProxyJump jump, got:\n%s", cfg)
|
||||
}
|
||||
if strings.Contains(instr, "127.0.0.1") {
|
||||
t.Errorf("proxyJump=true instructions must not mention local ports, got:\n%s", instr)
|
||||
}
|
||||
} else {
|
||||
if strings.Contains(cfg, "ProxyJump") {
|
||||
t.Errorf("proxyJump=false config must not contain ProxyJump, got:\n%s", cfg)
|
||||
}
|
||||
if !strings.Contains(cfg, "LocalForward") {
|
||||
t.Errorf("proxyJump=false config must contain LocalForward, got:\n%s", cfg)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
)
|
||||
|
||||
func genTimestamp() string {
|
||||
@@ -129,3 +131,107 @@ func RenderConfigUpdateInstructions(username string, forwards []AllocatedForward
|
||||
b.WriteString("Если что-то не работает — обратитесь к администратору.\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// RenderInstructionsProxyJump is the ProxyJump-mode counterpart of
|
||||
// RenderInstructions: same audience and same 4-file archive, but each
|
||||
// service gets its own "Host <alias>" entry (see
|
||||
// RenderClientConfigProxyJump) reached with a plain "ssh <alias>" instead of
|
||||
// one always-open tunnel exposing local ports — steps 3/4 are adapted
|
||||
// accordingly.
|
||||
func RenderInstructionsProxyJump(username, keyFileName string, forwards []model.Forward) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("КАК ПОДКЛЮЧИТЬСЯ (Windows)\n")
|
||||
b.WriteString("===========================\n\n")
|
||||
fmt.Fprintf(&b, "Ваш логин: %s\n\n", username)
|
||||
|
||||
b.WriteString("В этом архиве 4 файла:\n")
|
||||
fmt.Fprintf(&b, " - %s — ваш секретный ключ (никому не передавайте и не пересылайте)\n", keyFileName)
|
||||
fmt.Fprintf(&b, " - %s.pub — открытая часть ключа (можно не трогать)\n", keyFileName)
|
||||
b.WriteString(" - config — настройка подключения\n")
|
||||
b.WriteString(" - INSTRUCTIONS.txt — этот файл\n\n")
|
||||
|
||||
b.WriteString("ШАГ 1. Разложите файлы по нужной папке\n")
|
||||
b.WriteString("---------------------------------------\n")
|
||||
b.WriteString("Создайте (если её ещё нет) папку:\n\n")
|
||||
b.WriteString(" C:\\Users\\ВАШЕ_ИМЯ\\.ssh\\\n\n")
|
||||
fmt.Fprintf(&b, "И скопируйте туда все 3 файла из архива (%s, %s.pub, config).\n", keyFileName, keyFileName)
|
||||
b.WriteString("Важно: у файла config не должно быть никакого расширения (не config.txt).\n\n")
|
||||
|
||||
b.WriteString("ШАГ 2. Настройте доступ к файлу ключа\n")
|
||||
b.WriteString("----------------------------------------\n")
|
||||
b.WriteString("Windows откажется использовать секретный ключ, если его может прочитать\n")
|
||||
b.WriteString("кто-то ещё, кроме вас. Откройте PowerShell и выполните по очереди (это две\n")
|
||||
b.WriteString("отдельные команды, копируйте и выполняйте одну за другой):\n\n")
|
||||
fmt.Fprintf(&b, " icacls \"$env:USERPROFILE\\.ssh\\%s\" /inheritance:r\n", keyFileName)
|
||||
fmt.Fprintf(&b, " icacls \"$env:USERPROFILE\\.ssh\\%s\" /grant:r \"${env:USERNAME}:(R)\"\n\n", keyFileName)
|
||||
b.WriteString("Если файлы лежат не в стандартной папке .ssh, а где-то ещё — замените путь\n")
|
||||
b.WriteString("в обеих командах на реальный путь к файлу ключа (в кавычках, как выше).\n\n")
|
||||
|
||||
b.WriteString("ШАГ 3. Подключайтесь\n")
|
||||
b.WriteString("---------------------\n")
|
||||
b.WriteString("Никакое окно держать открытым не нужно. Когда понадобится один из сервисов\n")
|
||||
b.WriteString("из списка ниже, просто выполните в PowerShell соответствующую ему команду\n")
|
||||
b.WriteString("\"ssh <название>\" — она сама пройдёт через промежуточный узел, отдельно\n")
|
||||
b.WriteString("ничего для этого включать не нужно.\n\n")
|
||||
|
||||
b.WriteString("ШАГ 4. Ваши сервисы\n")
|
||||
b.WriteString("---------------------\n")
|
||||
|
||||
aliases := hostAliases(forwards)
|
||||
for i, f := range forwards {
|
||||
what := f.ServiceName
|
||||
if f.Description != "" {
|
||||
what = fmt.Sprintf("%s (%s)", f.ServiceName, f.Description)
|
||||
}
|
||||
fmt.Fprintf(&b, " %d. ssh %s\n", i+1, aliases[i])
|
||||
fmt.Fprintf(&b, " Это: %s\n", what)
|
||||
fmt.Fprintf(&b, " Реально находится на: %s:%d\n\n", f.HostName, f.Port)
|
||||
}
|
||||
if len(forwards) == 0 {
|
||||
b.WriteString(" (сервисы ещё не назначены — обратитесь к администратору)\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
b.WriteString("ЕСЛИ ЧТО-ТО НЕ РАБОТАЕТ\n")
|
||||
b.WriteString("------------------------\n")
|
||||
b.WriteString("Если команда из шага 4 сразу обрывается или пишет ошибку — обратитесь к\n")
|
||||
b.WriteString("администратору, который выдал вам этот архив. Возможно, изменился список\n")
|
||||
b.WriteString("сервисов — тогда вам пришлют обновлённый архив.\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// RenderConfigUpdateInstructionsProxyJump is the ProxyJump-mode counterpart
|
||||
// of RenderConfigUpdateInstructions.
|
||||
func RenderConfigUpdateInstructionsProxyJump(username string, forwards []model.Forward) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("ОБНОВЛЕНИЕ СПИСКА СЕРВИСОВ\n")
|
||||
b.WriteString("===========================\n\n")
|
||||
fmt.Fprintf(&b, "Ваш логин: %s\n\n", username)
|
||||
b.WriteString("Ваш ключ НЕ менялся — трогать его не нужно, продолжайте пользоваться тем же.\n")
|
||||
b.WriteString("Изменился только список сервисов, поэтому нужно заменить один файл.\n\n")
|
||||
|
||||
b.WriteString("Замените файл config (из этого архива) на:\n\n")
|
||||
b.WriteString(" C:\\Users\\ВАШЕ_ИМЯ\\.ssh\\config\n\n")
|
||||
b.WriteString("(тот же путь, что и раньше — просто перезапишите старый файл новым).\n\n")
|
||||
|
||||
b.WriteString("Актуальный список сервисов (подключайтесь напрямую соответствующей командой):\n\n")
|
||||
aliases := hostAliases(forwards)
|
||||
for i, f := range forwards {
|
||||
what := f.ServiceName
|
||||
if f.Description != "" {
|
||||
what = fmt.Sprintf("%s (%s)", f.ServiceName, f.Description)
|
||||
}
|
||||
fmt.Fprintf(&b, " %d. ssh %s\n", i+1, aliases[i])
|
||||
fmt.Fprintf(&b, " Это: %s\n", what)
|
||||
fmt.Fprintf(&b, " Реально находится на: %s:%d\n\n", f.HostName, f.Port)
|
||||
}
|
||||
if len(forwards) == 0 {
|
||||
b.WriteString(" (сервисы не назначены — обратитесь к администратору)\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("Если что-то не работает — обратитесь к администратору.\n")
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package keymgr
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
)
|
||||
|
||||
func TestRenderInstructionsProxyJump(t *testing.T) {
|
||||
forwards := []model.Forward{
|
||||
{HostName: "db-prod-1", Address: "10.0.0.5", Port: 22, ServiceName: "ssh", Description: "primary db"},
|
||||
{HostName: "db-prod-1", Address: "10.0.0.5", Port: 8080, ServiceName: "web-admin"},
|
||||
}
|
||||
|
||||
out := RenderInstructionsProxyJump("a-makarov", "a-makarov_jump_key", forwards)
|
||||
|
||||
if !strings.Contains(out, "a-makarov") {
|
||||
t.Errorf("expected username in output, got:\n%s", out)
|
||||
}
|
||||
// ProxyJump-mode instructions tell the user to run "ssh <alias>" per
|
||||
// service, not to open one tunnel and point tools at a local port.
|
||||
if strings.Contains(out, "127.0.0.1") {
|
||||
t.Errorf("ProxyJump instructions must not reference local forwarded ports, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "ssh -N jump") {
|
||||
t.Errorf("ProxyJump instructions must not tell the user to open the always-on tunnel, got:\n%s", out)
|
||||
}
|
||||
for _, wantAlias := range []string{"ssh db-prod-1-ssh", "ssh db-prod-1-web-admin"} {
|
||||
if !strings.Contains(out, wantAlias) {
|
||||
t.Errorf("expected instructions to mention %q, got:\n%s", wantAlias, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderInstructionsProxyJump_NoForwards(t *testing.T) {
|
||||
out := RenderInstructionsProxyJump("nobody", "nobody_jump_key", nil)
|
||||
if !strings.Contains(out, "обратитесь к администратору") {
|
||||
t.Errorf("expected a no-services fallback message, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderConfigUpdateInstructionsProxyJump(t *testing.T) {
|
||||
forwards := []model.Forward{
|
||||
{HostName: "cache-1", Address: "10.0.0.9", Port: 6379, ServiceName: "redis"},
|
||||
}
|
||||
out := RenderConfigUpdateInstructionsProxyJump("a-makarov", forwards)
|
||||
|
||||
if strings.Contains(out, "127.0.0.1") {
|
||||
t.Errorf("ProxyJump config-update instructions must not reference local ports, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "ssh cache-1") {
|
||||
t.Errorf("expected instructions to mention the cache-1 alias, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
+13
-5
@@ -22,8 +22,10 @@ type BuildResult struct {
|
||||
// directory, packs everything into a single AES-256-encrypted 7z archive
|
||||
// under outDir, and then wipes the scratch directory (including the
|
||||
// plaintext private key) from disk. password is shared across one export
|
||||
// batch and must be communicated to the recipient out-of-band.
|
||||
func BuildUserArchive(scratchDir, outDir, jumpHost string, jumpPort int, username, password string, forwards []model.Forward) (*BuildResult, error) {
|
||||
// batch and must be communicated to the recipient out-of-band. proxyJump
|
||||
// selects the client config/instructions style (see model.User.ProxyJump);
|
||||
// when true, local ports are never allocated at all — they're not needed.
|
||||
func BuildUserArchive(scratchDir, outDir, jumpHost string, jumpPort int, username, password string, forwards []model.Forward, proxyJump bool) (*BuildResult, error) {
|
||||
userScratch := filepath.Join(scratchDir, username)
|
||||
if err := os.MkdirAll(userScratch, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create scratch dir: %w", err)
|
||||
@@ -36,14 +38,20 @@ func BuildUserArchive(scratchDir, outDir, jumpHost string, jumpPort int, usernam
|
||||
}
|
||||
keyFileName := filepath.Base(kp.PrivateKeyPath)
|
||||
|
||||
allocated := AllocateLocalPorts(forwards)
|
||||
var cfg, instr string
|
||||
if proxyJump {
|
||||
cfg = RenderClientConfigProxyJump(jumpHost, jumpPort, username, keyFileName, forwards)
|
||||
instr = RenderInstructionsProxyJump(username, keyFileName, forwards)
|
||||
} else {
|
||||
allocated := AllocateLocalPorts(forwards)
|
||||
cfg = RenderClientConfig(jumpHost, jumpPort, username, keyFileName, allocated)
|
||||
instr = RenderInstructions(username, keyFileName, allocated)
|
||||
}
|
||||
|
||||
cfg := RenderClientConfig(jumpHost, jumpPort, username, keyFileName, allocated)
|
||||
if err := os.WriteFile(filepath.Join(userScratch, "config"), []byte(cfg), 0o600); err != nil {
|
||||
return nil, fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
|
||||
instr := RenderInstructions(username, keyFileName, allocated)
|
||||
if err := os.WriteFile(filepath.Join(userScratch, "INSTRUCTIONS.txt"), []byte(instr), 0o600); err != nil {
|
||||
return nil, fmt.Errorf("write instructions: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package keymgr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
)
|
||||
|
||||
// extract7z unpacks archivePath (encrypted with password) into a fresh temp
|
||||
// dir and returns that dir. Skips the test if 7z is unavailable rather than
|
||||
// failing, since this is exercising an external tool, not this package.
|
||||
func extract7z(t *testing.T, archivePath, password string) string {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("7z"); err != nil {
|
||||
t.Skip("7z not available in PATH")
|
||||
}
|
||||
dest := t.TempDir()
|
||||
cmd := exec.Command("7z", "x", "-p"+password, "-o"+dest, archivePath)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("7z x: %v: %s", err, out)
|
||||
}
|
||||
return dest
|
||||
}
|
||||
|
||||
// TestBuildUserArchive_ProxyJumpSelectsRenderer is a smoke test verifying
|
||||
// that BuildUserArchive's new proxyJump parameter actually switches which
|
||||
// client config / instructions renderer gets packed, end to end through the
|
||||
// real ssh-keygen/7z tools (see internal/keymgr package doc: the private key
|
||||
// only exists transiently on disk for this one operation).
|
||||
func TestBuildUserArchive_ProxyJumpSelectsRenderer(t *testing.T) {
|
||||
if _, err := exec.LookPath("ssh-keygen"); err != nil {
|
||||
t.Skip("ssh-keygen not available in PATH")
|
||||
}
|
||||
if _, err := exec.LookPath("7z"); err != nil {
|
||||
t.Skip("7z not available in PATH")
|
||||
}
|
||||
|
||||
forwards := []model.Forward{
|
||||
{HostName: "db-prod-1", Address: "10.0.0.5", Port: 22, ServiceName: "ssh"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
proxyJump bool
|
||||
}{
|
||||
{"LocalForward mode", false},
|
||||
{"ProxyJump mode", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
scratch := t.TempDir()
|
||||
outDir := t.TempDir()
|
||||
username := "test-user"
|
||||
password := "TestPassw0rd123"
|
||||
|
||||
res, err := BuildUserArchive(scratch, outDir, "bastion.example.com", 2222, username, password, forwards, tt.proxyJump)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildUserArchive: %v", err)
|
||||
}
|
||||
if res.ArchivePath == "" {
|
||||
t.Fatalf("expected non-empty archive path")
|
||||
}
|
||||
if _, err := os.Stat(res.ArchivePath); err != nil {
|
||||
t.Fatalf("archive not found on disk: %v", err)
|
||||
}
|
||||
|
||||
// Scratch dir must be wiped (private key must not linger).
|
||||
if entries, err := os.ReadDir(filepath.Join(scratch, username)); err == nil {
|
||||
t.Fatalf("expected scratch dir to be wiped, still has entries: %v", entries)
|
||||
}
|
||||
|
||||
extractedDir := extract7z(t, res.ArchivePath, password)
|
||||
cfgBytes, err := os.ReadFile(filepath.Join(extractedDir, "config"))
|
||||
if err != nil {
|
||||
t.Fatalf("read extracted config: %v", err)
|
||||
}
|
||||
cfg := string(cfgBytes)
|
||||
|
||||
if tt.proxyJump {
|
||||
if strings.Contains(cfg, "LocalForward") {
|
||||
t.Errorf("proxyJump=true archive must not contain LocalForward, got config:\n%s", cfg)
|
||||
}
|
||||
if !strings.Contains(cfg, "ProxyJump jump") {
|
||||
t.Errorf("proxyJump=true archive must contain ProxyJump jump, got config:\n%s", cfg)
|
||||
}
|
||||
} else {
|
||||
if strings.Contains(cfg, "ProxyJump") {
|
||||
t.Errorf("proxyJump=false archive must not contain ProxyJump, got config:\n%s", cfg)
|
||||
}
|
||||
if !strings.Contains(cfg, "LocalForward") {
|
||||
t.Errorf("proxyJump=false archive must contain LocalForward, got config:\n%s", cfg)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -82,10 +82,19 @@ type User struct {
|
||||
// this user was handed a config (full export or config-only update). If
|
||||
// it no longer matches the live computation, their local ssh config is
|
||||
// stale — see IsConfigStale.
|
||||
ForwardsHash string `yaml:"forwards_hash,omitempty"`
|
||||
Comment string `yaml:"comment,omitempty"`
|
||||
CreatedAt time.Time `yaml:"created_at"`
|
||||
LockedAt *time.Time `yaml:"locked_at,omitempty"`
|
||||
ForwardsHash string `yaml:"forwards_hash,omitempty"`
|
||||
Comment string `yaml:"comment,omitempty"`
|
||||
// ProxyJump selects the client config style delivered to this user: when
|
||||
// true, each allowed service gets its own "Host <alias>" ssh_config block
|
||||
// proxied through "jump" via ProxyJump (plain "ssh <alias>"); when false
|
||||
// (the default, preserving existing state.yaml behavior), the client
|
||||
// config instead lists one LocalForward per service on a single always-open
|
||||
// "jump" connection. Purely a client-config/instructions choice — the
|
||||
// server-side authorized_keys permitopen= restriction is identical either
|
||||
// way (see internal/sysadm/authorizedkeys.go).
|
||||
ProxyJump bool `yaml:"proxy_jump,omitempty"`
|
||||
CreatedAt time.Time `yaml:"created_at"`
|
||||
LockedAt *time.Time `yaml:"locked_at,omitempty"`
|
||||
}
|
||||
|
||||
// State is the full persisted configuration of the tool.
|
||||
|
||||
@@ -116,6 +116,7 @@ type Model struct {
|
||||
userNewGroupsOrder []string
|
||||
userNewCursor int
|
||||
userGroupsEditTarget string // non-empty: editing this existing user's groups instead of creating a new user
|
||||
userNewProxyJump bool // ProxyJump vs LocalForward client config mode, toggled alongside groups on the same screen
|
||||
|
||||
// catalog: hosts and their services, shared across groups
|
||||
catalogHostsList list.Model
|
||||
|
||||
@@ -125,7 +125,7 @@ func (m Model) runConfigUpdate(usernames []string) (tea.Model, tea.Cmd) {
|
||||
continue
|
||||
}
|
||||
forwards := st.AllowedForwards(u)
|
||||
res, err := keymgr.BuildConfigOnlyArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, forwards)
|
||||
res, err := keymgr.BuildConfigOnlyArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, forwards, u.ProxyJump)
|
||||
if err != nil {
|
||||
failures = append(failures, username+": "+err.Error())
|
||||
continue
|
||||
|
||||
@@ -118,7 +118,7 @@ func (m Model) runExport(usernames []string) (tea.Model, tea.Cmd) {
|
||||
continue
|
||||
}
|
||||
forwards := st.AllowedForwards(u)
|
||||
res, err := keymgr.BuildUserArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, forwards)
|
||||
res, err := keymgr.BuildUserArchive(m.cfg.ScratchDir, m.cfg.ExportDir, m.cfg.JumpHost, m.cfg.JumpPort, username, password, forwards, u.ProxyJump)
|
||||
if err != nil {
|
||||
failures = append(failures, username+": "+err.Error())
|
||||
continue
|
||||
|
||||
+25
-2
@@ -65,6 +65,7 @@ func (m Model) updateUsers(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.userNewCursor = 0
|
||||
m.userGroupsEditTarget = sel.id
|
||||
m.userNewProxyJump = u.ProxyJump
|
||||
m.err, m.status = "", ""
|
||||
m.screen = scrUserNewGroups
|
||||
return m, nil
|
||||
@@ -173,6 +174,7 @@ func (m Model) updateUserNew(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.userNewGroupsOrder = append(m.userNewGroupsOrder, g.Name)
|
||||
}
|
||||
m.userNewCursor = 0
|
||||
m.userNewProxyJump = false
|
||||
m.screen = scrUserNewGroups
|
||||
return m, nil
|
||||
}
|
||||
@@ -225,12 +227,16 @@ func (m Model) updateUserNewGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
case "down", "j":
|
||||
if m.userNewCursor < len(m.userNewGroupsOrder)-1 {
|
||||
// +1 for the trailing ProxyJump toggle row, always present after
|
||||
// the group checkboxes (see viewUserNewGroups).
|
||||
if m.userNewCursor < len(m.userNewGroupsOrder) {
|
||||
m.userNewCursor++
|
||||
}
|
||||
return m, nil
|
||||
case " ":
|
||||
if len(m.userNewGroupsOrder) > 0 {
|
||||
if m.userNewCursor == len(m.userNewGroupsOrder) {
|
||||
m.userNewProxyJump = !m.userNewProxyJump
|
||||
} else if len(m.userNewGroupsOrder) > 0 {
|
||||
g := m.userNewGroupsOrder[m.userNewCursor]
|
||||
m.userNewGroupsSel[g] = !m.userNewGroupsSel[g]
|
||||
}
|
||||
@@ -245,12 +251,14 @@ func (m Model) updateUserNewGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
if m.userGroupsEditTarget != "" {
|
||||
username := m.userGroupsEditTarget
|
||||
proxyJump := m.userNewProxyJump
|
||||
m.mutate("изменены группы пользователя "+username, func(st *model.State) error {
|
||||
u := st.FindUser(username)
|
||||
if u == nil {
|
||||
return fmt.Errorf("user %s not found in state", username)
|
||||
}
|
||||
u.Groups = groups
|
||||
u.ProxyJump = proxyJump
|
||||
if u.PublicKey != "" && u.Status == model.StatusActive {
|
||||
if err := sysadm.WriteAuthorizedKeys(username, u.PublicKey, st.AllowedForwards(u)); err != nil {
|
||||
return err
|
||||
@@ -260,6 +268,9 @@ func (m Model) updateUserNewGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
})
|
||||
m.logAudit(username, "admin", "groups changed to "+fmt.Sprint(groups))
|
||||
m.userGroupsEditTarget = ""
|
||||
if m.err == "" {
|
||||
m.status = "готово. Если менялся режим ProxyJump/LocalForward или доступ — выдайте пользователю новый конфиг в «Обновить конфиги»."
|
||||
}
|
||||
m.screen = scrUsers
|
||||
m.refreshUsersList()
|
||||
return m, nil
|
||||
@@ -279,6 +290,7 @@ func (m Model) updateUserNewGroups(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
Groups: groups,
|
||||
Status: model.StatusActive,
|
||||
Comment: comment,
|
||||
ProxyJump: m.userNewProxyJump,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
return nil
|
||||
@@ -315,6 +327,17 @@ func (m Model) viewUserNewGroups() string {
|
||||
}
|
||||
body += fmt.Sprintf("%s%s %s\n", cursor, check, g)
|
||||
}
|
||||
|
||||
proxyCursor := " "
|
||||
if m.userNewCursor == len(m.userNewGroupsOrder) {
|
||||
proxyCursor = "> "
|
||||
}
|
||||
proxyCheck := "[ ]"
|
||||
if m.userNewProxyJump {
|
||||
proxyCheck = "[x]"
|
||||
}
|
||||
body += fmt.Sprintf("\n%s%s ProxyJump вместо LocalForward (отдельная команда ssh на каждый сервис, без локальных портов)\n", proxyCursor, proxyCheck)
|
||||
|
||||
body += fmt.Sprintf("\n[space] отметить [enter] %s [esc] назад", confirmHint)
|
||||
return borderStyle.Render(body)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Build output
|
||||
bin/
|
||||
obj/
|
||||
|
||||
# VSTO/ClickOnce artifacts generated at build time (manifests, publish output)
|
||||
*.vsto
|
||||
*.manifest
|
||||
publish/
|
||||
|
||||
# VS user-specific files
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
.vs/
|
||||
|
||||
# Test results
|
||||
TestResults/
|
||||
@@ -0,0 +1,150 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">17.0</VisualStudioVersion>
|
||||
<VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
|
||||
<PropertyGroup>
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||
<ProjectGuid>{F0475AB7-87AA-4E6B-9BF9-1779AD59912E}</ProjectGuid>
|
||||
<OutputType>Library</OutputType>
|
||||
<RootNamespace>BulkArchiveMailer.AddIn</RootNamespace>
|
||||
<AssemblyName>BulkArchiveMailer.AddIn</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<ProjectTypeGuids>{BAA0C2D2-18E2-41B9-852F-F413020CAA33};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
|
||||
<LangVersion>7.3</LangVersion>
|
||||
|
||||
<!-- VSTO / Office add-in specifics -->
|
||||
<OfficeApplication>Outlook</OfficeApplication>
|
||||
<NoStandardLibraries>false</NoStandardLibraries>
|
||||
<VstoAddInTemplateVersion>3</VstoAddInTemplateVersion>
|
||||
<GenerateManifests>true</GenerateManifests>
|
||||
<VSTOFrameworkVer40>true</VSTOFrameworkVer40>
|
||||
|
||||
<!--
|
||||
Signing is deliberately OFF: this is an internal tool, sideloaded by
|
||||
hand (see docs/BUILD.md) rather than published/ClickOnce/GPO
|
||||
deployed, so there is no certificate to manage here. On first load
|
||||
Outlook will show an "unable to verify publisher" style prompt for
|
||||
the unsigned manifest - that's expected; docs/BUILD.md covers how to
|
||||
either accept it or add a Trusted Location. Turning signing on for a
|
||||
wider rollout is documented there as a follow-up, not implemented
|
||||
here - a judgment call, since fabricating a certificate in an
|
||||
environment with no Visual Studio/dotnet SDK to actually sign
|
||||
anything would just be decoration.
|
||||
-->
|
||||
<SignAssembly>false</SignAssembly>
|
||||
<SignManifests>false</SignManifests>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>full</DebugType>
|
||||
<Optimize>false</Optimize>
|
||||
<OutputPath>bin\Debug\</OutputPath>
|
||||
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
<Prefer32Bit>false</Prefer32Bit>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<Optimize>true</Optimize>
|
||||
<OutputPath>bin\Release\</OutputPath>
|
||||
<DefineConstants>TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
<Prefer32Bit>false</Prefer32Bit>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="System" />
|
||||
<Reference Include="System.Core" />
|
||||
<Reference Include="System.Xml" />
|
||||
<Reference Include="System.Xml.Linq" />
|
||||
<Reference Include="System.Drawing" />
|
||||
<Reference Include="System.Windows.Forms" />
|
||||
<Reference Include="Microsoft.CSharp" />
|
||||
|
||||
<!--
|
||||
The Microsoft.Office.* / Microsoft.VisualStudio.Tools.Applications.*
|
||||
references below are resolved from the GAC by the "Office/SharePoint
|
||||
development" Visual Studio workload (Office Developer Tools + VSTO
|
||||
Runtime), same as any project created from the built-in "Outlook VSTO
|
||||
Add-in" template - no HintPath/NuGet package needed for them. If VS
|
||||
reports these as missing, the workload component isn't installed
|
||||
(see docs/BUILD.md).
|
||||
-->
|
||||
<Reference Include="Microsoft.Office.Interop.Outlook, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c">
|
||||
<EmbedInteropTypes>False</EmbedInteropTypes>
|
||||
</Reference>
|
||||
<Reference Include="office, Version=15.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c">
|
||||
<EmbedInteropTypes>False</EmbedInteropTypes>
|
||||
</Reference>
|
||||
<Reference Include="stdole, Version=7.0.3300.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
|
||||
<Reference Include="Microsoft.Office.Tools, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
|
||||
<SpecificVersion>False</SpecificVersion>
|
||||
</Reference>
|
||||
<Reference Include="Microsoft.Office.Tools.Common, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
|
||||
<SpecificVersion>False</SpecificVersion>
|
||||
</Reference>
|
||||
<Reference Include="Microsoft.Office.Tools.Common.v4.0.Utilities, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
|
||||
<SpecificVersion>False</SpecificVersion>
|
||||
</Reference>
|
||||
<Reference Include="Microsoft.Office.Tools.Outlook, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
|
||||
<SpecificVersion>False</SpecificVersion>
|
||||
</Reference>
|
||||
<Reference Include="Microsoft.Office.Tools.v4.0.Framework, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
|
||||
<SpecificVersion>False</SpecificVersion>
|
||||
</Reference>
|
||||
<Reference Include="Microsoft.VisualStudio.Tools.Applications.Runtime, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
|
||||
<SpecificVersion>False</SpecificVersion>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\BulkArchiveMailer.Core\BulkArchiveMailer.Core.csproj">
|
||||
<Project>{96DFBF08-8BCA-4A4E-BDF8-3F013965E30E}</Project>
|
||||
<Name>BulkArchiveMailer.Core</Name>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Globals.cs" />
|
||||
<Compile Include="MailSender.cs" />
|
||||
<Compile Include="RibbonController.cs" />
|
||||
<Compile Include="SignatureReader.cs" />
|
||||
<Compile Include="ThisAddIn.cs" />
|
||||
<Compile Include="ThisAddIn.Designer.cs">
|
||||
<DependentUpon>ThisAddIn.cs</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="UI\SettingsForm.cs">
|
||||
<SubType>Form</SubType>
|
||||
</Compile>
|
||||
<Compile Include="UI\SettingsForm.Designer.cs">
|
||||
<DependentUpon>SettingsForm.cs</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="UI\PreviewForm.cs">
|
||||
<SubType>Form</SubType>
|
||||
</Compile>
|
||||
<Compile Include="UI\PreviewForm.Designer.cs">
|
||||
<DependentUpon>PreviewForm.cs</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="Properties\AssemblyInfo.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Ribbon.xml" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="app.config" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(MSBuildBinPath)\Microsoft.CSharp.targets" />
|
||||
<Import Project="$(VSToolsPath)\OfficeTools\Microsoft.VisualStudio.Tools.Office.targets" Condition="'$(VSToolsPath)' != ''" />
|
||||
<Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\OfficeTools\Microsoft.VisualStudio.Tools.Office.targets" Condition="false" />
|
||||
<!--
|
||||
To modify your build process, add your task inside one of the targets
|
||||
below and uncomment it. Other similar extension points exist, see
|
||||
Microsoft.Common.targets.
|
||||
<Target Name="BeforeBuild"> </Target>
|
||||
<Target Name="AfterBuild"> </Target>
|
||||
-->
|
||||
</Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace BulkArchiveMailer.AddIn
|
||||
{
|
||||
/// <summary>
|
||||
/// Static access point to the running add-in instance, matching the
|
||||
/// shape Visual Studio normally generates for VSTO projects (hand
|
||||
/// written here for the same reason as ThisAddIn.Designer.cs - see
|
||||
/// its header comment).
|
||||
/// </summary>
|
||||
internal static class Globals
|
||||
{
|
||||
internal static ThisAddIn ThisAddIn =>
|
||||
(ThisAddIn)Microsoft.Office.Tools.Outlook.OutlookAddIn.GetInstance(typeof(ThisAddIn));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using BulkArchiveMailer.Core;
|
||||
using Outlook = Microsoft.Office.Interop.Outlook;
|
||||
|
||||
namespace BulkArchiveMailer.AddIn
|
||||
{
|
||||
/// <summary>Per-job result of a send attempt, returned by MailSender so the caller can log + report it without any of this class's own exceptions ever escaping.</summary>
|
||||
public sealed class MailSendOutcome
|
||||
{
|
||||
public SendJob Job { get; set; }
|
||||
public bool Success { get; set; }
|
||||
public string ErrorMessage { get; set; }
|
||||
public bool WasDraft { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates and sends (or, in test mode, saves as a Draft) one Outlook
|
||||
/// mail item per SendJob via Interop. This is the only file that
|
||||
/// actually creates/sends a MailItem - everything else builds up to
|
||||
/// calling SendOne.
|
||||
///
|
||||
/// SendOne never throws: every failure is caught and reported back as
|
||||
/// a failed MailSendOutcome, so a caller looping over a batch can
|
||||
/// always move on to the next job without a try/catch of its own.
|
||||
/// </summary>
|
||||
public sealed class MailSender
|
||||
{
|
||||
private readonly Outlook.Application application;
|
||||
private readonly string signatureHtml;
|
||||
|
||||
/// <param name="application">Live Outlook.Application (from ThisAddIn.Application) used to create mail items.</param>
|
||||
/// <param name="signatureHtml">
|
||||
/// HTML of the operator's default signature, already read from
|
||||
/// disk by SignatureReader (Outlook does not auto-insert a
|
||||
/// signature on programmatically created items - it must be
|
||||
/// appended to HTMLBody by hand). May be null/empty if no
|
||||
/// signature is configured; the mail is then sent without one.
|
||||
/// </param>
|
||||
public MailSender(Outlook.Application application, string signatureHtml)
|
||||
{
|
||||
this.application = application ?? throw new ArgumentNullException(nameof(application));
|
||||
this.signatureHtml = signatureHtml ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <param name="job">Row to send. Caller is responsible for only passing selectable (Ready/AlreadySent), checked rows - this method does not re-validate Status.</param>
|
||||
/// <param name="testMode">When true, the mail item is Save()'d (left in Drafts) instead of Send()'d - the safety net for trying the tool out before trusting it with real recipients.</param>
|
||||
public MailSendOutcome SendOne(SendJob job, bool testMode)
|
||||
{
|
||||
if (job == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(job));
|
||||
}
|
||||
|
||||
Outlook.MailItem mailItem = null;
|
||||
try
|
||||
{
|
||||
mailItem = (Outlook.MailItem)application.CreateItem(Outlook.OlItemType.olMailItem);
|
||||
mailItem.To = job.Email;
|
||||
mailItem.Subject = job.Subject;
|
||||
|
||||
var body = job.BodyHtml ?? string.Empty;
|
||||
if (!string.IsNullOrEmpty(signatureHtml))
|
||||
{
|
||||
body += "<br><br>" + signatureHtml;
|
||||
}
|
||||
|
||||
mailItem.HTMLBody = body;
|
||||
|
||||
if (!string.IsNullOrEmpty(job.ArchivePath))
|
||||
{
|
||||
mailItem.Attachments.Add(job.ArchivePath, Outlook.OlAttachmentType.olByValue, Type.Missing, Type.Missing);
|
||||
}
|
||||
|
||||
if (testMode)
|
||||
{
|
||||
mailItem.Save();
|
||||
}
|
||||
else
|
||||
{
|
||||
mailItem.Send();
|
||||
}
|
||||
|
||||
return new MailSendOutcome { Job = job, Success = true, WasDraft = testMode };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new MailSendOutcome { Job = job, Success = false, ErrorMessage = ex.Message, WasDraft = testMode };
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (mailItem != null)
|
||||
{
|
||||
Marshal.ReleaseComObject(mailItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Reflection;
|
||||
using System.Resources;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
[assembly: AssemblyTitle("BulkArchiveMailer.AddIn")]
|
||||
[assembly: AssemblyDescription("VSTO Outlook add-in: sends ssh-access-tui's per-user .7z archives via a ribbon button.")]
|
||||
[assembly: AssemblyConfiguration("")]
|
||||
[assembly: AssemblyCompany("")]
|
||||
[assembly: AssemblyProduct("BulkArchiveMailer.AddIn")]
|
||||
[assembly: AssemblyCopyright("")]
|
||||
[assembly: AssemblyTrademark("")]
|
||||
[assembly: AssemblyCulture("")]
|
||||
[assembly: NeutralResourcesLanguage("ru")]
|
||||
|
||||
[assembly: ComVisible(false)]
|
||||
[assembly: Guid("7a1c9e2a-4f9a-4d6a-9b8e-1c2d3e4f5a6b")]
|
||||
|
||||
[assembly: AssemblyVersion("1.0.0.0")]
|
||||
[assembly: AssemblyFileVersion("1.0.0.0")]
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<customUI xmlns="http://schemas.microsoft.com/office/2009/07/customui" onLoad="Ribbon_Load">
|
||||
<ribbon>
|
||||
<tabs>
|
||||
<!-- TabMail = the Home tab of the main Outlook Explorer (mail list) window. -->
|
||||
<tab idMso="TabMail">
|
||||
<group id="BulkArchiveMailerGroup" label="ssh-access-tui">
|
||||
<button id="OpenBulkSenderButton"
|
||||
label="Массовая рассылка"
|
||||
size="large"
|
||||
onAction="OnOpenBulkSenderButtonClick"
|
||||
imageMso="MailMerge"
|
||||
screentip="Массовая рассылка архивов доступа"
|
||||
supertip="Сканирует папку с .7z-архивами, сгенерированными ssh-access-tui, и рассылает их получателям с индивидуальным письмом." />
|
||||
</group>
|
||||
</tab>
|
||||
</tabs>
|
||||
</ribbon>
|
||||
</customUI>
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Windows.Forms;
|
||||
using BulkArchiveMailer.AddIn.UI;
|
||||
using BulkArchiveMailer.Core;
|
||||
using Office = Microsoft.Office.Core;
|
||||
|
||||
namespace BulkArchiveMailer.AddIn
|
||||
{
|
||||
/// <summary>
|
||||
/// Backs the single "Массовая рассылка" ribbon button declared in
|
||||
/// Ribbon.xml (raw Ribbon XML + callbacks approach, not the Ribbon
|
||||
/// Designer visual item - keeps everything in one small readable
|
||||
/// file). Wired up via ThisAddIn.CreateRibbonExtensibilityObject().
|
||||
/// </summary>
|
||||
[System.Runtime.InteropServices.ComVisible(true)]
|
||||
public class RibbonController : Office.IRibbonExtensibility
|
||||
{
|
||||
private Office.IRibbonUI ribbon;
|
||||
|
||||
public string GetCustomUI(string ribbonId)
|
||||
{
|
||||
return GetEmbeddedResourceText("BulkArchiveMailer.AddIn.Ribbon.xml");
|
||||
}
|
||||
|
||||
public void Ribbon_Load(Office.IRibbonUI ribbonUi)
|
||||
{
|
||||
ribbon = ribbonUi;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The button's onAction callback. Opens SettingsForm first if the
|
||||
/// archive folder is not configured yet (or does not exist);
|
||||
/// otherwise jumps straight to PreviewForm. Kept deliberately
|
||||
/// simple - see README.md for the reasoning.
|
||||
/// </summary>
|
||||
public void OnOpenBulkSenderButtonClick(Office.IRibbonControl control)
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = AppSettings.Load();
|
||||
var outlookApplication = Globals.ThisAddIn.OutlookApplication;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.ArchiveFolder) || !Directory.Exists(settings.ArchiveFolder))
|
||||
{
|
||||
using (var settingsForm = new SettingsForm(outlookApplication))
|
||||
{
|
||||
settingsForm.ShowDialog();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
using (var previewForm = new PreviewForm(outlookApplication))
|
||||
{
|
||||
previewForm.ShowDialog();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(
|
||||
"Не удалось открыть окно массовой рассылки: " + ex.Message,
|
||||
"BulkArchiveMailer",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetEmbeddedResourceText(string resourceName)
|
||||
{
|
||||
var asm = Assembly.GetExecutingAssembly();
|
||||
foreach (var name in asm.GetManifestResourceNames())
|
||||
{
|
||||
if (string.Equals(name, resourceName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
using (var stream = asm.GetManifestResourceStream(name))
|
||||
{
|
||||
if (stream == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using (var reader = new StreamReader(stream))
|
||||
{
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Microsoft.Win32;
|
||||
using Outlook = Microsoft.Office.Interop.Outlook;
|
||||
|
||||
namespace BulkArchiveMailer.AddIn
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the user's default "new message" Outlook signature HTML from
|
||||
/// disk. Outlook Interop has no API that exposes "the currently
|
||||
/// selected default signature" - the only reliable way to get at it
|
||||
/// is: look up its *name* in the registry
|
||||
/// (HKCU\Software\Microsoft\Office\<version>\Common\MailSettings,
|
||||
/// value "NewSignature"), then load the matching .htm file from
|
||||
/// %APPDATA%\Microsoft\Signatures\<name>.htm ourselves.
|
||||
///
|
||||
/// This is the only file in the solution that touches the Windows
|
||||
/// registry - deliberately kept out of BulkArchiveMailer.Core.
|
||||
/// </summary>
|
||||
public static class SignatureReader
|
||||
{
|
||||
/// <summary>
|
||||
/// Best-effort auto-detection. Never throws - returns null if the
|
||||
/// registry value, the version-specific key, or the .htm file is
|
||||
/// missing for any reason, so the caller can fall back to letting
|
||||
/// the operator pick a signature file manually instead of crashing
|
||||
/// the add-in.
|
||||
/// </summary>
|
||||
public static string TryAutoDetectSignatureHtml(Outlook.Application application)
|
||||
{
|
||||
try
|
||||
{
|
||||
var signatureName = TryReadSignatureName(application);
|
||||
if (string.IsNullOrEmpty(signatureName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var path = ResolveSignaturePath(signatureName);
|
||||
return File.Exists(path) ? File.ReadAllText(path) : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Same lookup, but returns the resolved .htm path instead of its contents - useful for SettingsForm to show/prefill the path.</summary>
|
||||
public static string TryAutoDetectSignaturePath(Outlook.Application application)
|
||||
{
|
||||
try
|
||||
{
|
||||
var signatureName = TryReadSignatureName(application);
|
||||
if (string.IsNullOrEmpty(signatureName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var path = ResolveSignaturePath(signatureName);
|
||||
return File.Exists(path) ? path : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static string ResolveSignaturePath(string signatureName)
|
||||
{
|
||||
var signaturesFolder = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"Microsoft",
|
||||
"Signatures");
|
||||
return Path.Combine(signaturesFolder, signatureName + ".htm");
|
||||
}
|
||||
|
||||
private static string TryReadSignatureName(Outlook.Application application)
|
||||
{
|
||||
var candidateVersions = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
// application.Version looks like "16.0" and lines up
|
||||
// exactly with the Office registry hive layout, so prefer
|
||||
// it over the fixed fallback list below.
|
||||
var version = application?.Version;
|
||||
if (!string.IsNullOrEmpty(version))
|
||||
{
|
||||
candidateVersions.Add(version);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Interop call failed for some reason - fall through to
|
||||
// the fixed list.
|
||||
}
|
||||
|
||||
foreach (var fallback in new[] { "16.0", "15.0", "14.0" })
|
||||
{
|
||||
if (!candidateVersions.Contains(fallback))
|
||||
{
|
||||
candidateVersions.Add(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var version in candidateVersions)
|
||||
{
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(
|
||||
$@"Software\Microsoft\Office\{version}\Common\MailSettings"))
|
||||
{
|
||||
var value = key?.GetValue("NewSignature") as string;
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace BulkArchiveMailer.AddIn
|
||||
{
|
||||
/// <summary>
|
||||
/// Designer-generated half of ThisAddIn. Normally Visual Studio
|
||||
/// regenerates this file's contents automatically the first time the
|
||||
/// project is opened/built (it derives from
|
||||
/// Microsoft.Office.Tools.Outlook.OutlookAddIn and wires Startup/
|
||||
/// Shutdown) - it is hand-written here only because this environment
|
||||
/// has no VSTO tooling to run the code generator. Do not hand-edit
|
||||
/// once opened in VS if the IDE regenerates it differently.
|
||||
/// </summary>
|
||||
partial class ThisAddIn : Microsoft.Office.Tools.Outlook.OutlookAddIn
|
||||
{
|
||||
/// <summary>
|
||||
/// Required method for Designer support - do not modify the
|
||||
/// contents of this method with the code editor.
|
||||
/// </summary>
|
||||
private void InternalStartup()
|
||||
{
|
||||
this.Startup += new System.EventHandler(ThisAddIn_Startup);
|
||||
this.Shutdown += new System.EventHandler(ThisAddIn_Shutdown);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using Office = Microsoft.Office.Core;
|
||||
using Outlook = Microsoft.Office.Interop.Outlook;
|
||||
|
||||
namespace BulkArchiveMailer.AddIn
|
||||
{
|
||||
public partial class ThisAddIn
|
||||
{
|
||||
/// <summary>Convenience alias for the inherited Application property, so RibbonController/forms don't need to know the base class's exact property name.</summary>
|
||||
internal Outlook.Application OutlookApplication => this.Application;
|
||||
|
||||
private void ThisAddIn_Startup(object sender, EventArgs e)
|
||||
{
|
||||
// Deliberately does nothing eagerly: no settings/folder/registry
|
||||
// access happens until the operator clicks the ribbon button,
|
||||
// so add-in load never slows down or blocks Outlook startup.
|
||||
}
|
||||
|
||||
private void ThisAddIn_Shutdown(object sender, EventArgs e)
|
||||
{
|
||||
// Outlook typically no longer raises this reliably before
|
||||
// process exit; nothing here needs cleanup anyway (no open
|
||||
// handles/timers are kept alive between ribbon clicks).
|
||||
}
|
||||
|
||||
/// <summary>Wires up RibbonController (raw Ribbon XML + callbacks, see Ribbon.xml) as this add-in's ribbon UI.</summary>
|
||||
protected override Office.IRibbonExtensibility CreateRibbonExtensibilityObject()
|
||||
{
|
||||
return new RibbonController();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
namespace BulkArchiveMailer.AddIn.UI
|
||||
{
|
||||
partial class PreviewForm
|
||||
{
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && components != null)
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private System.Windows.Forms.Label lblPasswordReminder;
|
||||
private System.Windows.Forms.Label lblSignatureWarning;
|
||||
private System.Windows.Forms.DataGridView grid;
|
||||
private System.Windows.Forms.DataGridViewCheckBoxColumn colSelect;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn colLogin;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn colEmail;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn colKind;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn colFile;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn colStatus;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn colComment;
|
||||
|
||||
private System.Windows.Forms.Label lblSummary;
|
||||
private System.Windows.Forms.CheckBox chkTestMode;
|
||||
private System.Windows.Forms.Button btnCheckAllReady;
|
||||
private System.Windows.Forms.Button btnUncheckAll;
|
||||
private System.Windows.Forms.Button btnRescan;
|
||||
|
||||
private System.Windows.Forms.ProgressBar progressSend;
|
||||
private System.Windows.Forms.Label lblProgressStatus;
|
||||
|
||||
private System.Windows.Forms.Button btnSend;
|
||||
private System.Windows.Forms.Button btnClose;
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.lblPasswordReminder = new System.Windows.Forms.Label();
|
||||
this.lblSignatureWarning = new System.Windows.Forms.Label();
|
||||
|
||||
this.grid = new System.Windows.Forms.DataGridView();
|
||||
this.colSelect = new System.Windows.Forms.DataGridViewCheckBoxColumn();
|
||||
this.colLogin = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
this.colEmail = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
this.colKind = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
this.colFile = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
this.colStatus = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
this.colComment = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
|
||||
this.lblSummary = new System.Windows.Forms.Label();
|
||||
this.chkTestMode = new System.Windows.Forms.CheckBox();
|
||||
this.btnCheckAllReady = new System.Windows.Forms.Button();
|
||||
this.btnUncheckAll = new System.Windows.Forms.Button();
|
||||
this.btnRescan = new System.Windows.Forms.Button();
|
||||
|
||||
this.progressSend = new System.Windows.Forms.ProgressBar();
|
||||
this.lblProgressStatus = new System.Windows.Forms.Label();
|
||||
|
||||
this.btnSend = new System.Windows.Forms.Button();
|
||||
this.btnClose = new System.Windows.Forms.Button();
|
||||
|
||||
((System.ComponentModel.ISupportInitialize)(this.grid)).BeginInit();
|
||||
this.SuspendLayout();
|
||||
|
||||
//
|
||||
// lblPasswordReminder
|
||||
//
|
||||
this.lblPasswordReminder.Location = new System.Drawing.Point(12, 10);
|
||||
this.lblPasswordReminder.Size = new System.Drawing.Size(900, 20);
|
||||
this.lblPasswordReminder.Text = "Напоминание: пароль от архивов сообщается получателям отдельно (вручную, не через это письмо и не через этот инструмент).";
|
||||
this.lblPasswordReminder.ForeColor = System.Drawing.Color.DarkRed;
|
||||
this.lblPasswordReminder.Font = new System.Drawing.Font(this.lblPasswordReminder.Font, System.Drawing.FontStyle.Bold);
|
||||
this.lblPasswordReminder.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)));
|
||||
|
||||
//
|
||||
// lblSignatureWarning
|
||||
//
|
||||
this.lblSignatureWarning.Location = new System.Drawing.Point(12, 32);
|
||||
this.lblSignatureWarning.Size = new System.Drawing.Size(900, 20);
|
||||
this.lblSignatureWarning.Text = "Подпись не найдена — письма будут отправлены без подписи. Настройте .htm-файл вручную в Настройках.";
|
||||
this.lblSignatureWarning.ForeColor = System.Drawing.Color.DarkOrange;
|
||||
this.lblSignatureWarning.Visible = false;
|
||||
this.lblSignatureWarning.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)));
|
||||
|
||||
//
|
||||
// grid
|
||||
//
|
||||
this.grid.Location = new System.Drawing.Point(12, 58);
|
||||
this.grid.Size = new System.Drawing.Size(900, 420);
|
||||
this.grid.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.grid.AllowUserToAddRows = false;
|
||||
this.grid.AllowUserToDeleteRows = false;
|
||||
this.grid.AllowUserToResizeRows = false;
|
||||
this.grid.RowHeadersVisible = false;
|
||||
this.grid.AutoGenerateColumns = false;
|
||||
this.grid.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect;
|
||||
this.grid.MultiSelect = false;
|
||||
this.grid.EditMode = System.Windows.Forms.DataGridViewEditMode.EditOnEnter;
|
||||
this.grid.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] {
|
||||
this.colSelect,
|
||||
this.colLogin,
|
||||
this.colEmail,
|
||||
this.colKind,
|
||||
this.colFile,
|
||||
this.colStatus,
|
||||
this.colComment});
|
||||
this.grid.CurrentCellDirtyStateChanged += new System.EventHandler(this.grid_CurrentCellDirtyStateChanged);
|
||||
this.grid.CellValueChanged += new System.Windows.Forms.DataGridViewCellEventHandler(this.grid_CellValueChanged);
|
||||
this.grid.DataBindingComplete += new System.Windows.Forms.DataGridViewBindingCompleteEventHandler(this.grid_DataBindingComplete);
|
||||
|
||||
// colSelect
|
||||
this.colSelect.Name = "colSelect";
|
||||
this.colSelect.HeaderText = "Отправить";
|
||||
this.colSelect.DataPropertyName = "IsChecked";
|
||||
this.colSelect.Width = 70;
|
||||
this.colSelect.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
|
||||
|
||||
// colLogin
|
||||
this.colLogin.Name = "colLogin";
|
||||
this.colLogin.HeaderText = "Логин";
|
||||
this.colLogin.DataPropertyName = "Login";
|
||||
this.colLogin.ReadOnly = true;
|
||||
this.colLogin.Width = 100;
|
||||
this.colLogin.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
|
||||
|
||||
// colEmail
|
||||
this.colEmail.Name = "colEmail";
|
||||
this.colEmail.HeaderText = "Получатель";
|
||||
this.colEmail.DataPropertyName = "Email";
|
||||
this.colEmail.ReadOnly = true;
|
||||
this.colEmail.Width = 190;
|
||||
this.colEmail.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
|
||||
|
||||
// colKind
|
||||
this.colKind.Name = "colKind";
|
||||
this.colKind.HeaderText = "Вид архива";
|
||||
this.colKind.DataPropertyName = "KindDisplay";
|
||||
this.colKind.ReadOnly = true;
|
||||
this.colKind.Width = 170;
|
||||
this.colKind.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
|
||||
|
||||
// colFile
|
||||
this.colFile.Name = "colFile";
|
||||
this.colFile.HeaderText = "Файл";
|
||||
this.colFile.DataPropertyName = "FileName";
|
||||
this.colFile.ReadOnly = true;
|
||||
this.colFile.Width = 190;
|
||||
this.colFile.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
|
||||
|
||||
// colStatus
|
||||
this.colStatus.Name = "colStatus";
|
||||
this.colStatus.HeaderText = "Статус";
|
||||
this.colStatus.DataPropertyName = "StatusDisplay";
|
||||
this.colStatus.ReadOnly = true;
|
||||
this.colStatus.Width = 120;
|
||||
this.colStatus.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
|
||||
|
||||
// colComment
|
||||
this.colComment.Name = "colComment";
|
||||
this.colComment.HeaderText = "Комментарий";
|
||||
this.colComment.DataPropertyName = "Comment";
|
||||
this.colComment.ReadOnly = true;
|
||||
this.colComment.AutoSizeMode = System.Windows.Forms.DataGridViewAutoSizeColumnMode.Fill;
|
||||
this.colComment.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
|
||||
|
||||
//
|
||||
// lblSummary
|
||||
//
|
||||
this.lblSummary.Location = new System.Drawing.Point(12, 486);
|
||||
this.lblSummary.Size = new System.Drawing.Size(500, 20);
|
||||
this.lblSummary.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
|
||||
//
|
||||
// chkTestMode
|
||||
//
|
||||
this.chkTestMode.Location = new System.Drawing.Point(12, 510);
|
||||
this.chkTestMode.Size = new System.Drawing.Size(360, 24);
|
||||
this.chkTestMode.Text = "Тестовый режим (сохранять в Черновики, не отправлять)";
|
||||
this.chkTestMode.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.chkTestMode.UseVisualStyleBackColor = true;
|
||||
|
||||
//
|
||||
// btnRescan
|
||||
//
|
||||
this.btnRescan.Location = new System.Drawing.Point(12, 540);
|
||||
this.btnRescan.Size = new System.Drawing.Size(110, 27);
|
||||
this.btnRescan.Text = "Пересканировать";
|
||||
this.btnRescan.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.btnRescan.UseVisualStyleBackColor = true;
|
||||
this.btnRescan.Click += new System.EventHandler(this.btnRescan_Click);
|
||||
|
||||
//
|
||||
// btnCheckAllReady
|
||||
//
|
||||
this.btnCheckAllReady.Location = new System.Drawing.Point(130, 540);
|
||||
this.btnCheckAllReady.Size = new System.Drawing.Size(150, 27);
|
||||
this.btnCheckAllReady.Text = "Отметить все готовые";
|
||||
this.btnCheckAllReady.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.btnCheckAllReady.UseVisualStyleBackColor = true;
|
||||
this.btnCheckAllReady.Click += new System.EventHandler(this.btnCheckAllReady_Click);
|
||||
|
||||
//
|
||||
// btnUncheckAll
|
||||
//
|
||||
this.btnUncheckAll.Location = new System.Drawing.Point(288, 540);
|
||||
this.btnUncheckAll.Size = new System.Drawing.Size(120, 27);
|
||||
this.btnUncheckAll.Text = "Снять все отметки";
|
||||
this.btnUncheckAll.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.btnUncheckAll.UseVisualStyleBackColor = true;
|
||||
this.btnUncheckAll.Click += new System.EventHandler(this.btnUncheckAll_Click);
|
||||
|
||||
//
|
||||
// progressSend
|
||||
//
|
||||
this.progressSend.Location = new System.Drawing.Point(430, 543);
|
||||
this.progressSend.Size = new System.Drawing.Size(300, 20);
|
||||
this.progressSend.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
|
||||
//
|
||||
// lblProgressStatus
|
||||
//
|
||||
this.lblProgressStatus.Location = new System.Drawing.Point(430, 565);
|
||||
this.lblProgressStatus.Size = new System.Drawing.Size(482, 20);
|
||||
this.lblProgressStatus.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
|
||||
//
|
||||
// btnClose
|
||||
//
|
||||
this.btnClose.Location = new System.Drawing.Point(720, 540);
|
||||
this.btnClose.Size = new System.Drawing.Size(90, 30);
|
||||
this.btnClose.Text = "Закрыть";
|
||||
this.btnClose.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.btnClose.UseVisualStyleBackColor = true;
|
||||
this.btnClose.DialogResult = System.Windows.Forms.DialogResult.Cancel;
|
||||
this.btnClose.Click += new System.EventHandler(this.btnClose_Click);
|
||||
|
||||
//
|
||||
// btnSend
|
||||
//
|
||||
this.btnSend.Location = new System.Drawing.Point(816, 540);
|
||||
this.btnSend.Size = new System.Drawing.Size(96, 30);
|
||||
this.btnSend.Text = "Отправить";
|
||||
this.btnSend.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.btnSend.Font = new System.Drawing.Font(this.btnSend.Font, System.Drawing.FontStyle.Bold);
|
||||
this.btnSend.UseVisualStyleBackColor = true;
|
||||
this.btnSend.Click += new System.EventHandler(this.btnSend_Click);
|
||||
|
||||
//
|
||||
// PreviewForm
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(924, 582);
|
||||
this.MinimumSize = new System.Drawing.Size(760, 420);
|
||||
this.Controls.Add(this.lblPasswordReminder);
|
||||
this.Controls.Add(this.lblSignatureWarning);
|
||||
this.Controls.Add(this.grid);
|
||||
this.Controls.Add(this.lblSummary);
|
||||
this.Controls.Add(this.chkTestMode);
|
||||
this.Controls.Add(this.btnRescan);
|
||||
this.Controls.Add(this.btnCheckAllReady);
|
||||
this.Controls.Add(this.btnUncheckAll);
|
||||
this.Controls.Add(this.progressSend);
|
||||
this.Controls.Add(this.lblProgressStatus);
|
||||
this.Controls.Add(this.btnClose);
|
||||
this.Controls.Add(this.btnSend);
|
||||
this.Text = "BulkArchiveMailer — предпросмотр и рассылка";
|
||||
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
|
||||
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.Sizable;
|
||||
this.MaximizeBox = true;
|
||||
this.CancelButton = this.btnClose;
|
||||
|
||||
((System.ComponentModel.ISupportInitialize)(this.grid)).EndInit();
|
||||
this.ResumeLayout(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
using BulkArchiveMailer.Core;
|
||||
using Outlook = Microsoft.Office.Interop.Outlook;
|
||||
|
||||
namespace BulkArchiveMailer.AddIn.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Scans the configured archive folder, shows one row per login (plus
|
||||
/// unrecognized files) in a grid, and sends the checked rows through
|
||||
/// MailSender. This is the only form that actually touches Outlook
|
||||
/// COM (via the Outlook.Application passed into its constructor) or
|
||||
/// writes to the send log.
|
||||
/// </summary>
|
||||
public partial class PreviewForm : Form
|
||||
{
|
||||
private readonly Outlook.Application outlookApplication;
|
||||
|
||||
private AppSettingsData settings;
|
||||
private SendLogStore sendLog;
|
||||
private string signatureHtml;
|
||||
private BindingList<PreviewRow> rows;
|
||||
|
||||
public PreviewForm(Outlook.Application outlookApplication)
|
||||
{
|
||||
InitializeComponent();
|
||||
this.outlookApplication = outlookApplication ?? throw new ArgumentNullException(nameof(outlookApplication));
|
||||
Load += PreviewForm_Load;
|
||||
}
|
||||
|
||||
private void PreviewForm_Load(object sender, EventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
settings = AppSettings.Load();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, "Не удалось прочитать настройки: " + ex.Message, "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
Close();
|
||||
return;
|
||||
}
|
||||
|
||||
chkTestMode.Checked = settings.TestModeDefault;
|
||||
|
||||
signatureHtml = ResolveSignatureHtml();
|
||||
lblSignatureWarning.Visible = string.IsNullOrEmpty(signatureHtml);
|
||||
|
||||
RunScan();
|
||||
}
|
||||
|
||||
private string ResolveSignatureHtml()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(settings.SignatureHtmlPath))
|
||||
{
|
||||
return File.Exists(settings.SignatureHtmlPath) ? File.ReadAllText(settings.SignatureHtmlPath) : null;
|
||||
}
|
||||
|
||||
return SignatureReader.TryAutoDetectSignatureHtml(outlookApplication);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null; // never block preview over a signature problem
|
||||
}
|
||||
}
|
||||
|
||||
private void RunScan()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(settings.ArchiveFolder) || !Directory.Exists(settings.ArchiveFolder))
|
||||
{
|
||||
MessageBox.Show(
|
||||
this,
|
||||
"Папка с архивами не настроена или не существует:\n" + settings.ArchiveFolder,
|
||||
"BulkArchiveMailer",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Error);
|
||||
Close();
|
||||
return;
|
||||
}
|
||||
|
||||
var logPath = Path.Combine(settings.ArchiveFolder, "send-log.csv");
|
||||
sendLog = new SendLogStore(logPath);
|
||||
|
||||
var templates = settings.ToTemplateSet();
|
||||
var scan = ArchiveScanner.Scan(settings.ArchiveFolder, settings.EmailDomain, templates, sendLog);
|
||||
|
||||
rows = new BindingList<PreviewRow>(scan.AllRows.Select(job => new PreviewRow(job)).ToList());
|
||||
grid.DataSource = rows;
|
||||
|
||||
UpdateSummaryLabel();
|
||||
}
|
||||
|
||||
private void ApplyRowStyles()
|
||||
{
|
||||
foreach (DataGridViewRow row in grid.Rows)
|
||||
{
|
||||
if (!(row.DataBoundItem is PreviewRow previewRow))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
row.Cells[colSelect.Index].ReadOnly = !previewRow.Job.IsSelectable;
|
||||
|
||||
switch (previewRow.Job.Status)
|
||||
{
|
||||
case SendStatus.Error:
|
||||
row.DefaultCellStyle.BackColor = System.Drawing.Color.MistyRose;
|
||||
break;
|
||||
case SendStatus.Skipped:
|
||||
row.DefaultCellStyle.BackColor = System.Drawing.Color.WhiteSmoke;
|
||||
row.DefaultCellStyle.ForeColor = System.Drawing.Color.Gray;
|
||||
break;
|
||||
case SendStatus.AlreadySent:
|
||||
row.DefaultCellStyle.BackColor = System.Drawing.Color.LightYellow;
|
||||
break;
|
||||
default:
|
||||
row.DefaultCellStyle.BackColor = grid.DefaultCellStyle.BackColor;
|
||||
row.DefaultCellStyle.ForeColor = grid.DefaultCellStyle.ForeColor;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateSummaryLabel()
|
||||
{
|
||||
var ready = rows.Count(r => r.Job.Status == SendStatus.Ready);
|
||||
var already = rows.Count(r => r.Job.Status == SendStatus.AlreadySent);
|
||||
var errors = rows.Count(r => r.Job.Status == SendStatus.Error);
|
||||
var skipped = rows.Count(r => r.Job.Status == SendStatus.Skipped);
|
||||
var checkedCount = rows.Count(r => r.IsChecked);
|
||||
|
||||
lblSummary.Text = $"Готово: {ready} Уже отправлено: {already} Ошибки: {errors} Пропущено: {skipped} | Отмечено к отправке: {checkedCount}";
|
||||
}
|
||||
|
||||
private void grid_DataBindingComplete(object sender, DataGridViewBindingCompleteEventArgs e)
|
||||
{
|
||||
ApplyRowStyles();
|
||||
UpdateSummaryLabel();
|
||||
}
|
||||
|
||||
private void grid_CurrentCellDirtyStateChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (grid.IsCurrentCellDirty && grid.CurrentCell is DataGridViewCheckBoxCell)
|
||||
{
|
||||
grid.CommitEdit(DataGridViewDataErrorContexts.Commit);
|
||||
}
|
||||
}
|
||||
|
||||
private void grid_CellValueChanged(object sender, DataGridViewCellEventArgs e)
|
||||
{
|
||||
UpdateSummaryLabel();
|
||||
}
|
||||
|
||||
private void btnRescan_Click(object sender, EventArgs e)
|
||||
{
|
||||
RunScan();
|
||||
}
|
||||
|
||||
private void btnCheckAllReady_Click(object sender, EventArgs e)
|
||||
{
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (row.Job.IsSelectable)
|
||||
{
|
||||
row.IsChecked = true;
|
||||
}
|
||||
}
|
||||
|
||||
grid.Refresh();
|
||||
UpdateSummaryLabel();
|
||||
}
|
||||
|
||||
private void btnUncheckAll_Click(object sender, EventArgs e)
|
||||
{
|
||||
foreach (var row in rows)
|
||||
{
|
||||
row.IsChecked = false;
|
||||
}
|
||||
|
||||
grid.Refresh();
|
||||
UpdateSummaryLabel();
|
||||
}
|
||||
|
||||
private void btnClose_Click(object sender, EventArgs e)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
|
||||
private void btnSend_Click(object sender, EventArgs e)
|
||||
{
|
||||
var toSend = rows.Where(r => r.IsChecked && r.Job.IsSelectable).ToList();
|
||||
if (toSend.Count == 0)
|
||||
{
|
||||
MessageBox.Show(this, "Не выбрано ни одной строки для отправки.", "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
var testMode = chkTestMode.Checked;
|
||||
var confirmText = testMode
|
||||
? $"Сохранить {toSend.Count} писем в Черновики (тестовый режим, реальная отправка НЕ произойдёт)?"
|
||||
: $"Отправить {toSend.Count} писем? Это реальная отправка получателям.";
|
||||
if (MessageBox.Show(this, confirmText, "Подтверждение", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
btnSend.Enabled = false;
|
||||
btnRescan.Enabled = false;
|
||||
progressSend.Minimum = 0;
|
||||
progressSend.Maximum = toSend.Count;
|
||||
progressSend.Value = 0;
|
||||
|
||||
var mailSender = new MailSender(outlookApplication, signatureHtml);
|
||||
var sentCount = 0;
|
||||
var failedCount = 0;
|
||||
|
||||
foreach (var row in toSend)
|
||||
{
|
||||
lblProgressStatus.Text = $"Обработка: {row.Job.Email} ({row.Job.KindDisplay})...";
|
||||
// No async/await here on purpose: Outlook COM objects are
|
||||
// STA-thread-affine, and MailSender.SendOne must run on
|
||||
// this UI thread. DoEvents keeps the form responsive
|
||||
// (progress bar, ability to see the current item) during
|
||||
// what is otherwise a synchronous per-item loop.
|
||||
Application.DoEvents();
|
||||
|
||||
var outcome = mailSender.SendOne(row.Job, testMode);
|
||||
|
||||
try
|
||||
{
|
||||
sendLog.Append(new SendLogEntry
|
||||
{
|
||||
TimestampUtc = DateTime.UtcNow,
|
||||
Login = row.Job.Login,
|
||||
Email = row.Job.Email,
|
||||
Kind = row.Job.Kind ?? default,
|
||||
ArchivePath = row.Job.ArchivePath ?? "",
|
||||
FileSha256 = row.Job.FileSha256 ?? "",
|
||||
Status = outcome.Success ? (testMode ? SendLogStatus.Draft : SendLogStatus.Sent) : SendLogStatus.Failed,
|
||||
Error = outcome.Success ? "" : outcome.ErrorMessage,
|
||||
});
|
||||
}
|
||||
catch (Exception logEx)
|
||||
{
|
||||
// A logging failure must not be confused with a send
|
||||
// failure, but it also must not silently disappear -
|
||||
// surface it in the comment column.
|
||||
row.ResultText = (outcome.Success ? "Отправлено, но" : "Ошибка отправки, и") + " не удалось записать в журнал: " + logEx.Message;
|
||||
row.IsChecked = false;
|
||||
grid.Refresh();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (outcome.Success)
|
||||
{
|
||||
sentCount++;
|
||||
row.ResultText = testMode ? "Сохранено в Черновики" : "Отправлено";
|
||||
}
|
||||
else
|
||||
{
|
||||
failedCount++;
|
||||
row.ResultText = "Ошибка: " + outcome.ErrorMessage;
|
||||
}
|
||||
|
||||
row.IsChecked = false;
|
||||
progressSend.Value++;
|
||||
grid.Refresh();
|
||||
}
|
||||
|
||||
btnSend.Enabled = true;
|
||||
btnRescan.Enabled = true;
|
||||
lblProgressStatus.Text = "Готово.";
|
||||
UpdateSummaryLabel();
|
||||
|
||||
MessageBox.Show(
|
||||
this,
|
||||
$"Отправлено/сохранено: {sentCount}\nОшибок: {failedCount}\n\nЖурнал: {sendLog.CsvPath}",
|
||||
"Массовая рассылка — итог",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Information);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UI-only wrapper around a Core SendJob: adds the checkbox state
|
||||
/// and post-send result text, neither of which belong in the
|
||||
/// portable Core model.
|
||||
/// </summary>
|
||||
private sealed class PreviewRow
|
||||
{
|
||||
public PreviewRow(SendJob job)
|
||||
{
|
||||
Job = job;
|
||||
// AlreadySent starts unchecked by default (per spec) but
|
||||
// remains selectable if the operator wants to resend it
|
||||
// anyway (e.g. recipient claims they never got it).
|
||||
IsChecked = job.Status == SendStatus.Ready;
|
||||
}
|
||||
|
||||
public SendJob Job { get; }
|
||||
|
||||
public bool IsChecked { get; set; }
|
||||
|
||||
public string ResultText { get; set; } = "";
|
||||
|
||||
public string Login => Job.Login;
|
||||
|
||||
public string Email => Job.Email;
|
||||
|
||||
public string KindDisplay => Job.KindDisplay;
|
||||
|
||||
public string FileName => Job.FileName;
|
||||
|
||||
public string StatusDisplay => ToRussian(Job.Status);
|
||||
|
||||
public string Comment => !string.IsNullOrEmpty(ResultText) ? ResultText : (Job.ErrorMessage ?? "");
|
||||
|
||||
private static string ToRussian(SendStatus status)
|
||||
{
|
||||
switch (status)
|
||||
{
|
||||
case SendStatus.Ready:
|
||||
return "Готово";
|
||||
case SendStatus.AlreadySent:
|
||||
return "Уже отправлено";
|
||||
case SendStatus.Error:
|
||||
return "Ошибка";
|
||||
case SendStatus.Skipped:
|
||||
return "Пропущено";
|
||||
default:
|
||||
return status.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
namespace BulkArchiveMailer.AddIn.UI
|
||||
{
|
||||
partial class SettingsForm
|
||||
{
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && components != null)
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private System.Windows.Forms.TabControl tabControl;
|
||||
private System.Windows.Forms.TabPage tabGeneral;
|
||||
private System.Windows.Forms.TabPage tabSshAccess;
|
||||
private System.Windows.Forms.TabPage tabConfigUpdate;
|
||||
|
||||
private System.Windows.Forms.Label lblArchiveFolder;
|
||||
private System.Windows.Forms.TextBox txtArchiveFolder;
|
||||
private System.Windows.Forms.Button btnBrowseFolder;
|
||||
|
||||
private System.Windows.Forms.Label lblDomain;
|
||||
private System.Windows.Forms.TextBox txtDomain;
|
||||
|
||||
private System.Windows.Forms.Label lblSignaturePath;
|
||||
private System.Windows.Forms.TextBox txtSignaturePath;
|
||||
private System.Windows.Forms.Button btnBrowseSignature;
|
||||
private System.Windows.Forms.Button btnAutoDetectSignature;
|
||||
|
||||
private System.Windows.Forms.CheckBox chkTestModeDefault;
|
||||
|
||||
private System.Windows.Forms.Label lblSshSubject;
|
||||
private System.Windows.Forms.TextBox txtSshSubject;
|
||||
private System.Windows.Forms.Label lblSshBody;
|
||||
private System.Windows.Forms.TextBox txtSshBody;
|
||||
private System.Windows.Forms.Label lblSshHint;
|
||||
|
||||
private System.Windows.Forms.Label lblCfgSubject;
|
||||
private System.Windows.Forms.TextBox txtCfgSubject;
|
||||
private System.Windows.Forms.Label lblCfgBody;
|
||||
private System.Windows.Forms.TextBox txtCfgBody;
|
||||
private System.Windows.Forms.Label lblCfgHint;
|
||||
|
||||
private System.Windows.Forms.Button btnSave;
|
||||
private System.Windows.Forms.Button btnCancel;
|
||||
private System.Windows.Forms.Button btnResetDefaults;
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.tabControl = new System.Windows.Forms.TabControl();
|
||||
this.tabGeneral = new System.Windows.Forms.TabPage();
|
||||
this.tabSshAccess = new System.Windows.Forms.TabPage();
|
||||
this.tabConfigUpdate = new System.Windows.Forms.TabPage();
|
||||
|
||||
this.lblArchiveFolder = new System.Windows.Forms.Label();
|
||||
this.txtArchiveFolder = new System.Windows.Forms.TextBox();
|
||||
this.btnBrowseFolder = new System.Windows.Forms.Button();
|
||||
|
||||
this.lblDomain = new System.Windows.Forms.Label();
|
||||
this.txtDomain = new System.Windows.Forms.TextBox();
|
||||
|
||||
this.lblSignaturePath = new System.Windows.Forms.Label();
|
||||
this.txtSignaturePath = new System.Windows.Forms.TextBox();
|
||||
this.btnBrowseSignature = new System.Windows.Forms.Button();
|
||||
this.btnAutoDetectSignature = new System.Windows.Forms.Button();
|
||||
|
||||
this.chkTestModeDefault = new System.Windows.Forms.CheckBox();
|
||||
|
||||
this.lblSshSubject = new System.Windows.Forms.Label();
|
||||
this.txtSshSubject = new System.Windows.Forms.TextBox();
|
||||
this.lblSshBody = new System.Windows.Forms.Label();
|
||||
this.txtSshBody = new System.Windows.Forms.TextBox();
|
||||
this.lblSshHint = new System.Windows.Forms.Label();
|
||||
|
||||
this.lblCfgSubject = new System.Windows.Forms.Label();
|
||||
this.txtCfgSubject = new System.Windows.Forms.TextBox();
|
||||
this.lblCfgBody = new System.Windows.Forms.Label();
|
||||
this.txtCfgBody = new System.Windows.Forms.TextBox();
|
||||
this.lblCfgHint = new System.Windows.Forms.Label();
|
||||
|
||||
this.btnSave = new System.Windows.Forms.Button();
|
||||
this.btnCancel = new System.Windows.Forms.Button();
|
||||
this.btnResetDefaults = new System.Windows.Forms.Button();
|
||||
|
||||
this.tabControl.SuspendLayout();
|
||||
this.tabGeneral.SuspendLayout();
|
||||
this.tabSshAccess.SuspendLayout();
|
||||
this.tabConfigUpdate.SuspendLayout();
|
||||
this.SuspendLayout();
|
||||
|
||||
//
|
||||
// tabControl
|
||||
//
|
||||
this.tabControl.Location = new System.Drawing.Point(12, 12);
|
||||
this.tabControl.Size = new System.Drawing.Size(660, 470);
|
||||
this.tabControl.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.tabControl.TabPages.AddRange(new System.Windows.Forms.TabPage[] {
|
||||
this.tabGeneral,
|
||||
this.tabSshAccess,
|
||||
this.tabConfigUpdate});
|
||||
|
||||
//
|
||||
// tabGeneral
|
||||
//
|
||||
this.tabGeneral.Text = "Общие";
|
||||
this.tabGeneral.Padding = new System.Windows.Forms.Padding(12);
|
||||
this.tabGeneral.UseVisualStyleBackColor = true;
|
||||
this.tabGeneral.Controls.Add(this.lblArchiveFolder);
|
||||
this.tabGeneral.Controls.Add(this.txtArchiveFolder);
|
||||
this.tabGeneral.Controls.Add(this.btnBrowseFolder);
|
||||
this.tabGeneral.Controls.Add(this.lblDomain);
|
||||
this.tabGeneral.Controls.Add(this.txtDomain);
|
||||
this.tabGeneral.Controls.Add(this.lblSignaturePath);
|
||||
this.tabGeneral.Controls.Add(this.txtSignaturePath);
|
||||
this.tabGeneral.Controls.Add(this.btnBrowseSignature);
|
||||
this.tabGeneral.Controls.Add(this.btnAutoDetectSignature);
|
||||
this.tabGeneral.Controls.Add(this.chkTestModeDefault);
|
||||
|
||||
// lblArchiveFolder
|
||||
this.lblArchiveFolder.Location = new System.Drawing.Point(15, 20);
|
||||
this.lblArchiveFolder.Size = new System.Drawing.Size(400, 20);
|
||||
this.lblArchiveFolder.Text = "Папка с архивами ssh-access-tui (общий выходной каталог):";
|
||||
|
||||
// txtArchiveFolder
|
||||
this.txtArchiveFolder.Location = new System.Drawing.Point(15, 43);
|
||||
this.txtArchiveFolder.Size = new System.Drawing.Size(500, 23);
|
||||
|
||||
// btnBrowseFolder
|
||||
this.btnBrowseFolder.Location = new System.Drawing.Point(525, 41);
|
||||
this.btnBrowseFolder.Size = new System.Drawing.Size(100, 25);
|
||||
this.btnBrowseFolder.Text = "Обзор...";
|
||||
this.btnBrowseFolder.UseVisualStyleBackColor = true;
|
||||
this.btnBrowseFolder.Click += new System.EventHandler(this.btnBrowseFolder_Click);
|
||||
|
||||
// lblDomain
|
||||
this.lblDomain.Location = new System.Drawing.Point(15, 85);
|
||||
this.lblDomain.Size = new System.Drawing.Size(400, 20);
|
||||
this.lblDomain.Text = "Домен электронной почты (login → login@домен):";
|
||||
|
||||
// txtDomain
|
||||
this.txtDomain.Location = new System.Drawing.Point(15, 108);
|
||||
this.txtDomain.Size = new System.Drawing.Size(300, 23);
|
||||
|
||||
// lblSignaturePath
|
||||
this.lblSignaturePath.Location = new System.Drawing.Point(15, 150);
|
||||
this.lblSignaturePath.Size = new System.Drawing.Size(610, 20);
|
||||
this.lblSignaturePath.Text = "Файл подписи (.htm). Пусто = определять автоматически по умолчанию Outlook при каждой отправке:";
|
||||
|
||||
// txtSignaturePath
|
||||
this.txtSignaturePath.Location = new System.Drawing.Point(15, 173);
|
||||
this.txtSignaturePath.Size = new System.Drawing.Size(500, 23);
|
||||
|
||||
// btnBrowseSignature
|
||||
this.btnBrowseSignature.Location = new System.Drawing.Point(525, 171);
|
||||
this.btnBrowseSignature.Size = new System.Drawing.Size(100, 25);
|
||||
this.btnBrowseSignature.Text = "Обзор...";
|
||||
this.btnBrowseSignature.UseVisualStyleBackColor = true;
|
||||
this.btnBrowseSignature.Click += new System.EventHandler(this.btnBrowseSignature_Click);
|
||||
|
||||
// btnAutoDetectSignature
|
||||
this.btnAutoDetectSignature.Location = new System.Drawing.Point(15, 205);
|
||||
this.btnAutoDetectSignature.Size = new System.Drawing.Size(280, 25);
|
||||
this.btnAutoDetectSignature.Text = "Определить автоматически сейчас";
|
||||
this.btnAutoDetectSignature.UseVisualStyleBackColor = true;
|
||||
this.btnAutoDetectSignature.Click += new System.EventHandler(this.btnAutoDetectSignature_Click);
|
||||
|
||||
// chkTestModeDefault
|
||||
this.chkTestModeDefault.Location = new System.Drawing.Point(15, 250);
|
||||
this.chkTestModeDefault.Size = new System.Drawing.Size(610, 24);
|
||||
this.chkTestModeDefault.Text = "По умолчанию открывать предпросмотр в тестовом режиме (сохранять в Черновики)";
|
||||
this.chkTestModeDefault.UseVisualStyleBackColor = true;
|
||||
|
||||
//
|
||||
// tabSshAccess
|
||||
//
|
||||
this.tabSshAccess.Text = "Шаблон: новый доступ";
|
||||
this.tabSshAccess.Padding = new System.Windows.Forms.Padding(12);
|
||||
this.tabSshAccess.UseVisualStyleBackColor = true;
|
||||
this.tabSshAccess.Controls.Add(this.lblSshSubject);
|
||||
this.tabSshAccess.Controls.Add(this.txtSshSubject);
|
||||
this.tabSshAccess.Controls.Add(this.lblSshBody);
|
||||
this.tabSshAccess.Controls.Add(this.txtSshBody);
|
||||
this.tabSshAccess.Controls.Add(this.lblSshHint);
|
||||
|
||||
// lblSshSubject
|
||||
this.lblSshSubject.Location = new System.Drawing.Point(15, 20);
|
||||
this.lblSshSubject.Size = new System.Drawing.Size(200, 20);
|
||||
this.lblSshSubject.Text = "Тема письма:";
|
||||
|
||||
// txtSshSubject
|
||||
this.txtSshSubject.Location = new System.Drawing.Point(15, 43);
|
||||
this.txtSshSubject.Size = new System.Drawing.Size(610, 23);
|
||||
|
||||
// lblSshBody
|
||||
this.lblSshBody.Location = new System.Drawing.Point(15, 78);
|
||||
this.lblSshBody.Size = new System.Drawing.Size(200, 20);
|
||||
this.lblSshBody.Text = "Тело письма (HTML):";
|
||||
|
||||
// txtSshBody
|
||||
this.txtSshBody.Location = new System.Drawing.Point(15, 101);
|
||||
this.txtSshBody.Size = new System.Drawing.Size(610, 300);
|
||||
this.txtSshBody.Multiline = true;
|
||||
this.txtSshBody.ScrollBars = System.Windows.Forms.ScrollBars.Vertical;
|
||||
this.txtSshBody.AcceptsReturn = true;
|
||||
this.txtSshBody.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
|
||||
// lblSshHint
|
||||
this.lblSshHint.Location = new System.Drawing.Point(15, 408);
|
||||
this.lblSshHint.Size = new System.Drawing.Size(610, 20);
|
||||
this.lblSshHint.Text = "Доступные плейсхолдеры: {Login}, {Email}, {Date}";
|
||||
this.lblSshHint.ForeColor = System.Drawing.SystemColors.GrayText;
|
||||
this.lblSshHint.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
|
||||
//
|
||||
// tabConfigUpdate
|
||||
//
|
||||
this.tabConfigUpdate.Text = "Шаблон: обновление конфига";
|
||||
this.tabConfigUpdate.Padding = new System.Windows.Forms.Padding(12);
|
||||
this.tabConfigUpdate.UseVisualStyleBackColor = true;
|
||||
this.tabConfigUpdate.Controls.Add(this.lblCfgSubject);
|
||||
this.tabConfigUpdate.Controls.Add(this.txtCfgSubject);
|
||||
this.tabConfigUpdate.Controls.Add(this.lblCfgBody);
|
||||
this.tabConfigUpdate.Controls.Add(this.txtCfgBody);
|
||||
this.tabConfigUpdate.Controls.Add(this.lblCfgHint);
|
||||
|
||||
// lblCfgSubject
|
||||
this.lblCfgSubject.Location = new System.Drawing.Point(15, 20);
|
||||
this.lblCfgSubject.Size = new System.Drawing.Size(200, 20);
|
||||
this.lblCfgSubject.Text = "Тема письма:";
|
||||
|
||||
// txtCfgSubject
|
||||
this.txtCfgSubject.Location = new System.Drawing.Point(15, 43);
|
||||
this.txtCfgSubject.Size = new System.Drawing.Size(610, 23);
|
||||
|
||||
// lblCfgBody
|
||||
this.lblCfgBody.Location = new System.Drawing.Point(15, 78);
|
||||
this.lblCfgBody.Size = new System.Drawing.Size(200, 20);
|
||||
this.lblCfgBody.Text = "Тело письма (HTML):";
|
||||
|
||||
// txtCfgBody
|
||||
this.txtCfgBody.Location = new System.Drawing.Point(15, 101);
|
||||
this.txtCfgBody.Size = new System.Drawing.Size(610, 300);
|
||||
this.txtCfgBody.Multiline = true;
|
||||
this.txtCfgBody.ScrollBars = System.Windows.Forms.ScrollBars.Vertical;
|
||||
this.txtCfgBody.AcceptsReturn = true;
|
||||
this.txtCfgBody.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
|
||||
// lblCfgHint
|
||||
this.lblCfgHint.Location = new System.Drawing.Point(15, 408);
|
||||
this.lblCfgHint.Size = new System.Drawing.Size(610, 20);
|
||||
this.lblCfgHint.Text = "Доступные плейсхолдеры: {Login}, {Email}, {Date}";
|
||||
this.lblCfgHint.ForeColor = System.Drawing.SystemColors.GrayText;
|
||||
this.lblCfgHint.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
|
||||
//
|
||||
// btnSave / btnCancel / btnResetDefaults
|
||||
//
|
||||
this.btnResetDefaults.Location = new System.Drawing.Point(12, 494);
|
||||
this.btnResetDefaults.Size = new System.Drawing.Size(220, 30);
|
||||
this.btnResetDefaults.Text = "Сбросить домен/шаблоны по умолчанию";
|
||||
this.btnResetDefaults.UseVisualStyleBackColor = true;
|
||||
this.btnResetDefaults.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.btnResetDefaults.Click += new System.EventHandler(this.btnResetDefaults_Click);
|
||||
|
||||
this.btnCancel.Location = new System.Drawing.Point(479, 494);
|
||||
this.btnCancel.Size = new System.Drawing.Size(90, 30);
|
||||
this.btnCancel.Text = "Отмена";
|
||||
this.btnCancel.UseVisualStyleBackColor = true;
|
||||
this.btnCancel.DialogResult = System.Windows.Forms.DialogResult.Cancel;
|
||||
this.btnCancel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.btnCancel.Click += new System.EventHandler(this.btnCancel_Click);
|
||||
|
||||
this.btnSave.Location = new System.Drawing.Point(575, 494);
|
||||
this.btnSave.Size = new System.Drawing.Size(97, 30);
|
||||
this.btnSave.Text = "Сохранить";
|
||||
this.btnSave.UseVisualStyleBackColor = true;
|
||||
this.btnSave.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.btnSave.Click += new System.EventHandler(this.btnSave_Click);
|
||||
|
||||
//
|
||||
// SettingsForm
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(684, 536);
|
||||
this.MinimumSize = new System.Drawing.Size(560, 420);
|
||||
this.Controls.Add(this.tabControl);
|
||||
this.Controls.Add(this.btnResetDefaults);
|
||||
this.Controls.Add(this.btnCancel);
|
||||
this.Controls.Add(this.btnSave);
|
||||
this.Text = "BulkArchiveMailer — настройки";
|
||||
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
|
||||
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.Sizable;
|
||||
this.MaximizeBox = true;
|
||||
this.AcceptButton = null;
|
||||
this.CancelButton = this.btnCancel;
|
||||
|
||||
this.tabControl.ResumeLayout(false);
|
||||
this.tabGeneral.ResumeLayout(false);
|
||||
this.tabGeneral.PerformLayout();
|
||||
this.tabSshAccess.ResumeLayout(false);
|
||||
this.tabSshAccess.PerformLayout();
|
||||
this.tabConfigUpdate.ResumeLayout(false);
|
||||
this.tabConfigUpdate.PerformLayout();
|
||||
this.ResumeLayout(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using System;
|
||||
using System.Windows.Forms;
|
||||
using BulkArchiveMailer.Core;
|
||||
using Outlook = Microsoft.Office.Interop.Outlook;
|
||||
|
||||
namespace BulkArchiveMailer.AddIn.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Archive folder, email domain, signature override, test-mode default,
|
||||
/// and the two editable mail templates. Saves straight to
|
||||
/// %APPDATA%\BulkArchiveMailer\config.json via AppSettings.
|
||||
/// </summary>
|
||||
public partial class SettingsForm : Form
|
||||
{
|
||||
private readonly Outlook.Application outlookApplication;
|
||||
|
||||
public SettingsForm(Outlook.Application outlookApplication)
|
||||
{
|
||||
InitializeComponent();
|
||||
this.outlookApplication = outlookApplication;
|
||||
Load += SettingsForm_Load;
|
||||
}
|
||||
|
||||
private void SettingsForm_Load(object sender, EventArgs e)
|
||||
{
|
||||
AppSettingsData settings;
|
||||
try
|
||||
{
|
||||
settings = AppSettings.Load();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(
|
||||
"Не удалось прочитать config.json (" + AppSettings.ConfigFilePath + "):\n" + ex.Message +
|
||||
"\n\nБудут показаны пустые настройки — сохранение перезапишет файл заново.",
|
||||
"BulkArchiveMailer",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
settings = new AppSettingsData();
|
||||
}
|
||||
|
||||
PopulateFrom(settings);
|
||||
}
|
||||
|
||||
private void PopulateFrom(AppSettingsData settings)
|
||||
{
|
||||
txtArchiveFolder.Text = settings.ArchiveFolder ?? "";
|
||||
txtDomain.Text = settings.EmailDomain ?? "";
|
||||
txtSignaturePath.Text = settings.SignatureHtmlPath ?? "";
|
||||
chkTestModeDefault.Checked = settings.TestModeDefault;
|
||||
|
||||
var templates = settings.ToTemplateSet();
|
||||
txtSshSubject.Text = templates.SshAccess.Subject;
|
||||
txtSshBody.Text = templates.SshAccess.BodyHtml;
|
||||
txtCfgSubject.Text = templates.ConfigUpdate.Subject;
|
||||
txtCfgBody.Text = templates.ConfigUpdate.BodyHtml;
|
||||
}
|
||||
|
||||
private void btnBrowseFolder_Click(object sender, EventArgs e)
|
||||
{
|
||||
using (var dialog = new FolderBrowserDialog())
|
||||
{
|
||||
dialog.Description = "Папка с архивами, которые ssh-access-tui выкладывает для рассылки";
|
||||
if (System.IO.Directory.Exists(txtArchiveFolder.Text))
|
||||
{
|
||||
dialog.SelectedPath = txtArchiveFolder.Text;
|
||||
}
|
||||
|
||||
if (dialog.ShowDialog(this) == DialogResult.OK)
|
||||
{
|
||||
txtArchiveFolder.Text = dialog.SelectedPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void btnBrowseSignature_Click(object sender, EventArgs e)
|
||||
{
|
||||
using (var dialog = new OpenFileDialog())
|
||||
{
|
||||
dialog.Title = "Выберите файл подписи";
|
||||
dialog.Filter = "HTML-подпись (*.htm;*.html)|*.htm;*.html|Все файлы (*.*)|*.*";
|
||||
if (dialog.ShowDialog(this) == DialogResult.OK)
|
||||
{
|
||||
txtSignaturePath.Text = dialog.FileName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void btnAutoDetectSignature_Click(object sender, EventArgs e)
|
||||
{
|
||||
if (outlookApplication == null)
|
||||
{
|
||||
MessageBox.Show(this, "Outlook.Application недоступен в этом окне.", "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var path = SignatureReader.TryAutoDetectSignaturePath(outlookApplication);
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
MessageBox.Show(
|
||||
this,
|
||||
"Не удалось определить подпись автоматически (не настроена подпись по умолчанию для новых писем, либо файл не найден). Выберите файл вручную кнопкой \"Обзор...\".",
|
||||
"BulkArchiveMailer",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
txtSignaturePath.Text = path;
|
||||
}
|
||||
|
||||
private void btnResetDefaults_Click(object sender, EventArgs e)
|
||||
{
|
||||
var confirm = MessageBox.Show(
|
||||
this,
|
||||
"Заменить домен и оба шаблона писем значениями по умолчанию? Папка с архивами и файл подписи не изменятся. Несохранённые правки в этом окне будут потеряны.",
|
||||
"Подтверждение",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Question);
|
||||
if (confirm != DialogResult.Yes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var defaults = AppSettings.LoadDefaults();
|
||||
txtDomain.Text = defaults.EmailDomain;
|
||||
|
||||
var templates = defaults.ToTemplateSet();
|
||||
txtSshSubject.Text = templates.SshAccess.Subject;
|
||||
txtSshBody.Text = templates.SshAccess.BodyHtml;
|
||||
txtCfgSubject.Text = templates.ConfigUpdate.Subject;
|
||||
txtCfgBody.Text = templates.ConfigUpdate.BodyHtml;
|
||||
}
|
||||
|
||||
private void btnSave_Click(object sender, EventArgs e)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(txtDomain.Text))
|
||||
{
|
||||
MessageBox.Show(this, "Домен электронной почты не может быть пустым.", "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
tabControl.SelectedTab = tabGeneral;
|
||||
txtDomain.Focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(txtArchiveFolder.Text) && !System.IO.Directory.Exists(txtArchiveFolder.Text))
|
||||
{
|
||||
var proceed = MessageBox.Show(
|
||||
this,
|
||||
"Указанная папка с архивами не существует:\n" + txtArchiveFolder.Text + "\n\nВсё равно сохранить?",
|
||||
"BulkArchiveMailer",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Warning);
|
||||
if (proceed != DialogResult.Yes)
|
||||
{
|
||||
tabControl.SelectedTab = tabGeneral;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var data = new AppSettingsData
|
||||
{
|
||||
ArchiveFolder = txtArchiveFolder.Text.Trim(),
|
||||
EmailDomain = txtDomain.Text.Trim(),
|
||||
SignatureHtmlPath = txtSignaturePath.Text.Trim(),
|
||||
TestModeDefault = chkTestModeDefault.Checked,
|
||||
};
|
||||
data.SetTemplate(ArchiveKind.SshAccess, new MailTemplate { Subject = txtSshSubject.Text, BodyHtml = txtSshBody.Text });
|
||||
data.SetTemplate(ArchiveKind.ConfigUpdate, new MailTemplate { Subject = txtCfgSubject.Text, BodyHtml = txtCfgBody.Text });
|
||||
|
||||
try
|
||||
{
|
||||
AppSettings.Save(data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, "Не удалось сохранить настройки: " + ex.Message, "BulkArchiveMailer", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void btnCancel_Click(object sender, EventArgs e)
|
||||
{
|
||||
DialogResult = DialogResult.Cancel;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<startup useLegacyV2RuntimeActivationPolicy="true">
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
|
||||
</startup>
|
||||
<runtime>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.Office.Tools.Common.v4.0.Utilities" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-10.0.0.0" newVersion="10.0.0.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
</runtime>
|
||||
</configuration>
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
SDK-style project deliberately: xUnit's tooling and `dotnet test` work
|
||||
with zero friction this way, and unlike BulkArchiveMailer.Core/AddIn
|
||||
this project has no VSTO constraints forcing the classic csproj format.
|
||||
Mixing project styles in one .sln is fine.
|
||||
|
||||
SCAFFOLD ONLY - see FilenameParserTests.cs. A separate dotnet-tester
|
||||
pass is expected to flesh out real coverage for ArchiveScanner,
|
||||
SendLogStore, TemplateEngine, AppSettings, etc.
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<LangVersion>7.3</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
||||
<PackageReference Include="xunit" Version="2.7.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\BulkArchiveMailer.Core\BulkArchiveMailer.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,25 @@
|
||||
using BulkArchiveMailer.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace BulkArchiveMailer.Core.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Scaffold smoke test only, proving the test project wires up
|
||||
/// (references Core correctly, xunit runs). NOT meant to be the real
|
||||
/// test suite - a dotnet-tester pass covers ArchiveScanner,
|
||||
/// SendLogStore, TemplateEngine, AppSettings, EmailAddressBuilder
|
||||
/// edge cases, etc.
|
||||
/// </summary>
|
||||
public class FilenameParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryParse_KnownGoodSshAccessFileName_Succeeds()
|
||||
{
|
||||
var ok = FilenameParser.TryParse("a-makarov_ssh_access.7z", out var parsed);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("a-makarov", parsed.Login);
|
||||
Assert.Equal(ArchiveKind.SshAccess, parsed.Kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Runtime.Serialization.Json;
|
||||
using System.Text;
|
||||
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
[DataContract]
|
||||
public sealed class TemplateData
|
||||
{
|
||||
[DataMember(Name = "subject")]
|
||||
public string Subject { get; set; } = "";
|
||||
|
||||
[DataMember(Name = "bodyHtml")]
|
||||
public string BodyHtml { get; set; } = "";
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public sealed class TemplateSetData
|
||||
{
|
||||
[DataMember(Name = "ssh_access")]
|
||||
public TemplateData SshAccess { get; set; } = new TemplateData();
|
||||
|
||||
[DataMember(Name = "config_update")]
|
||||
public TemplateData ConfigUpdate { get; set; } = new TemplateData();
|
||||
}
|
||||
|
||||
/// <summary>Shape of %APPDATA%\BulkArchiveMailer\config.json (and of the embedded config.default.json that seeds it).</summary>
|
||||
[DataContract]
|
||||
public sealed class AppSettingsData
|
||||
{
|
||||
[DataMember(Name = "archiveFolder")]
|
||||
public string ArchiveFolder { get; set; } = "";
|
||||
|
||||
[DataMember(Name = "emailDomain")]
|
||||
public string EmailDomain { get; set; } = "redlab.lo";
|
||||
|
||||
/// <summary>Whether "test mode" (save to Drafts instead of Send) should be pre-checked when PreviewForm opens. Defaults to true - sending live is an explicit opt-in each time the operator unchecks it.</summary>
|
||||
[DataMember(Name = "testModeDefault")]
|
||||
public bool TestModeDefault { get; set; } = true;
|
||||
|
||||
/// <summary>Manual override for the Outlook signature .htm path, used when SignatureReader's registry-based auto-detection fails or the operator wants a different signature. Empty = rely on auto-detection.</summary>
|
||||
[DataMember(Name = "signatureHtmlPath")]
|
||||
public string SignatureHtmlPath { get; set; } = "";
|
||||
|
||||
[DataMember(Name = "templates")]
|
||||
public TemplateSetData Templates { get; set; } = new TemplateSetData();
|
||||
|
||||
public MailTemplateSet ToTemplateSet()
|
||||
{
|
||||
return new MailTemplateSet
|
||||
{
|
||||
SshAccess = new MailTemplate
|
||||
{
|
||||
Subject = Templates?.SshAccess?.Subject ?? "",
|
||||
BodyHtml = Templates?.SshAccess?.BodyHtml ?? "",
|
||||
},
|
||||
ConfigUpdate = new MailTemplate
|
||||
{
|
||||
Subject = Templates?.ConfigUpdate?.Subject ?? "",
|
||||
BodyHtml = Templates?.ConfigUpdate?.BodyHtml ?? "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public void SetTemplate(ArchiveKind kind, MailTemplate template)
|
||||
{
|
||||
Templates = Templates ?? new TemplateSetData();
|
||||
var data = new TemplateData { Subject = template?.Subject ?? "", BodyHtml = template?.BodyHtml ?? "" };
|
||||
if (kind == ArchiveKind.SshAccess)
|
||||
{
|
||||
Templates.SshAccess = data;
|
||||
}
|
||||
else
|
||||
{
|
||||
Templates.ConfigUpdate = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads/saves the small JSON settings file at
|
||||
/// %APPDATA%\BulkArchiveMailer\config.json. On first run (file
|
||||
/// missing) the file is seeded verbatim from the config.default.json
|
||||
/// resource embedded in this assembly, so the shipped defaults and the
|
||||
/// initial user-editable file always start out identical - there is
|
||||
/// only one place (config.default.json) where default copy/domain
|
||||
/// text is authored.
|
||||
///
|
||||
/// Uses DataContractJsonSerializer (part of the .NET Framework since
|
||||
/// 3.5, ships in System.Runtime.Serialization.dll) rather than a
|
||||
/// third-party JSON package, specifically so this project has zero
|
||||
/// NuGet dependencies to resolve - this whole solution cannot be
|
||||
/// build-tested in the environment this was authored in, so removing
|
||||
/// every avoidable source of "does the package restore correctly on
|
||||
/// first open" risk is worth the small amount of extra code below.
|
||||
/// </summary>
|
||||
public static class AppSettings
|
||||
{
|
||||
private const string EmbeddedDefaultResourceName = "BulkArchiveMailer.Core.config.default.json";
|
||||
|
||||
public static string ConfigDirectory =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "BulkArchiveMailer");
|
||||
|
||||
public static string ConfigFilePath => Path.Combine(ConfigDirectory, "config.json");
|
||||
|
||||
public static string ReadEmbeddedDefaultJson()
|
||||
{
|
||||
var asm = Assembly.GetExecutingAssembly();
|
||||
using (var stream = asm.GetManifestResourceStream(EmbeddedDefaultResourceName))
|
||||
{
|
||||
if (stream == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Embedded resource '{EmbeddedDefaultResourceName}' not found in {asm.FullName}. " +
|
||||
"Check that config.default.json's Build Action is set to Embedded Resource in BulkArchiveMailer.Core.csproj.");
|
||||
}
|
||||
|
||||
using (var reader = new StreamReader(stream, Encoding.UTF8))
|
||||
{
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static AppSettingsData LoadDefaults() => Deserialize(ReadEmbeddedDefaultJson());
|
||||
|
||||
/// <summary>
|
||||
/// Loads settings from disk, seeding the file from the embedded
|
||||
/// defaults on first run. A malformed hand-edited config.json is
|
||||
/// never silently overwritten - the DataContractJsonSerializer
|
||||
/// exception propagates so the caller can warn the operator
|
||||
/// instead of quietly discarding their edits.
|
||||
/// </summary>
|
||||
public static AppSettingsData Load()
|
||||
{
|
||||
if (!File.Exists(ConfigFilePath))
|
||||
{
|
||||
Directory.CreateDirectory(ConfigDirectory);
|
||||
File.WriteAllText(ConfigFilePath, ReadEmbeddedDefaultJson(), new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(ConfigFilePath, Encoding.UTF8);
|
||||
return Deserialize(json);
|
||||
}
|
||||
|
||||
public static void Save(AppSettingsData data)
|
||||
{
|
||||
if (data == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(data));
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(ConfigDirectory);
|
||||
File.WriteAllText(ConfigFilePath, Serialize(data), new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
private static AppSettingsData Deserialize(string json)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
using (var ms = new MemoryStream(bytes))
|
||||
{
|
||||
var serializer = new DataContractJsonSerializer(typeof(AppSettingsData));
|
||||
return (AppSettingsData)serializer.ReadObject(ms);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Serialize(AppSettingsData data)
|
||||
{
|
||||
using (var ms = new MemoryStream())
|
||||
{
|
||||
var serializer = new DataContractJsonSerializer(typeof(AppSettingsData));
|
||||
serializer.WriteObject(ms, data);
|
||||
var minified = Encoding.UTF8.GetString(ms.ToArray());
|
||||
return PrettyPrintJson(minified);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DataContractJsonSerializer has no built-in indentation option
|
||||
/// and writes single-line JSON. This tiny formatter re-indents it
|
||||
/// so config.json stays comfortable to hand-edit (it is, after
|
||||
/// all, the officially supported way to change the mail
|
||||
/// templates). It only tracks string-literal boundaries, which is
|
||||
/// sufficient for the flat/shallow shape this file always has.
|
||||
/// </summary>
|
||||
private static string PrettyPrintJson(string json)
|
||||
{
|
||||
var sb = new StringBuilder(json.Length * 2);
|
||||
var indent = 0;
|
||||
var inString = false;
|
||||
var escapeNext = false;
|
||||
|
||||
for (var i = 0; i < json.Length; i++)
|
||||
{
|
||||
var c = json[i];
|
||||
|
||||
if (inString)
|
||||
{
|
||||
sb.Append(c);
|
||||
if (escapeNext)
|
||||
{
|
||||
escapeNext = false;
|
||||
}
|
||||
else if (c == '\\')
|
||||
{
|
||||
escapeNext = true;
|
||||
}
|
||||
else if (c == '"')
|
||||
{
|
||||
inString = false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (c)
|
||||
{
|
||||
case '"':
|
||||
inString = true;
|
||||
sb.Append(c);
|
||||
break;
|
||||
case '{':
|
||||
case '[':
|
||||
sb.Append(c);
|
||||
if (i + 1 < json.Length && (json[i + 1] == '}' || json[i + 1] == ']'))
|
||||
{
|
||||
sb.Append(json[i + 1]);
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
indent++;
|
||||
sb.Append('\n').Append(' ', indent * 2);
|
||||
}
|
||||
|
||||
break;
|
||||
case '}':
|
||||
case ']':
|
||||
indent--;
|
||||
sb.Append('\n').Append(' ', indent * 2).Append(c);
|
||||
break;
|
||||
case ',':
|
||||
sb.Append(c).Append('\n').Append(' ', indent * 2);
|
||||
break;
|
||||
case ':':
|
||||
sb.Append(c).Append(' ');
|
||||
break;
|
||||
default:
|
||||
sb.Append(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
/// <summary>Everything ArchiveScanner.Scan found in one folder, split by shape so the caller can render them however it likes.</summary>
|
||||
public sealed class ScanResult
|
||||
{
|
||||
/// <summary>Ready / AlreadySent / Error rows - one per login that had at least one recognized *.7z file.</summary>
|
||||
public List<SendJob> Jobs { get; } = new List<SendJob>();
|
||||
|
||||
/// <summary>One Skipped row per file that did not match the expected naming pattern.</summary>
|
||||
public List<SendJob> UnrecognizedFiles { get; } = new List<SendJob>();
|
||||
|
||||
/// <summary>Convenience: both lists concatenated, sorted the same way PreviewForm wants to bind a single grid.</summary>
|
||||
public IEnumerable<SendJob> AllRows => Jobs.Concat(UnrecognizedFiles);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks a folder of ssh-access-tui output archives (plain System.IO -
|
||||
/// no Outlook/COM involved) and builds the list of preview rows.
|
||||
/// </summary>
|
||||
public static class ArchiveScanner
|
||||
{
|
||||
/// <param name="folderPath">Folder to scan (non-recursive - ssh-access-tui writes archives flat into one output directory).</param>
|
||||
/// <param name="emailDomain">Domain appended to the derived local part, e.g. "redlab.lo".</param>
|
||||
/// <param name="templates">Used to render each Ready job's Subject/BodyHtml. May be null (rows are then created with empty Subject/BodyHtml, useful for pure filename-parsing tests).</param>
|
||||
/// <param name="sendLog">When provided, each single-archive login is checked against the log (by SHA-256 of the archive's current bytes) and marked AlreadySent if a prior Sent row matches. When null, dedup is skipped entirely and every recognized single-archive login comes back Ready.</param>
|
||||
public static ScanResult Scan(string folderPath, string emailDomain, MailTemplateSet templates, SendLogStore sendLog = null)
|
||||
{
|
||||
var result = new ScanResult();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(folderPath) || !Directory.Exists(folderPath))
|
||||
{
|
||||
// Caller (SettingsForm/PreviewForm) is expected to validate
|
||||
// the configured folder up front and show its own error;
|
||||
// scanning a missing folder just yields an empty result
|
||||
// rather than throwing.
|
||||
return result;
|
||||
}
|
||||
|
||||
// login -> every (path, kind) found for it, case-insensitive
|
||||
// because the file system this runs on (Windows) is too.
|
||||
var byLogin = new Dictionary<string, List<(string Path, ArchiveKind Kind)>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var filePath in Directory.EnumerateFiles(folderPath, "*.7z", SearchOption.TopDirectoryOnly).OrderBy(p => p, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
|
||||
if (!FilenameParser.TryParse(fileName, out var parsed))
|
||||
{
|
||||
result.UnrecognizedFiles.Add(new SendJob
|
||||
{
|
||||
ArchivePath = filePath,
|
||||
Status = SendStatus.Skipped,
|
||||
ErrorMessage = $"Имя файла не соответствует шаблону \"{{login}}_ssh_access.7z\" / \"{{login}}_config_update.7z\": {fileName}",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!byLogin.TryGetValue(parsed.Login, out var list))
|
||||
{
|
||||
list = new List<(string, ArchiveKind)>();
|
||||
byLogin[parsed.Login] = list;
|
||||
}
|
||||
|
||||
list.Add((filePath, parsed.Kind));
|
||||
}
|
||||
|
||||
foreach (var kv in byLogin.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var login = kv.Key;
|
||||
var files = kv.Value;
|
||||
var email = EmailAddressBuilder.BuildEmail(login, emailDomain);
|
||||
|
||||
if (files.Count > 1)
|
||||
{
|
||||
// Both archive kinds present for one login (or, less
|
||||
// likely, a same-kind duplicate left behind from a
|
||||
// prior export) - this is a data error upstream. Never
|
||||
// auto-pick one; surface it as a blocking row instead.
|
||||
var names = string.Join(", ", files.Select(f => Path.GetFileName(f.Path)));
|
||||
result.Jobs.Add(new SendJob
|
||||
{
|
||||
Login = login,
|
||||
Email = email,
|
||||
Kind = null,
|
||||
ArchivePath = null,
|
||||
ConflictingArchivePaths = files.Select(f => f.Path).ToList(),
|
||||
Status = SendStatus.Error,
|
||||
ErrorMessage = $"Для логина \"{login}\" одновременно найдено {files.Count} архива(ов): {names}. Это ошибка данных (должен существовать только один архив на логин за раз) — требуется ручная проверка на jump-хосте, автоматический выбор не выполняется.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var (path, kind) = files[0];
|
||||
var rendered = templates != null
|
||||
? TemplateEngine.Render(templates.Get(kind), login, email)
|
||||
: new MailTemplate();
|
||||
|
||||
var job = new SendJob
|
||||
{
|
||||
Login = login,
|
||||
Email = email,
|
||||
Kind = kind,
|
||||
ArchivePath = path,
|
||||
Subject = rendered.Subject,
|
||||
BodyHtml = rendered.BodyHtml,
|
||||
Status = SendStatus.Ready,
|
||||
};
|
||||
|
||||
if (sendLog != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
job.FileSha256 = SendLogStore.ComputeSha256(path);
|
||||
if (sendLog.TryFindSent(email, kind, job.FileSha256, out var priorEntry))
|
||||
{
|
||||
job.Status = SendStatus.AlreadySent;
|
||||
job.ErrorMessage = $"Уже отправлено {priorEntry.TimestampUtc:yyyy-MM-dd HH:mm} UTC (тот же файл, без изменений с тех пор).";
|
||||
}
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
// File locked/unreadable right now - don't block
|
||||
// the whole scan over one bad file, surface it as
|
||||
// an error row instead.
|
||||
job.Status = SendStatus.Error;
|
||||
job.ErrorMessage = $"Не удалось прочитать файл архива для проверки дубликатов: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
result.Jobs.Add(job);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="15.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
|
||||
<PropertyGroup>
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||
<ProjectGuid>{96DFBF08-8BCA-4A4E-BDF8-3F013965E30E}</ProjectGuid>
|
||||
<OutputType>Library</OutputType>
|
||||
<RootNamespace>BulkArchiveMailer.Core</RootNamespace>
|
||||
<AssemblyName>BulkArchiveMailer.Core</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<Deterministic>true</Deterministic>
|
||||
<LangVersion>7.3</LangVersion>
|
||||
<!--
|
||||
Deliberately NOT referencing anything Outlook/COM/registry-related
|
||||
here - this project must stay buildable and testable without
|
||||
Windows/Office installed. See BulkArchiveMailer.Core.Tests and the
|
||||
dotnet-developer agent notes for why that boundary matters.
|
||||
-->
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>full</DebugType>
|
||||
<Optimize>false</Optimize>
|
||||
<OutputPath>bin\Debug\</OutputPath>
|
||||
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<Optimize>true</Optimize>
|
||||
<OutputPath>bin\Release\</OutputPath>
|
||||
<DefineConstants>TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="System" />
|
||||
<Reference Include="System.Core" />
|
||||
<Reference Include="System.Runtime.Serialization" />
|
||||
<Reference Include="System.Xml" />
|
||||
<Reference Include="System.Xml.Linq" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="AppSettings.cs" />
|
||||
<Compile Include="ArchiveScanner.cs" />
|
||||
<Compile Include="EmailAddressBuilder.cs" />
|
||||
<Compile Include="FilenameParser.cs" />
|
||||
<Compile Include="MailTemplates.cs" />
|
||||
<Compile Include="SendJob.cs" />
|
||||
<Compile Include="SendLogStore.cs" />
|
||||
<Compile Include="TemplateEngine.cs" />
|
||||
<Compile Include="Properties\AssemblyInfo.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="config.default.json" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||
</Project>
|
||||
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Derives the recipient's email address from the login embedded in an
|
||||
/// archive file name. Rule (see repo README/plan, confirmed with the
|
||||
/// operator): a Linux login such as "a-makarov" maps to
|
||||
/// "a.makarov@<domain>" by replacing every hyphen with a dot -
|
||||
/// there is no separate mapping table.
|
||||
///
|
||||
/// Known accepted limitation (do NOT try to fix this with an override
|
||||
/// mechanism): if a real login contains a literal hyphen that is not
|
||||
/// an initial/surname separator, this produces the wrong local part.
|
||||
/// That is out of scope for this tool - see README.md.
|
||||
/// </summary>
|
||||
public static class EmailAddressBuilder
|
||||
{
|
||||
public static string BuildEmail(string login, string domain)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(login))
|
||||
{
|
||||
throw new ArgumentException("login must not be empty.", nameof(login));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(domain))
|
||||
{
|
||||
throw new ArgumentException("domain must not be empty.", nameof(domain));
|
||||
}
|
||||
|
||||
var localPart = login.Trim().ToLowerInvariant().Replace('-', '.');
|
||||
var cleanDomain = domain.Trim().TrimStart('@');
|
||||
return $"{localPart}@{cleanDomain}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Which kind of archive ssh-access-tui produced. See
|
||||
/// internal/keymgr/pack.go (BuildUserArchive -> "_ssh_access.7z") and
|
||||
/// internal/keymgr/configonly.go (BuildConfigOnlyArchive ->
|
||||
/// "_config_update.7z") in the main repo for the producing side.
|
||||
/// </summary>
|
||||
public enum ArchiveKind
|
||||
{
|
||||
SshAccess,
|
||||
ConfigUpdate
|
||||
}
|
||||
|
||||
/// <summary>Result of successfully parsing an archive file name.</summary>
|
||||
public sealed class ParsedFilename
|
||||
{
|
||||
public ParsedFilename(string login, ArchiveKind kind)
|
||||
{
|
||||
Login = login;
|
||||
Kind = kind;
|
||||
}
|
||||
|
||||
/// <summary>Lower-cased login exactly as it appears in the file name (hyphens intact).</summary>
|
||||
public string Login { get; }
|
||||
|
||||
public ArchiveKind Kind { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the two file name shapes ssh-access-tui ever produces:
|
||||
/// "{login}_ssh_access.7z" and "{login}_config_update.7z". Anything
|
||||
/// else (wrong extension, extra suffix, typo'd kind, stray file dropped
|
||||
/// in the export folder by a human) simply fails to parse - the caller
|
||||
/// (ArchiveScanner) is responsible for routing that into the
|
||||
/// "unrecognized" bucket instead of blocking the batch.
|
||||
/// </summary>
|
||||
public static class FilenameParser
|
||||
{
|
||||
// Matches the login part ssh-access-tui itself accepts for a Linux
|
||||
// username: lowercase alnum segments joined by single hyphens (no
|
||||
// leading/trailing/double hyphens). Case-insensitive purely as a
|
||||
// defensive measure in case a file was renamed by hand on a
|
||||
// case-insensitive filesystem; the captured login is normalized to
|
||||
// lower case before use.
|
||||
private static readonly Regex Pattern = new Regex(
|
||||
@"^(?<login>[a-z0-9]+(?:-[a-z0-9]+)*)_(?<kind>ssh_access|config_update)\.7z$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public static bool TryParse(string fileName, out ParsedFilename parsed)
|
||||
{
|
||||
parsed = null;
|
||||
if (string.IsNullOrEmpty(fileName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var match = Pattern.Match(fileName);
|
||||
if (!match.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var login = match.Groups["login"].Value.ToLowerInvariant();
|
||||
var kindText = match.Groups["kind"].Value.ToLowerInvariant();
|
||||
var kind = kindText == "ssh_access" ? ArchiveKind.SshAccess : ArchiveKind.ConfigUpdate;
|
||||
|
||||
parsed = new ParsedFilename(login, kind);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
/// <summary>One editable Subject + BodyHtml pair.</summary>
|
||||
public sealed class MailTemplate
|
||||
{
|
||||
public string Subject { get; set; } = "";
|
||||
|
||||
public string BodyHtml { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>The two named template keys used throughout the tool (matches ArchiveKind, and the JSON config's "templates" object).</summary>
|
||||
public static class MailTemplateKeys
|
||||
{
|
||||
public const string SshAccess = "ssh_access";
|
||||
public const string ConfigUpdate = "config_update";
|
||||
|
||||
public static string For(ArchiveKind kind) => kind == ArchiveKind.SshAccess ? SshAccess : ConfigUpdate;
|
||||
}
|
||||
|
||||
/// <summary>Both named templates together, as used by ArchiveScanner to render a SendJob's Subject/BodyHtml.</summary>
|
||||
public sealed class MailTemplateSet
|
||||
{
|
||||
public MailTemplate SshAccess { get; set; } = new MailTemplate();
|
||||
|
||||
public MailTemplate ConfigUpdate { get; set; } = new MailTemplate();
|
||||
|
||||
public MailTemplate Get(ArchiveKind kind) => kind == ArchiveKind.SshAccess ? SshAccess : ConfigUpdate;
|
||||
|
||||
/// <summary>
|
||||
/// The two templates as shipped in config.default.json (embedded
|
||||
/// resource), independent of anything the user has saved to
|
||||
/// %APPDATA%\BulkArchiveMailer\config.json. Mainly useful for a
|
||||
/// "reset to defaults" action in SettingsForm.
|
||||
/// </summary>
|
||||
public static MailTemplateSet LoadDefaults() => AppSettings.LoadDefaults().ToTemplateSet();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Reflection;
|
||||
using System.Resources;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
[assembly: AssemblyTitle("BulkArchiveMailer.Core")]
|
||||
[assembly: AssemblyDescription("Portable (no Outlook/COM) logic for BulkArchiveMailer: filename parsing, email derivation, templates, send log, settings.")]
|
||||
[assembly: AssemblyConfiguration("")]
|
||||
[assembly: AssemblyCompany("")]
|
||||
[assembly: AssemblyProduct("BulkArchiveMailer.Core")]
|
||||
[assembly: AssemblyCopyright("")]
|
||||
[assembly: AssemblyTrademark("")]
|
||||
[assembly: AssemblyCulture("")]
|
||||
[assembly: NeutralResourcesLanguage("ru")]
|
||||
|
||||
[assembly: ComVisible(false)]
|
||||
[assembly: Guid("2f3f5a4e-6a3b-4a7c-8f2f-6f9d0b4a9d10")]
|
||||
|
||||
[assembly: AssemblyVersion("1.0.0.0")]
|
||||
[assembly: AssemblyFileVersion("1.0.0.0")]
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
/// <summary>Status of one preview row / send attempt.</summary>
|
||||
public enum SendStatus
|
||||
{
|
||||
/// <summary>Exactly one archive found for this login, nothing sent for it yet - safe to send.</summary>
|
||||
Ready,
|
||||
|
||||
/// <summary>A prior log entry shows this exact archive (by SHA-256) was already sent to this address.</summary>
|
||||
AlreadySent,
|
||||
|
||||
/// <summary>Blocking data problem (e.g. both archive kinds present for one login) - never sendable.</summary>
|
||||
Error,
|
||||
|
||||
/// <summary>File in the folder did not match the expected naming pattern - listed for visibility, not sendable.</summary>
|
||||
Skipped
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One row of the send preview: everything needed to either display the
|
||||
/// row in the grid or hand it to MailSender.
|
||||
/// </summary>
|
||||
public sealed class SendJob
|
||||
{
|
||||
public string Login { get; set; } = "";
|
||||
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
/// <summary>Null for Error rows caused by a login/kind conflict, and for Skipped (unrecognized file) rows.</summary>
|
||||
public ArchiveKind? Kind { get; set; }
|
||||
|
||||
/// <summary>Null for conflict Error rows (see <see cref="ConflictingArchivePaths"/>) - otherwise the single archive to attach.</summary>
|
||||
public string ArchivePath { get; set; }
|
||||
|
||||
/// <summary>Populated only for the "both kinds present" Error case: every archive path found for that login.</summary>
|
||||
public IReadOnlyList<string> ConflictingArchivePaths { get; set; }
|
||||
|
||||
public string Subject { get; set; } = "";
|
||||
|
||||
public string BodyHtml { get; set; } = "";
|
||||
|
||||
public SendStatus Status { get; set; } = SendStatus.Ready;
|
||||
|
||||
/// <summary>Human-readable explanation, set for Error/Skipped rows and for AlreadySent rows (when it was sent).</summary>
|
||||
public string ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>SHA-256 of ArchivePath's current bytes, computed when a SendLogStore is available. Used for the AlreadySent dedup key.</summary>
|
||||
public string FileSha256 { get; set; }
|
||||
|
||||
/// <summary>Whether this row is even eligible to be checked/sent in the preview grid.</summary>
|
||||
public bool IsSelectable => Status == SendStatus.Ready || Status == SendStatus.AlreadySent;
|
||||
|
||||
public string FileName => string.IsNullOrEmpty(ArchivePath) ? "" : Path.GetFileName(ArchivePath);
|
||||
|
||||
public string KindDisplay
|
||||
{
|
||||
get
|
||||
{
|
||||
switch (Kind)
|
||||
{
|
||||
case ArchiveKind.SshAccess:
|
||||
return "Новый доступ (ключ + конфиг)";
|
||||
case ArchiveKind.ConfigUpdate:
|
||||
return "Обновление конфига";
|
||||
default:
|
||||
return "-";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Login} <{Email}> [{Status}]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
/// <summary>Outcome recorded for one send attempt.</summary>
|
||||
public static class SendLogStatus
|
||||
{
|
||||
public const string Sent = "Sent";
|
||||
public const string Failed = "Failed";
|
||||
|
||||
/// <summary>Written in test mode (mail saved to Drafts, not actually sent) - deliberately NOT "Sent", so a later real send is not treated as a duplicate.</summary>
|
||||
public const string Draft = "Draft";
|
||||
}
|
||||
|
||||
public sealed class SendLogEntry
|
||||
{
|
||||
public DateTime TimestampUtc { get; set; }
|
||||
public string Login { get; set; } = "";
|
||||
public string Email { get; set; } = "";
|
||||
public ArchiveKind Kind { get; set; }
|
||||
public string ArchivePath { get; set; } = "";
|
||||
public string FileSha256 { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
public string Error { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Append-only CSV log at a path chosen by the caller (AddIn typically
|
||||
/// keeps it next to the archive folder, e.g.
|
||||
/// "<archiveFolder>\send-log.csv"). Columns:
|
||||
/// TimestampUtc,Login,Email,Kind,ArchivePath,FileSha256,Status,Error
|
||||
///
|
||||
/// Dedup key for "already sent this exact archive": (Email, Kind,
|
||||
/// FileSha256) with Status=Sent. Hashing the archive's *current* bytes
|
||||
/// (done by the caller via ComputeSha256, at scan time) is what lets a
|
||||
/// regenerated archive - same login, same kind, new key material or
|
||||
/// port list - be correctly treated as NOT already sent, while a
|
||||
/// second scan of an untouched folder correctly flags it as
|
||||
/// AlreadySent.
|
||||
/// </summary>
|
||||
public sealed class SendLogStore
|
||||
{
|
||||
private static readonly string[] Header =
|
||||
{
|
||||
"TimestampUtc", "Login", "Email", "Kind", "ArchivePath", "FileSha256", "Status", "Error",
|
||||
};
|
||||
|
||||
public string CsvPath { get; }
|
||||
|
||||
public SendLogStore(string csvPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(csvPath))
|
||||
{
|
||||
throw new ArgumentException("csvPath must not be empty.", nameof(csvPath));
|
||||
}
|
||||
|
||||
CsvPath = csvPath;
|
||||
}
|
||||
|
||||
public static string ComputeSha256(string filePath)
|
||||
{
|
||||
using (var sha = SHA256.Create())
|
||||
using (var stream = File.OpenRead(filePath))
|
||||
{
|
||||
var hash = sha.ComputeHash(stream);
|
||||
var sb = new StringBuilder(hash.Length * 2);
|
||||
foreach (var b in hash)
|
||||
{
|
||||
sb.Append(b.ToString("x2", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<SendLogEntry> ReadAll()
|
||||
{
|
||||
var result = new List<SendLogEntry>();
|
||||
if (!File.Exists(CsvPath))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
using (var reader = new StreamReader(CsvPath, Encoding.UTF8))
|
||||
{
|
||||
var first = true;
|
||||
string line;
|
||||
while ((line = reader.ReadLine()) != null)
|
||||
{
|
||||
if (first)
|
||||
{
|
||||
first = false;
|
||||
continue; // header row
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fields = ParseCsvLine(line);
|
||||
if (fields.Count < Header.Length)
|
||||
{
|
||||
continue; // malformed/truncated row - skip rather than throw
|
||||
}
|
||||
|
||||
ArchiveKind kind;
|
||||
Enum.TryParse(fields[3], true, out kind);
|
||||
|
||||
DateTime ts;
|
||||
DateTime.TryParse(
|
||||
fields[0],
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.RoundtripKind | DateTimeStyles.AssumeUniversal,
|
||||
out ts);
|
||||
|
||||
result.Add(new SendLogEntry
|
||||
{
|
||||
TimestampUtc = ts,
|
||||
Login = fields[1],
|
||||
Email = fields[2],
|
||||
Kind = kind,
|
||||
ArchivePath = fields[4],
|
||||
FileSha256 = fields[5],
|
||||
Status = fields[6],
|
||||
Error = fields[7],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Append(SendLogEntry entry)
|
||||
{
|
||||
if (entry == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(entry));
|
||||
}
|
||||
|
||||
var dir = Path.GetDirectoryName(CsvPath);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
var writeHeader = !File.Exists(CsvPath);
|
||||
using (var writer = new StreamWriter(CsvPath, append: true, encoding: new UTF8Encoding(false)))
|
||||
{
|
||||
if (writeHeader)
|
||||
{
|
||||
writer.WriteLine(string.Join(",", Header));
|
||||
}
|
||||
|
||||
writer.WriteLine(string.Join(",", new[]
|
||||
{
|
||||
Escape(entry.TimestampUtc.ToString("o", CultureInfo.InvariantCulture)),
|
||||
Escape(entry.Login),
|
||||
Escape(entry.Email),
|
||||
Escape(entry.Kind.ToString()),
|
||||
Escape(entry.ArchivePath),
|
||||
Escape(entry.FileSha256),
|
||||
Escape(entry.Status),
|
||||
Escape(entry.Error),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if the most recent matching row for (email, kind, sha256)
|
||||
/// has Status=Sent. Draft/Failed rows never count as "already
|
||||
/// sent" - only a completed real Send does.
|
||||
/// </summary>
|
||||
public bool TryFindSent(string email, ArchiveKind kind, string sha256, out SendLogEntry match)
|
||||
{
|
||||
match = null;
|
||||
if (string.IsNullOrEmpty(sha256) || string.IsNullOrEmpty(email))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var entry in ReadAll())
|
||||
{
|
||||
if (!string.Equals(entry.Status, SendLogStatus.Sent, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(entry.Email, email, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.Kind != kind)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(entry.FileSha256, sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (match == null || entry.TimestampUtc > match.TimestampUtc)
|
||||
{
|
||||
match = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return match != null;
|
||||
}
|
||||
|
||||
private static string Escape(string field)
|
||||
{
|
||||
field = field ?? "";
|
||||
if (field.IndexOfAny(new[] { ',', '"', '\n', '\r' }) >= 0)
|
||||
{
|
||||
return "\"" + field.Replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
|
||||
return field;
|
||||
}
|
||||
|
||||
private static List<string> ParseCsvLine(string line)
|
||||
{
|
||||
var fields = new List<string>();
|
||||
var i = 0;
|
||||
var n = line.Length;
|
||||
|
||||
while (i < n)
|
||||
{
|
||||
string field;
|
||||
if (line[i] == '"')
|
||||
{
|
||||
i++;
|
||||
var sb = new StringBuilder();
|
||||
while (i < n)
|
||||
{
|
||||
if (line[i] == '"')
|
||||
{
|
||||
if (i + 1 < n && line[i + 1] == '"')
|
||||
{
|
||||
sb.Append('"');
|
||||
i += 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(line[i]);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
field = sb.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
var start = i;
|
||||
while (i < n && line[i] != ',')
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
field = line.Substring(start, i - start);
|
||||
}
|
||||
|
||||
fields.Add(field);
|
||||
if (i < n && line[i] == ',')
|
||||
{
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimal placeholder substitution for Subject/BodyHtml templates.
|
||||
/// Deliberately simple (no conditionals/loops) - templates are short,
|
||||
/// hand-edited strings, not a general templating language.
|
||||
/// </summary>
|
||||
public static class TemplateEngine
|
||||
{
|
||||
private static readonly Regex PlaceholderPattern = new Regex(@"\{(?<name>[A-Za-z0-9_]+)\}", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Replaces every "{Name}" placeholder with the matching value from
|
||||
/// <paramref name="values"/> (case-insensitive lookup). Unknown
|
||||
/// placeholders are left untouched rather than throwing, so a typo
|
||||
/// in a hand-edited template shows up visibly in the preview
|
||||
/// instead of crashing the scan.
|
||||
/// </summary>
|
||||
public static string Render(string template, IDictionary<string, string> values)
|
||||
{
|
||||
if (string.IsNullOrEmpty(template))
|
||||
{
|
||||
return template ?? string.Empty;
|
||||
}
|
||||
|
||||
return PlaceholderPattern.Replace(template, match =>
|
||||
{
|
||||
var name = match.Groups["name"].Value;
|
||||
foreach (var kvp in values)
|
||||
{
|
||||
if (string.Equals(kvp.Key, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return kvp.Value ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
return match.Value; // leave "{Unknown}" as-is
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Renders both Subject and BodyHtml of a template for one recipient.</summary>
|
||||
public static MailTemplate Render(MailTemplate template, string login, string email)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Login"] = login ?? "",
|
||||
["Email"] = email ?? "",
|
||||
["Date"] = DateTime.Now.ToString("dd.MM.yyyy"),
|
||||
};
|
||||
|
||||
return new MailTemplate
|
||||
{
|
||||
Subject = Render(template?.Subject ?? "", values),
|
||||
BodyHtml = Render(template?.BodyHtml ?? "", values),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"archiveFolder": "",
|
||||
"emailDomain": "redlab.lo",
|
||||
"testModeDefault": true,
|
||||
"signatureHtmlPath": "",
|
||||
"templates": {
|
||||
"ssh_access": {
|
||||
"subject": "Доступ к SSH jump-хосту — {Login}",
|
||||
"bodyHtml": "<p>Здравствуйте!</p><p>Во вложении — архив с вашим персональным SSH-ключом, готовым клиентским конфигом и краткой инструкцией по подключению (<b>{Login}_ssh_access.7z</b>).</p><p>Пароль от архива будет отправлен отдельным сообщением — не пересылайте архив вместе с паролем.</p><p>Если по какой-то причине не получается распаковать архив или подключиться — напишите в ответ на это письмо.</p>"
|
||||
},
|
||||
"config_update": {
|
||||
"subject": "Обновление конфигурации доступа — {Login}",
|
||||
"bodyHtml": "<p>Здравствуйте!</p><p>Список доступных вам через jump-хост сервисов изменился. Во вложении — обновлённый клиентский конфиг и инструкция (<b>{Login}_config_update.7z</b>). Ваш существующий SSH-ключ по-прежнему действителен, переустанавливать его не нужно — достаточно заменить конфиг.</p><p>Пароль от архива будет отправлен отдельным сообщением — не пересылайте архив вместе с паролем.</p>"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BulkArchiveMailer.Core", "BulkArchiveMailer.Core\BulkArchiveMailer.Core.csproj", "{96DFBF08-8BCA-4A4E-BDF8-3F013965E30E}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BulkArchiveMailer.Core.Tests", "BulkArchiveMailer.Core.Tests\BulkArchiveMailer.Core.Tests.csproj", "{A653C590-569E-4A9C-8573-0532D93CBB48}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BulkArchiveMailer.AddIn", "BulkArchiveMailer.AddIn\BulkArchiveMailer.AddIn.csproj", "{F0475AB7-87AA-4E6B-9BF9-1779AD59912E}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{96DFBF08-8BCA-4A4E-BDF8-3F013965E30E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{96DFBF08-8BCA-4A4E-BDF8-3F013965E30E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{96DFBF08-8BCA-4A4E-BDF8-3F013965E30E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{96DFBF08-8BCA-4A4E-BDF8-3F013965E30E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A653C590-569E-4A9C-8573-0532D93CBB48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A653C590-569E-4A9C-8573-0532D93CBB48}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A653C590-569E-4A9C-8573-0532D93CBB48}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A653C590-569E-4A9C-8573-0532D93CBB48}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F0475AB7-87AA-4E6B-9BF9-1779AD59912E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F0475AB7-87AA-4E6B-9BF9-1779AD59912E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F0475AB7-87AA-4E6B-9BF9-1779AD59912E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F0475AB7-87AA-4E6B-9BF9-1779AD59912E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,124 @@
|
||||
# BulkArchiveMailer (outlook-bulk-sender)
|
||||
|
||||
VSTO-плагин классического Outlook (Win32/C2R, **не** "новый Outlook" — тот
|
||||
не поддерживает VSTO и локальный доступ к файловой системе) для массовой
|
||||
рассылки зашифрованных `.7z`-архивов, которые генерирует
|
||||
[`ssh-access-tui`](../README.md) для каждого пользователя jump-хоста.
|
||||
|
||||
## Как это связано с ssh-access-tui
|
||||
|
||||
`ssh-access-tui` (см. `internal/keymgr/pack.go` и
|
||||
`internal/keymgr/configonly.go` в корне репозитория) складывает в общий
|
||||
выходной каталог по одному архиву на пользователя:
|
||||
|
||||
- `{login}_ssh_access.7z` — новая пара ключей + конфиг + инструкция (выдаётся
|
||||
при первом доступе или полной переиздаче ключа).
|
||||
- `{login}_config_update.7z` — только обновлённый конфиг + инструкция, без
|
||||
ключа (выдаётся, когда у пользователя поменялся список разрешённых
|
||||
сервисов, а сам ключ остался прежним).
|
||||
|
||||
`ssh-access-tui` только *генерирует* эти архивы — раздача их получателям по
|
||||
почте раньше делалась вручную. BulkArchiveMailer автоматизирует именно эту
|
||||
последнюю часть: сканирует папку, по имени файла восстанавливает логин и
|
||||
адрес получателя, подставляет письмо по шаблону, показывает предпросмотр со
|
||||
статусами и рассылает.
|
||||
|
||||
BulkArchiveMailer **не порождает архивы и не знает пароль от них** — пароль
|
||||
общий на партию экспорта и сообщается получателям отдельно, вне этого
|
||||
инструмента (см. баннер-напоминание в окне предпросмотра). Инструмент только
|
||||
прикладывает уже готовый файл архива к письму.
|
||||
|
||||
## Как логин превращается в адрес
|
||||
|
||||
Правило простое и единственное: в логине из имени файла (например,
|
||||
`a-makarov`) каждый дефис заменяется на точку, и добавляется домен —
|
||||
получаем `a.makarov@<домен>`. Отдельной таблицы соответствий нет и не
|
||||
планируется.
|
||||
|
||||
**Известное ограничение:** если у реального логина дефис не является
|
||||
разделителем инициала/фамилии (то есть входит в исходное системное имя как
|
||||
есть, а не как искусственный разделитель), адрес получится неправильным.
|
||||
Механизм переопределения (per-user override) сознательно не реализован —
|
||||
такие логины считаются исключением, которое разбирается вручную (переслать
|
||||
письмо оператором с указанием правильного адреса).
|
||||
|
||||
## Структура решения
|
||||
|
||||
```
|
||||
BulkArchiveMailer.sln
|
||||
BulkArchiveMailer.Core/ net48, class library, БЕЗ ссылок на Outlook/COM/реестр — можно
|
||||
собрать и протестировать без Windows/Office.
|
||||
FilenameParser.cs парсинг "{login}_(ssh_access|config_update).7z"
|
||||
EmailAddressBuilder.cs login -> "login.with.dots@domain"
|
||||
SendJob.cs модель строки предпросмотра (Login/Email/Kind/ArchivePath/…/Status)
|
||||
ArchiveScanner.cs сканирование папки, группировка по логину, конфликт "оба архива сразу"
|
||||
TemplateEngine.cs подстановка {Login}/{Email}/{Date} в шаблон
|
||||
MailTemplates.cs два именованных шаблона (ssh_access / config_update)
|
||||
SendLogStore.cs CSV-журнал отправок + дедуп по (Email, Kind, SHA-256 архива)
|
||||
AppSettings.cs %APPDATA%\BulkArchiveMailer\config.json, засев из config.default.json
|
||||
config.default.json шаблоны писем и домен по умолчанию (встроен как Embedded Resource)
|
||||
BulkArchiveMailer.Core.Tests/ xUnit-заготовка (один smoke-тест) — полное покрытие добавляется отдельно
|
||||
BulkArchiveMailer.AddIn/ VSTO-плагин, net48, Windows-only, ссылается на Core
|
||||
ThisAddIn.cs / .Designer.cs запуск/остановка плагина
|
||||
Ribbon.xml + RibbonController.cs одна кнопка ленты "Массовая рассылка"
|
||||
SignatureReader.cs чтение подписи Outlook из %APPDATA%\Microsoft\Signatures через реестр
|
||||
MailSender.cs создание/отправка Outlook.MailItem, тестовый режим (Черновики)
|
||||
UI/SettingsForm.cs папка, домен, подпись, тестовый режим по умолчанию, оба шаблона
|
||||
UI/PreviewForm.cs таблица предпросмотра, баннер про пароль, отправка, итоговый журнал
|
||||
docs/BUILD.md как собрать и подключить плагин в Visual Studio на Windows
|
||||
```
|
||||
|
||||
Почему граница именно такая: всё, что в принципе можно проверить без
|
||||
Windows/Office (парсинг имён, вывод email, шаблоны, дедуп-журнал,
|
||||
JSON-настройки), живёт в `BulkArchiveMailer.Core` и покрывается обычными
|
||||
xUnit-тестами. Всё, что обязано трогать Outlook (COM-объекты писем, реестр
|
||||
для подписи, лента) — в `BulkArchiveMailer.AddIn`, который без установленного
|
||||
Outlook в принципе не запустить.
|
||||
|
||||
## Настройка
|
||||
|
||||
При первом запуске плагин создаёт `%APPDATA%\BulkArchiveMailer\config.json`
|
||||
из встроенного `config.default.json`. Дальше всё редактируется через окно
|
||||
"Настройки" (открывается кнопкой ленты, если папка с архивами ещё не
|
||||
настроена или не существует):
|
||||
|
||||
- **Папка с архивами** — тот самый общий выходной каталог `ssh-access-tui`.
|
||||
- **Домен электронной почты** — по умолчанию `redlab.lo` (реальный домен
|
||||
организации), обычное редактируемое поле, а не зашитая логика.
|
||||
- **Файл подписи (.htm)** — можно оставить пустым (тогда при каждой рассылке
|
||||
подпись определяется автоматически по умолчанию Outlook для новых писем)
|
||||
либо указать конкретный файл вручную — например, если у оператора настроена
|
||||
не та подпись, которую нужно вставлять в такие письма.
|
||||
- **Шаблоны писем** — тема и HTML-тело отдельно для `ssh_access` и
|
||||
`config_update`, с плейсхолдерами `{Login}`, `{Email}`, `{Date}`.
|
||||
- **Тестовый режим по умолчанию** — включён по умолчанию (письма сохраняются
|
||||
в Черновики, а не отправляются) до первого осознанного отключения
|
||||
оператором в окне предпросмотра.
|
||||
|
||||
Журнал отправок пишется в `send-log.csv` прямо в папке с архивами
|
||||
(`TimestampUtc,Login,Email,Kind,ArchivePath,FileSha256,Status,Error`). Строка
|
||||
считается "уже отправлено" (`AlreadySent` в предпросмотре), только если для
|
||||
той же пары (email, вид архива) в журнале есть строка `Status=Sent` с тем же
|
||||
SHA-256 файла — то есть если архив перегенерировали (новый ключ или новый
|
||||
список сервисов), это будет новый хэш, и строка снова станет `Ready`.
|
||||
|
||||
## Поведение, которое нельзя менять не глядя
|
||||
|
||||
- Файл с "неправильным" именем не блокирует остальную рассылку — он просто
|
||||
попадает в список нераспознанных со статусом "Пропущено".
|
||||
- Если для одного логина одновременно лежат оба вида архива — это ошибка
|
||||
данных выше по конвейеру (в норме такого не должно случаться), и строка
|
||||
помечается как `Error`, заблокирована для отправки, без автоматического
|
||||
выбора одного из двух файлов.
|
||||
- Одна неудачная отправка не прерывает пачку — журналируется, и цикл идёт
|
||||
дальше.
|
||||
- Пароль от архива этот инструмент никогда не формирует, не хранит и не
|
||||
отправляет — только статичное напоминание в интерфейсе, что пароль нужно
|
||||
сообщить получателю отдельно.
|
||||
|
||||
## Статус сборки
|
||||
|
||||
**Ничего из этого решения не скомпилировано и не запущено** — код написан в
|
||||
среде без Windows, Visual Studio, Office и .NET SDK. Первую сборку и
|
||||
проверку нужно сделать на машине с Windows + Visual Studio (workload
|
||||
"Office/SharePoint development") — см. [docs/BUILD.md](docs/BUILD.md).
|
||||
@@ -0,0 +1,121 @@
|
||||
# Сборка и подключение BulkArchiveMailer
|
||||
|
||||
Это решение написано и ни разу не собрано в среде без Windows/Visual
|
||||
Studio/.NET SDK — весь код написан вручную по памяти о реальных
|
||||
VSTO/Outlook Interop API, но **первая сборка обязательно должна пройти на
|
||||
вашей машине**, прежде чем этому плагину можно будет доверять. Ниже —
|
||||
пошаговая инструкция и список мест, где по опыту чаще всего что-то не
|
||||
сходится при первом открытии вручную написанного `.csproj`.
|
||||
|
||||
## Требования
|
||||
|
||||
- Windows 10/11.
|
||||
- Классический Outlook (Win32 или Microsoft 365 C2R) — **не** "новый
|
||||
Outlook" (он не поддерживает VSTO).
|
||||
- Visual Studio 2022 (Community — бесплатная) с установленным workload'ом
|
||||
**".NET desktop development"** И **"Office/SharePoint development"**
|
||||
(последний ставит шаблоны VSTO, VSTO Runtime и Office Developer Tools —
|
||||
без него сборка `BulkArchiveMailer.AddIn` не найдёт
|
||||
`Microsoft.Office.Tools.*` сборки).
|
||||
- Установленный **VSTO Runtime** (обычно ставится вместе с
|
||||
Office/SharePoint workload'ом; если Outlook жалуется на "not a valid
|
||||
Office Add-in" при загрузке — скорее всего он не установлен отдельно,
|
||||
доустановите "Microsoft Visual Studio 2010 Tools for Office Runtime" /
|
||||
актуальную версию VSTO Runtime).
|
||||
|
||||
## Открытие и сборка
|
||||
|
||||
1. Откройте `BulkArchiveMailer.sln` в Visual Studio.
|
||||
2. Дайте NuGet восстановить пакеты (`BulkArchiveMailer.Core.Tests` тянет
|
||||
xUnit + `Microsoft.NET.Test.Sdk`; `BulkArchiveMailer.Core` и
|
||||
`BulkArchiveMailer.AddIn` NuGet-пакетов не используют вовсе — это
|
||||
осознанное решение, см. `AppSettings.cs`, чтобы не зависеть от
|
||||
восстановления пакетов в непроверенной среде).
|
||||
3. Соберите решение (Build → Build Solution). Скорее всего первая сборка
|
||||
пройдёт чисто, но если нет — см. раздел "Типичные проблемы" ниже.
|
||||
4. `BulkArchiveMailer.Core.Tests` можно запустить через Test Explorer —
|
||||
там сейчас один smoke-тест (`FilenameParserTests`), подтверждающий, что
|
||||
тестовый проект в принципе собирается и видит `Core`. Полное покрытие
|
||||
тестами — отдельная задача (dotnet-tester), не часть этой сборки.
|
||||
|
||||
## Отладка (F5)
|
||||
|
||||
Проект `BulkArchiveMailer.AddIn` настроен как VSTO Outlook Add-in — при
|
||||
нажатии F5 (с ним как стартовым проектом) Visual Studio должна сама
|
||||
зарегистрировать временную запись в реестре и запустить Outlook с уже
|
||||
загруженным плагином. Если Outlook уже был открыт — закройте его перед F5.
|
||||
|
||||
Кнопка ленты **"Массовая рассылка"** появляется на вкладке "Главная"
|
||||
основного окна Outlook (список писем), в группе "ssh-access-tui".
|
||||
|
||||
## Установка вне Visual Studio (ручной sideload)
|
||||
|
||||
Это внутренний инструмент, поэтому здесь используется **sideload через
|
||||
реестр**, а не полноценная публикация ClickOnce с веб-сервером или
|
||||
раскатка через GPO (это отдельная задача на будущее, если понадобится
|
||||
массово ставить плагин большому числу операторов).
|
||||
|
||||
1. Соберите решение в конфигурации Release (Build → Configuration Manager
|
||||
→ Release, затем Build Solution).
|
||||
2. В `BulkArchiveMailer.AddIn\bin\Release\` после сборки появятся, среди
|
||||
прочего, `BulkArchiveMailer.AddIn.dll` и
|
||||
`BulkArchiveMailer.AddIn.vsto` (манифест генерируется автоматически
|
||||
MSBuild-таргетами VSTO — в исходниках его нет и не должно быть,
|
||||
`.gitignore` его игнорирует).
|
||||
3. Скопируйте всю папку `bin\Release\` в постоянное место на диске
|
||||
оператора (например, `C:\Tools\BulkArchiveMailer\`) — VSTO plug-in
|
||||
должен запускаться из стабильного пути, а не из папки сборки, которая
|
||||
может быть пересобрана/удалена.
|
||||
4. Добавьте ключ реестра, которым Outlook находит плагин:
|
||||
|
||||
```
|
||||
[HKEY_CURRENT_USER\Software\Microsoft\Office\Outlook\Addins\BulkArchiveMailer.AddIn]
|
||||
"Description"="Массовая рассылка архивов ssh-access-tui"
|
||||
"FriendlyName"="BulkArchiveMailer"
|
||||
"Manifest"="file:///C:\\Tools\\BulkArchiveMailer\\BulkArchiveMailer.AddIn.vsto|vstolocal"
|
||||
"LoadBehavior"=dword:00000003
|
||||
```
|
||||
|
||||
(можно сохранить как `.reg`-файл и применить двойным щелчком).
|
||||
5. Запустите Outlook. Поскольку манифест не подписан сертификатом
|
||||
(`SignManifests=false` в `.csproj` — см. комментарий там же о причине),
|
||||
при первой загрузке Outlook, скорее всего, покажет предупреждение
|
||||
"не удалось проверить издателя" или подобное. Варианты:
|
||||
- принять предупреждение вручную (для одного оператора это разумно);
|
||||
- добавить папку с плагином в доверенные расположения для VSTO:
|
||||
`HKEY_CURRENT_USER\Software\Microsoft\VSTO\Security\Trust Manager\TrustedFolders\<любой уникальный ключ>` = путь к папке с `.vsto`, `String` REG_SZ.
|
||||
- для более широкой раскатки — подписать манифест сертификатом
|
||||
(Project Properties → Signing в Visual Studio) и настроить
|
||||
доверие централизованно (GPO/сертификат в доверенных издателях) —
|
||||
это уже упомянутое будущее развитие, не сделано в этой версии.
|
||||
|
||||
## Типичные проблемы при первом открытии
|
||||
|
||||
Поскольку `.csproj`/`.sln` в этом решении написаны вручную (без мастера
|
||||
Visual Studio, из-за отсутствия здесь Windows/VS/.NET SDK для
|
||||
генерации), возможны мелкие расхождения с тем, что сгенерировал бы
|
||||
реальный мастер "Outlook VSTO Add-in". Если сборка `BulkArchiveMailer.AddIn`
|
||||
падает на ссылках `Microsoft.Office.*`/`Microsoft.VisualStudio.Tools.*`:
|
||||
|
||||
1. Проверьте, что workload "Office/SharePoint development" точно
|
||||
установлен (Visual Studio Installer → Modify).
|
||||
2. Если конкретная ссылка подсвечена жёлтым в Solution Explorer — правой
|
||||
кнопкой на неё → Properties → сверьте путь; как правило, достаточно
|
||||
удалить и заново добавить через Add Reference → Assemblies →
|
||||
Extensions, найдя те же имена (`Microsoft.Office.Tools.Outlook` и
|
||||
т.д.) — они должны там появиться, если workload установлен.
|
||||
3. Если `Microsoft.Office.Interop.Outlook`/`office` не резолвятся с
|
||||
версией `15.0.0.0` — это нормально расходится в зависимости от того,
|
||||
какая версия Office PIA установлена; смените `Version=` в ссылке (в
|
||||
`.csproj`, ищите `Microsoft.Office.Interop.Outlook`/`office`) на
|
||||
установленную (`14.0.0.0` для Office 2010 PIA и т.п.) либо просто
|
||||
удалите атрибут версии в Solution Explorer и добавьте заново через
|
||||
Add Reference — Visual Studio подставит то, что реально стоит на
|
||||
машине.
|
||||
4. TargetFrameworkVersion `v4.8` требует установленного .NET Framework 4.8
|
||||
Developer Pack — Visual Studio предложит установить его сама при
|
||||
открытии, если его нет.
|
||||
|
||||
Ничего из перечисленного не проверялось вживую — список составлен по
|
||||
опыту работы с VSTO-проектами в целом, а не по факту увиденной ошибки в
|
||||
этом конкретном решении.
|
||||
Binary file not shown.
Reference in new issue
Block a user