commit 0e7736f6132b1bf0a7f30e3e1ee4bcc682af0c8e Author: vasyansk Date: Wed Jul 1 16:16:14 2026 +0700 docs: design spec for imap-copier Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd diff --git a/docs/superpowers/specs/2026-07-01-imap-copier-design.md b/docs/superpowers/specs/2026-07-01-imap-copier-design.md new file mode 100644 index 0000000..f68e0f4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-imap-copier-design.md @@ -0,0 +1,154 @@ +# imap-copier — Design Spec + +**Дата:** 2026-07-01 +**Статус:** утверждён к планированию + +## Context + +Нужна утилита для массового переноса почты между IMAP-провайдерами — «улучшение» +классического imapsync с современным веб-интерфейсом. Задания создаются вручную +или импортом CSV. Перенос должен показываться в реальном времени, быть +идемпотентным (повторный запуск не дублирует письма) и не оседать телами писем на +диске хоста с софтом. Итог — self-hosted контейнер, поднимаемый за Caddy. + +## Ключевые решения (зафиксированы с заказчиком) + +- **Движок:** нативный Go на `github.com/emersion/go-imap/v2` (не обёртка над Perl-imapsync). +- **Data path:** стриминг в RAM. Тело письма живёт в памяти только на время одного + `FETCH → APPEND`. Тела писем НИКОГДА не пишутся на диск хоста. Ящик на 500 ГБ + переносится при near-zero использовании диска. +- **Семантика:** только копирование (недеструктивно). Источник не изменяется. + `move`/удаление из источника — НЕ реализуется (осознанно отвергнуто как рискованное). +- **Хранилище состояния:** PostgreSQL. +- **Дедуп:** по `Message-ID`, при отсутствии — fallback `md5(From|To|Subject|Date|Size)`. +- **IMAP не умеет server-to-server.** «Напрямую» = один процесс-посредник, стриминг в + памяти, без спула на диск и без отдельного прокси-хопа. Это физический максимум для IMAP. + +## Архитектура + +Один Docker-образ: Go-бинарник встраивает собранный React через `embed.FS` +(REST API + WebSocket + статика в одном процессе). Рядом — Postgres. Сверху — Caddy. + +``` +┌─────────────────── Docker compose ───────────────────┐ +│ caddy (:80, опц. :443 + Let's Encrypt ACME) │ +│ └─ reverse_proxy → app:8080 │ +│ app (Go): REST + WebSocket + embed(React) │ +│ └─ worker pool → go-imap src/dst соединения │ +│ postgres (внутренняя сеть, volume) │ +└─────────────────────────────────────────────────────────┘ +На диске хоста — только Postgres. Тел писем нет. +``` + +### Компоненты (границы ответственности) + +| Компонент | Что делает | Зависит от | +|---|---|---| +| `httpapi` | REST-роуты, auth-middleware, отдача embed React | store, orchestrator | +| `wshub` | пул WebSocket-подключений, рассылка событий по `task_id` | — | +| `store` | доступ к Postgres (endpoints/tasks/accounts/runs/migrated_messages), шифрование паролей | pgx | +| `imapx` | обёртка go-imap: connect, тест, list folders, stream FETCH→APPEND, вычисление message_key | go-imap/v2 | +| `orchestrator` | worker pool, прогон задания по ящикам/папкам, дедуп-проверка, эмит событий в wshub, запись в runs | store, imapx, wshub | +| `csvimport` | парсинг/валидация CSV → accounts | store | +| `config` | env: AUTH_*, ENC_KEY, SESSION_SECRET, DB DSN, параллелизм | — | + +## Модель данных (Postgres) + +- **endpoints** — `id, role_label, host, port, tls_mode (ssl|starttls|plain), created_at`. +- **tasks** — `id, name, src_endpoint_id, dst_endpoint_id, status, folder_mapping (jsonb), created_at`. +- **accounts** — `id, task_id, src_login, src_pass_enc, dst_login, dst_pass_enc, test_src_status, test_dst_status, copied_count, skipped_count, error_count, status`. +- **runs** — `id, task_id, started_at, finished_at, status, total_copied, total_skipped, total_errors`. +- **migrated_messages** — `id, account_id, folder, message_key, copied_at`. **UNIQUE(account_id, message_key)**. + +Пароли ящиков шифруются AES-256-GCM, ключ — env `ENC_KEY` (32 байта, base64). +Plain-пароли в БД не хранятся. Пароли не логируются и не возвращаются в API. + +Миграции — `golang-migrate`, файлы в `migrations/`. + +## Дедуп (идемпотентность повторного запуска) + +Перед `APPEND`: +1. Вычислить `message_key`: `Message-ID` если есть, иначе `md5(From|To|Subject|Date|Size)`. +2. `SELECT 1 FROM migrated_messages WHERE account_id=$1 AND message_key=$2`. +3. Есть → инкремент `skipped_count`, письмо пропускается. +4. Нет → `APPEND` → `INSERT ... ON CONFLICT DO NOTHING` → инкремент `copied_count`. + +Порядок: сначала `APPEND`, затем запись ключа. При падении между ними — на следующем +запуске письмо считается новым и переносится снова; возможный редкий дубль на приёмнике +приемлемее, чем потеря письма. (Документируется как известный trade-off.) + +## Тесты подключения (обязательны перед запуском) + +- **Test endpoints:** TCP + TLS handshake + `CAPABILITY` к src и dst. Результат per-endpoint. +- **Test accounts:** `LOGIN` в каждый src- и dst-ящик, `LIST` папок, `STATUS` (кол-во писем). + Результат per-account (`test_src_status`, `test_dst_status`). +- **Гейт:** `POST /api/tasks/:id/run` возвращает 409, если не все аккаунты прошли обе проверки. + +## Движок переноса + +Worker pool (дефолт 4 параллельных ящика, `WORKER_CONCURRENCY`). На ящик: +1. Connect src (read-only) + dst. +2. `LIST` папок src → применить `folder_mapping` (дефолт 1:1; спецпапки Gmail — через маппинг). +3. Для каждой папки: `SELECT` src (EXAMINE), стриминговый `FETCH` (UID+Envelope) → дедуп-фильтр. +4. Для новых писем: `FETCH BODY[]` через `.Next()` (без буферизации крупных литералов) → + `APPEND` в dst с сохранением флагов и internal date → запись ключа → эмит прогресса. +5. Ошибка на письме → лог + `error_count`, продолжаем со следующего (не валим весь ящик). + +Тело письма держится в памяти только на время одной итерации FETCH→APPEND. + +## WebSocket (реалтайм) + +- Подключение: `GET /ws?task_id=…` (под auth). +- События (JSON): `run_started`, `account_started`, `folder_progress` (folder, done/total), + `progress` (батч скопированных, throttle ~1/сек на аккаунт), `account_done`, + `error`, `run_done`. +- Начальное состояние при (пере)подключении — `GET /api/tasks/:id` (REST), затем поток WS. + +## Импорт CSV + +Endpoints src/dst задаются в UI один раз. CSV: по строке на ящик — +`src_login,src_pass,dst_login,dst_pass`. Парсинг → валидация (формат, дубли логинов, +пустые поля) → предпросмотр → создание `accounts` для задания. Пароли сразу шифруются. + +## Логирование + +`slog` (JSON) в stdout контейнера. Per-account и суммарные счётчики (copied/skipped/errors) +пишутся в `accounts`/`runs` и доступны в UI (экран лога задания). Пароли/тела писем в лог не попадают. + +## Авторизация UI + +Логин/пароль из env (`AUTH_USER`, `AUTH_PASS`). После логина — signed cookie +(HMAC-SHA256, `SESSION_SECRET`). Всё под auth, кроме `/login`, `/api/login`, `/healthz`. + +## Стек и инфраструктура + +- **Backend:** Go 1.22+, `emersion/go-imap/v2`, стандартный `net/http` (роут-паттерны 1.22), + `coder/websocket`, `jackc/pgx/v5`, `golang-migrate`, `slog`. +- **Frontend:** React + TypeScript + Vite, встраивается в бинарник (`embed.FS`). +- **Инфра:** multi-stage Dockerfile (node build → go build → scratch/distroless), + `docker-compose.yml` (app + caddy + postgres), `Caddyfile` с профилями `:80` (дефолт) + и `:443` + `tls` (ACME Let's Encrypt) через env-переключатель домена. + +## Тестирование (verification) + +- **Unit:** дедуп-ключ (Message-ID и fallback), шифрование паролей, CSV-парсер, + folder-mapping, session-cookie sign/verify. +- **Integration:** поднять тестовый IMAP-сервер (greenmail в контейнере или + `go-imap` server) → прогнать перенос → проверить идемпотентность (второй запуск = + 0 copied, N skipped). +- **E2E (prod):** деплой через docker-compose → логин в UI → создать endpoints → + импорт CSV → тесты подключения → запуск → наблюдать WebSocket-прогресс → + повторный запуск не дублирует. + +## Осознанно НЕ делаем (YAGNI) + +- Move/удаление из источника. +- Спул писем на диск, отдельный прокси-сервис. +- Мульти-юзер/RBAC (один env-аккаунт). +- Внешняя очередь/брокер — worker pool в процессе достаточно. + +## Открытые дефолты (принимаются, если не оспорены) + +- Роутер: стандартный `net/http`. +- Миграции: `golang-migrate`. +- Параллелизм: 4 ящика, конфигурируемо.