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:
2026-09-15 17:48:05 +00:00
parent 65c167acc2
commit defcf53d75
10 changed files with 982 additions and 28 deletions
@@ -0,0 +1,27 @@
# email-attachments Specification
## MODIFIED Requirements
### Requirement: Вложения сохраняются в каталог письма
Для каждого письма с вложениями (флаг `has_attachment: true` в frontmatter)
вложения MUST быть сохранены в подкаталог `attachments/` каталога письма
(`/opt/hermes/email/<folder>/YYYY/MM/<uid>/attachments/`). Скачивание выполняется
как при опросе по расписанию (текущее поведение), так и при событийной
синхронизации (сервис `imap-realtime-sync`). Скачивание вложений MUST NOT
выставлять флаг `\Seen`.
#### Scenario: Письмо с вложением архивировано
- **WHEN** `mail_archive.py` заархивировал письмо с `has_attachment: true`
- **THEN** файлы вложений лежат в `<msg_dir>/attachments/` и совпадают с вложениями на IMAP-сервере
#### Scenario: Вложения при событийной синхронизации
- **GIVEN** новое письмо с вложением обнаружено сервисом `email-imap-stream`
- **WHEN** письмо архивируется по событию IDLE
- **THEN** вложения скачаны в `<msg_dir>/attachments/`, флаг `\Seen` не выставлен
#### Scenario: Вложения при reconcile
- **GIVEN** письмо с вложением было пропущено (обрыв соединения)
- **WHEN** сервис выполняет reconcile
- **THEN** вложения докачиваются в `<msg_dir>/attachments/` (то же поведение,
что существующий `--attachments-backfill`)
@@ -0,0 +1,26 @@
# email-classification Specification
## MODIFIED Requirements
### Requirement: Классификация каждого нового письма
Классификация писем (Qwen3:8b через Ollama) MUST запускаться не только по
расписанию (cron `mail-classify-handlers`), но и по событиям из ChangeLog
(новое письмо / изменённое письмо), которые генерирует сервис
`email-imap-realtime-sync`. Механика классификации (теги info/urgent/task/meeting
+ `classification`/`classification_reason` в frontmatter) не меняется.
#### Scenario: Новое письмо после архивации
- **GIVEN** письмо заархивировано (`mail_archive.py`)
- **WHEN** `email_classifier.py` обрабатывает письмо
- **THEN** в frontmatter появляются `classification` и `classification_reason`
#### Scenario: Новое письмо классифицируется сразу
- **GIVEN** в ChangeLog появилось событие `added`
- **WHEN** анализатор `email-change-analyzer` обрабатывает событие
- **THEN** письмо классифицируется (тег + обоснование в frontmatter) без ожидания cron
#### Scenario: Событие обработано повторно (идемпотентность)
- **GIVEN** письмо уже классифицировано (есть `classification` в frontmatter)
- **WHEN** анализатор видит то же событие повторно (например, после рестарта)
- **THEN** классификация не выполняется заново (идемпотентно, как сейчас)
@@ -0,0 +1,118 @@
# imap-realtime-sync Specification
## ADDED Requirements
### Requirement: REQ-IMAP-SYNC-001: Постоянное IMAP-соединение с IDLE
Система MUST поддерживать постоянное IMAP-соединение с почтовым сервером
(mail.corpoffice.tech:143, STARTTLS) с использованием команды `IDLE`
(поддерживается сервером, проверено в CAPABILITY), чтобы узнавать о новых
письмах и изменениях в ящике **мгновенно**, без опроса по расписанию.
#### Scenario: Новое письмо появляется в INBOX
- **GIVEN** сервис `email-imap-stream` запущен и держит IDLE-соединение с INBOX
- **WHEN** в INBOX приходит новое письмо
- **THEN** сервис получает уведомление `* N EXISTS` от сервера в течение секунд
(не дольше таймаута IDLE) И инициирует архивацию письма
#### Scenario: IDLE-соединение обрывается
- **GIVEN** IDLE-соединение активно
- **WHEN** сервер закрывает соединение (таймаут/сбой)
- **THEN** сервис автоматически переподключается и выполняет полный reconcile
(синхронизацию изменений), чтобы не пропустить события, случившиеся во время обрыва
### Requirement: REQ-IMAP-SYNC-002: Reconcile как страховка от пропущенных событий
Сервис MUST периодически (не реже 1 раза в 5 минут) выполнять reconcile —
полную сверку состояния ящика с сервером (UID FETCH / STATUS), потому что IDLE
не гарантирует доставку всех событий (особенно при длительных соединениях).
#### Scenario: Событие потеряно при обрыве
- **GIVEN** сервис работал, но IDLE оборвался на 3 минуты, за это время письмо
было перемещено пользователем
- **WHEN** сервис переподключается и делает reconcile
- **THEN** перемещение обнаруживается и фиксируется в ChangeLog
### Requirement: REQ-IMAP-SYNC-003: Архивация без установки \Seen
Любое скачивание тела/вложений (по событию IDLE или reconcile) MUST NOT
выставлять IMAP-флаг `\Seen`. Используется существующий
`fetch_attachments_imaplib()` (BODY.PEEK[]) и чтение с `--preview`.
#### Scenario: Новое письмо архивируется по событию
- **GIVEN** новое письмо в INBOX с флагами `()` (непрочитанное)
- **WHEN** сервис по событию IDLE архивирует письмо
- **THEN** на IMAP флаги письма остаются `()` (письмо остаётся непрочитанным)
### Requirement: REQ-IMAP-SYNC-004: Онлайновая копия ящика
Система MUST вести онлайновую копию ящика в SQLite (`mailbox_state`): для
каждого письма — текущий folder, UID, флаги, Message-ID, References/In-Reply-To,
дата и время последнего изменения. Копия обновляется при каждом событии.
#### Scenario: Письмо перемещено пользователем
- **GIVEN** письмо было в INBOX, пользователь переместил его в `INBOX/Проекты`
- **WHEN** сервис получает событие пересмещения (MOVE/UIDPLUS или reconcile)
- **THEN** в `mailbox_state` folder обновлён на `INBOX/Проекты`, в ChangeLog
записано событие `moved`
#### Scenario: Письмо удалено пользователем
- **GIVEN** письмо было в ящике
- **WHEN** пользователь удаляет письмо (EXPUNGE/FLAGS \Deleted)
- **THEN** в `mailbox_state` письмо помечается удалённым (soft-delete), в ChangeLog
записано событие `deleted`; файл письма в архиве сохраняется
### Requirement: REQ-IMAP-SYNC-005: Журнал изменений (ChangeLog)
Система MUST вести append-only журнал изменений (`mailbox_events`): каждое
событие (added / moved / deleted / flag_changed / replied) с timestamp, folder,
UID, Message-ID. Журнал служит источником истины для анализатора и RAG.
#### Scenario: Запись о новом письме
- **GIVEN** сервис обнаружил новое письмо
- **WHEN** письмо заархивировано
- **THEN** в `mailbox_events` создана запись `added` с полным контекстом (UID,
folder, Message-ID, timestamp)
#### Scenario: Запись об ответе
- **GIVEN** пользователь ответил на письмо (флаг \Answered или новое письмо с In-Reply-To)
- **WHEN** сервис видит изменение
- **THEN** в ChangeLog записано событие `replied` (по References/In-Reply-To) или
`flag_changed` (для \Answered)
### Requirement: REQ-IMAP-SYNC-006: Анализатор изменений
Система MUST иметь отдельный процесс (`email-change-analyzer`), который читает
ChangeLog и обновляет производные данные: online-копию, RAG-индексы,
классификацию/обработчики. Анализатор работает по событиям (потоково), а не
по расписанию.
#### Scenario: Новое письмо → классификация и обработчики
- **GIVEN** в ChangeLog появилось событие `added`
- **WHEN** анализатор обрабатывает событие
- **THEN** запускается классификация (Qwen3:8b) и обработчики (urgent→TG,
task→VTODO, meeting→VEVENT) — как сейчас, но по событию, без ожидания cron
#### Scenario: Письмо удалено → RAG-индексы
- **GIVEN** в ChangeLog событие `deleted`
- **WHEN** анализатор обрабатывает событие
- **THEN** RAG/индекс помечает письмо удалённым (не удаляя файл) — при поиске
агент может сказать «это письмо удалено пользователем»
### Requirement: REQ-IMAP-SYNC-007: Управление как systemd-сервисами
Сервисы MUST работать как постоянные процессы под systemd (user units),
автозапуск при логине, auto-restart при падении, логи в journald. Hermes cron
остаётся как fallback-reconcile (страховка, если оба сервиса не работают).
#### Scenario: Сервис упал
- **GIVEN** `email-imap-stream.service` запущен
- **WHEN** процесс падает/убивается
- **THEN** systemd перезапускает его автоматически (Restart=always), при старте —
reconcile
#### Scenario: Оба сервиса не работают
- **GIVEN** оба сервиса остановлены (например, после перезагрузки без автозапуска)
- **WHEN** проходит 5 минут
- **THEN** Hermes cron (fallback) запускает `mail_archive.py` — архивация не
останавливается полностью