# Design: Потоковая синхронизация почты (IMAP IDLE) и онлайновая копия ящика ## Context - Сервер: `mail.corpoffice.tech:143` (STARTTLS), Microsoft Exchange. Проверено 2026-09-15 (ручной IMAP-тест): - CAPABILITY: `IMAP4 IMAP4rev1 AUTH=PLAIN AUTH=NTLM AUTH=GSSAPI STARTTLS SASL-IR UIDPLUS MOVE ID UNSELECT CHILDREN IDLE NAMESPACE LITERAL+` - **IDLE, MOVE, UIDPLUS, CHILDREN, LITERAL+ — всё есть**, значит потоковая синхронизация и отслеживание перемещений возможны. - Логин с чистого скрипта (`LOGIN` / `AUTHENTICATE PLAIN`) не прошёл (NO LOGIN failed). himalaya логинится успешно — вероятно, проблема в способе авторизации/лимитах для «незнакомого» клиента, а не в пароле. В design — использовать существующий himalaya-конфиг и проверенный путь. - Текущий архиватор: `scripts/mail_archive.py` — poll каждые 5 мин (Hermes cron `mail-archive-every-5min`, id 5f2305b2bbf8). Отслеживает `last_uid` по папкам в `/opt/hermes/email/state/mail-archive-last-.json`. Уже есть: `fetch_attachments_imaplib()` (сырой IMAP, BODY.PEEK[], не ставит `\Seen`), `get_inbox_subfolders()` (динамическое обнаружение 136 папок, CHILDREN), `_imap_utf7_encode()` (modified UTF-7 для кириллических папок). - Архив: `/opt/hermes/email//YYYY/MM//email.md` (frontmatter: id, folder, subject, from, to, date, flags, message_id, in_reply_to, references, cc, content_type; + classification, handled_*, has_attachment). **5467 писем** на 2026-09-15. - Индексы: `scripts/mail_index.py` (SQLite FTS5 `/opt/hermes/email/mail_index.db`), `sqlite_search.py`, классификатор `email_classifier.py` (Qwen3:8b, Ollama), обработчики `email_handlers.py` (urgent→TG, task→VTODO, meeting→VEVENT), `digest.py` (еженедельный). - Пользователь активно меняет ящик в клиенте: переносит в папки, удаляет, отвечает. Эти изменения сейчас НЕ видны архиватору. ## Решение ### 1. Выбор модели: 2 постоянных процесса (systemd user units) Не один процесс на всё, а два — разделение ответственности (как ты предложил): 1. **`email-imap-stream.service`** — держит IMAP-соединение, IDLE, пишет события в SQLite (`mailbox_events`), архивирует новые письма. *Единственный* процесс, который разговаривает с IMAP (кроме fallback-cron). 2. **`email-change-analyzer.service`** — постоянно читает `mailbox_events`, обновляет online-копию (`mailbox_state`), запускает классификатор/обработчики, индексы, может писать «журнал изменений» для пользователя. Почему 2, а не 1: падение анализатора не теряет события (они в ChangeLog, append-only); падение stream-процесса не останавливает анализ (fallback-cron докачает письма). Отвязка скорости IMAP от скорости LLM-анализа. ### 2. Библиотека для IMAP stream Варианты: - (a) `aioimaplib` — Python asyncio IMAP с поддержкой IDLE, reconnect, ивенты. Мягкая зависимость; если нет — pip install. Совместим с Exchange (IDLE есть). - (b) Сырой socket (как уже сделано в `fetch_attachments_imaplib`) + select() на IDLE — без новых зависимостей, но больше кода (reconnect, парсинг untagged-ответов, литералы). **Выбор: (a) `aioimaplib`** — проверенная библиотека, IDLE/reconnect из коробки; сырой IMAP оставляем только в `fetch_attachments_imaplib` (там он уже работает и менять не нужно). Если `aioimaplib` недоступен/не заводится на нашем Python — fallback (b). **Авторизация:** переиспользовать учётку из `config/himalaya-config.toml` (login `e.storozhenko`, host `mail.corpoffice.tech`). Для stream-процесса — `AUTH=PLAIN` с SASL-IR (Exchange поддерживает; но в тесте не прошло — проверить в задаче 2: использовать himalaya `account sync`/`envelope list` как эталон, возможно, потребуется `\r\n`-формат или конкретный порядок SASL). ### 3. Схема SQLite (новая БД или таблицы в существующей) Отдельная БД: `/opt/hermes/email/state/mailbox.db` (не смешивать с `mail_index.db`, чтобы не ломать FTS-индексы и было легко откатиться). ```sql -- Онлайновая копия ящика: текущее состояние каждого письма CREATE TABLE mailbox_state ( uid INTEGER NOT NULL, -- UID в папке folder TEXT NOT NULL, -- папка (например 'INBOX', 'INBOX/Проекты') message_id TEXT, -- Message-ID in_reply_to TEXT, references TEXT, -- цепочка (References / In-Reply-To) subject TEXT, from_addr TEXT, date TEXT, flags TEXT, -- JSON-массив флагов IMAP has_attachment INTEGER DEFAULT 0, archive_path TEXT, -- путь к email.md (если заархивировано) etag TEXT, -- ETag сервера (возможно, не нужен для IMAP) last_seen TEXT, -- ISO-время последней синхронизации deleted INTEGER DEFAULT 0, -- soft-delete (пользователь удалил на сервере) PRIMARY KEY (folder, uid) ); -- Журнал изменений (ChangeLog), append-only CREATE TABLE mailbox_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, -- ISO-время события event TEXT NOT NULL, -- added | moved | deleted | flag_changed | replied | reconcile folder TEXT, uid INTEGER, message_id TEXT, details TEXT -- JSON: from_folder, to_folder, old_flags, new_flags и т.п. ); CREATE INDEX idx_events_message ON mailbox_events(message_id); CREATE INDEX idx_events_ts ON mailbox_events(ts); ``` ### 4. Алгоритм stream-процесса (`imap_stream.py`) ``` loop: 1. connect + login (AUTH=PLAIN / STARTTLS) 2. folder list (get_inbox_subfolders через IMAP, dynamic) 3. для каждой папки: SELECT; если нет в mailbox_state — полный reconcile (UID FETCH 1:* (FLAGS UID MESSAGE-ID ...) = initial sync) 4. reconcile источником истины: для INBOX + подпапок - UID FETCH (новые UID > last_uid) → событие added, архивация - сравнение FLAGS (в т.ч. \Seen, \Answered, \Flagged) → flag_changed - UID SEARCH EXPUNGE / отсутствие в SELECT → deleted (если был в state) 5. IDLE loop (для INBOX, и по очереди для подпапок если CPU позволяет): - IDLE → ждём untagged: EXISTS (новое письмо), EXPUNGE (удаление), FETCH FLAGS (изменение флагов), MOVE (если сервер шлёт) - на событие: обработать (архивировать/обновить state/записать event) - продлевать IDLE каждые ~29 мин (сервер обычно рвёт после 30) 6. при обрыве: reconnect + reconcile (REQ-IMAP-SYNC-001/002) 7. период паузы между reconcile: 60-300 сек (конфигурируемо) ``` **Архивация новых писем** — переиспользуем существующий код `mail_archive.py`: `get_email_content()` (himalaya `message read --preview`, не ставит Seen) + `fetch_attachments_imaplib()` (BODY.PEEK[]). Функции вынести в общий модуль или импортировать (mail_archive.py уже модульный). **Удаление:** при событии EXPUNGE/`deleted` — НЕ удаляем файл email.md (письмо остаётся в архиве, REQ-IMAP-SYNC-004 soft-delete), помечаем `deleted=1` в `mailbox_state`, пишем событие `deleted` в ChangeLog. Пользователь сможет видеть «это письмо вы удалили» в RAG/поиске. **Перемещение:** при обнаружении (MOVE/UIDPLUS от сервера или reconcile: UID в старой папке исчез, в новой появился с тем же Message-ID) — обновить `folder`, записать `moved` с from/to. Если UID меняется (Exchange MOVE) — следить по Message-ID. ### 5. Анализатор (`change_analyzer.py`) ``` loop: 1. читать mailbox_events от последнего обработанного id (offset в state) 2. для каждого события: added → классифицировать (email_classifier), обработать (email_handlers), обновить индексы (mail_index --incremental) flag_changed → обновить flags в mailbox_state; если \Answered → событие replied moved → обновить folder; если классификация была — можно переклассифицировать deleted → пометить в RAG/индексе удалённым (не удаляя файл) 3. записать обработанный id (persistent) 4. спать 1-5 сек (или ждать сигнала от stream через очередь) ``` **Связь stream ↔ analyzer:** через SQLite (ChangeLog) — это проще и надёжнее, чем IPC/очереди. Stream пишет, analyzer читает с offset. Несколько analyser процессов не нужны (одна учётка). ### 6. Управление / systemd Два user unit (в `~/.config/systemd/user/`): ```ini # email-imap-stream.service [Unit] Description=Email IMAP realtime sync stream After=network-online.target [Service] Type=simple ExecStart=/usr/bin/python3 /opt/hermes/email-assistant/scripts/imap_stream.py Restart=always RestartSec=5 Environment=HOME=/home/estorozhenko # для himalaya (конфиг там) [Install] WantedBy=default.target ``` Аналогично `email-change-analyzer.service`. Включить: `systemctl --user enable --now`. **Hermes cron** `mail-archive-every-5min` → оставить как **fallback**: если оба сервиса не работают (например, после ребута без автозапуска), cron всё равно архивирует раз в 5 мин (REQ-IMAP-SYNC-007). Чтобы не дублировать: stream-процесс и cron оба идемпотентны (last_uid / mailbox_state). ### 7. Что НЕ делаем (ограничения) - **НЕ** переписываем архиватор под IMAP с нуля — используем существующий `mail_archive.py` как библиотеку/подпроцесс. - **НЕ** удаляем файлы писем при удалении на сервере (RAG должен видеть «удалено», но данные не теряем). - **НЕ** реалтайм-синхронизация «один к одному» всех 137 папок через отдельные IDLE-коннекты — IDLE держим на INBOX (главный источник), подпапки — через reconcile (раз в 1-5 мин) + CHILDREN-обнаружение. Это баланс скорости и ресурсов (Exchange лимитирует коннекты на учётку). - **НЕ** реализуем SMTP/отправку — только чтение (как сейчас). ### 8. Риски и смягчение | Риск | Смягчение | |---|---| | Exchange рвёт IDLE после ~30 мин | авто-reconnect + reconcile после каждого обрыва | | LOGIN с чистого скрипта не прошёл | использовать himalaya-путь; AUTH=PLAIN; задача 2 — проверить формат | | IDLE не гарантирует доставку всех событий | reconcile каждые 1-5 мин (REQ-002) | | MOVE меняет UID на Exchange | следить по Message-ID, а не UID | | Много папок (137) — много коннектов | IDLE только на INBOX; подпапки — reconcile по очереди | | aioimaplib не установлен | fallback на сырой socket (уже есть паттерн) |