Files
imap-copier/docs/superpowers/specs/2026-07-01-imap-copier-design.md
T
vasyansk 0e7736f613 docs: design spec for imap-copier
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd
2026-07-01 16:16:14 +07:00

11 KiB
Raw Blame History

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)

  • endpointsid, role_label, host, port, tls_mode (ssl|starttls|plain), created_at.
  • tasksid, name, src_endpoint_id, dst_endpoint_id, status, folder_mapping (jsonb), created_at.
  • accountsid, 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.
  • runsid, task_id, started_at, finished_at, status, total_copied, total_skipped, total_errors.
  • migrated_messagesid, 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. Нет → APPENDINSERT ... 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 ящика, конфигурируемо.