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
This commit is contained in:
@@ -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 ящика, конфигурируемо.
|
||||
Reference in New Issue
Block a user