# 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 ящика, конфигурируемо.