Files

212 lines
13 KiB
Markdown
Raw Permalink 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.
# 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-<folder>.json`.
Уже есть: `fetch_attachments_imaplib()` (сырой IMAP, BODY.PEEK[], не ставит
`\Seen`), `get_inbox_subfolders()` (динамическое обнаружение 136 папок,
CHILDREN), `_imap_utf7_encode()` (modified UTF-7 для кириллических папок).
- Архив: `/opt/hermes/email/<folder>/YYYY/MM/<uid>/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 (уже есть паттерн) |