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

155 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 ящика, конфигурируемо.