Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd
11 KiB
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, при отсутствии — fallbackmd5(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:
- Вычислить
message_key:Message-IDесли есть, иначеmd5(From|To|Subject|Date|Size). SELECT 1 FROM migrated_messages WHERE account_id=$1 AND message_key=$2.- Есть → инкремент
skipped_count, письмо пропускается. - Нет →
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). На ящик:
- Connect src (read-only) + dst.
LISTпапок src → применитьfolder_mapping(дефолт 1:1; спецпапки Gmail — через маппинг).- Для каждой папки:
SELECTsrc (EXAMINE), стриминговыйFETCH(UID+Envelope) → дедуп-фильтр. - Для новых писем:
FETCH BODY[]через.Next()(без буферизации крупных литералов) →APPENDв dst с сохранением флагов и internal date → запись ключа → эмит прогресса. - Ошибка на письме → лог +
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-imapserver) → прогнать перенос → проверить идемпотентность (второй запуск = 0 copied, N skipped). - E2E (prod): деплой через docker-compose → логин в UI → создать endpoints → импорт CSV → тесты подключения → запуск → наблюдать WebSocket-прогресс → повторный запуск не дублирует.
Осознанно НЕ делаем (YAGNI)
- Move/удаление из источника.
- Спул писем на диск, отдельный прокси-сервис.
- Мульти-юзер/RBAC (один env-аккаунт).
- Внешняя очередь/брокер — worker pool в процессе достаточно.
Открытые дефолты (принимаются, если не оспорены)
- Роутер: стандартный
net/http. - Миграции:
golang-migrate. - Параллелизм: 4 ящика, конфигурируемо.