mirror of
https://gitverse.ru/kpa39l/email-assistant.git
synced 2026-09-29 09:15:09 +00:00
Session 2026-09-15 code: imap_client (auth+metrics), handlers (TG date format), cron fix (venv python), openspec imap-realtime-sync proposal
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
# 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 (уже есть паттерн) |
|
||||
Reference in New Issue
Block a user