diff --git a/STATUS.md b/STATUS.md index aa70b37..f5add24 100644 --- a/STATUS.md +++ b/STATUS.md @@ -134,10 +134,12 @@ Hermes cron: ### Задача 8: Классификация писем и обработчики 🔵 (в работе, change `email-classification-handlers`) - [x] **Вложения**: фикс бага `himalaya --dir` → `--downloads-dir`; вложения в `/attachments/`; идемпотентно (2026-09-13 вечер, проверено на живом письме) - [x] **Классификатор**: `scripts/email_classifier.py` — Qwen3:8b (Ollama localhost:11434) → теги info/urgent/task/meeting + `classification`/`classification_reason` в frontmatter; идемпотентно; прогон прошёл (письмо 422 → task,meeting) -- [x] **Обработчики**: `scripts/email_handlers.py` — urgent→Telegram (Bot API+SOCKS5), task→Radicale VTODO («Задачи»), meeting→Radicale VEVENT («Рабочий»), info→ничего; идемпотентно через `handled_*`; dry-run чист (VTODO 204/VEVENT 201) -- [ ] Живой прогон обработчиков без --dry-run + проверка доставки urgent→Telegram -- [ ] Cron: классификация/обработчики после `mail-archive` -- [ ] Telegram-секреты (chat_id) в `.env` (токен — `VESTI_BOT_TOKEN` из /opt/vesti/.env, канал-дефолт `@dedinit_vesti`) +- [x] **Обработчики**: `scripts/email_handlers.py` — urgent→Telegram (Bot API+SOCKS5), task→Radicale VTODO («Задачи»), meeting→Radicale VEVENT («Рабочий»), info→ничего; идемпотентно через `handled_*` +- [x] **Фикс секретов (2026-09-14)**: скрипт теперь сам читает `radicale/.env` (RADICALE_PASS) и `/opt/vesti/.env` (VESTI_BOT_TOKEN) — раньше без ручного export был 401; добавлен stdlib-парсер .env (python-dotenv в системе нет) +- [x] **Живой прогон (2026-09-14)**: письмо 2026/422 → VTODO «Задачи» (204) + VEVENT «Рабочий» (204); повтор — идемпотентно (0 дублей) +- [x] **Cron (2026-09-14)**: `mail-classify-handlers` (6e1e78ceedfd, every 5m) — классификатор (--limit 10) → обработчики; end-to-end проверено: 5 новых «meeting» → 5 VEVENT (201) +- [ ] Живое urgent-письмо → доставка в Telegram (механика готова, токен подхватывается; пока не было urgent-писем) +- [x] **Telegram-секреты**: токен `VESTI_BOT_TOKEN` читается из /opt/vesti/.env; канал-дефолт `@dedinit_vesti` (TELEGRAM_CHAT_ID можно переопределить в .env проекта) ### Задача 1: Веб-интерфейс ассистента ⬜ - [ ] FastAPI + SQLite FTS5: список писем (дата/адресант/тэги/папка) @@ -242,6 +244,7 @@ Hermes cron: |----|-----|-----------|-----|--------| | 5f2305b2bbf8 | mail-archive-every-5min | every 5m | no-agent (скрипт) | ✅ (Фаза 1.7: использует `--all --drain` с динамическим списком) | | ea0fd1ab4f93 | contacts-extractor-every-30m | every 30m | скрипт (--limit 15) | ✅ | +| 6e1e78ceedfd | mail-classify-handlers | every 5m | скрипт (classifier --limit 10 → handlers) | ✅ (2026-09-14) | | — | mail-index-incremental | not set | — | ❌ | | — | digest-weekly | not set | — | ❌ | diff --git a/openspec/changes/email-classification-handlers/.openspec.yaml b/openspec/changes/archive/2026-09-14-email-classification-handlers/.openspec.yaml similarity index 100% rename from openspec/changes/email-classification-handlers/.openspec.yaml rename to openspec/changes/archive/2026-09-14-email-classification-handlers/.openspec.yaml diff --git a/openspec/changes/email-classification-handlers/design.md b/openspec/changes/archive/2026-09-14-email-classification-handlers/design.md similarity index 100% rename from openspec/changes/email-classification-handlers/design.md rename to openspec/changes/archive/2026-09-14-email-classification-handlers/design.md diff --git a/openspec/changes/email-classification-handlers/proposal.md b/openspec/changes/archive/2026-09-14-email-classification-handlers/proposal.md similarity index 100% rename from openspec/changes/email-classification-handlers/proposal.md rename to openspec/changes/archive/2026-09-14-email-classification-handlers/proposal.md diff --git a/openspec/changes/email-classification-handlers/specs/email-attachments/spec.md b/openspec/changes/archive/2026-09-14-email-classification-handlers/specs/email-attachments/spec.md similarity index 100% rename from openspec/changes/email-classification-handlers/specs/email-attachments/spec.md rename to openspec/changes/archive/2026-09-14-email-classification-handlers/specs/email-attachments/spec.md diff --git a/openspec/changes/email-classification-handlers/specs/email-classification/spec.md b/openspec/changes/archive/2026-09-14-email-classification-handlers/specs/email-classification/spec.md similarity index 100% rename from openspec/changes/email-classification-handlers/specs/email-classification/spec.md rename to openspec/changes/archive/2026-09-14-email-classification-handlers/specs/email-classification/spec.md diff --git a/openspec/changes/email-classification-handlers/specs/email-handlers/spec.md b/openspec/changes/archive/2026-09-14-email-classification-handlers/specs/email-handlers/spec.md similarity index 100% rename from openspec/changes/email-classification-handlers/specs/email-handlers/spec.md rename to openspec/changes/archive/2026-09-14-email-classification-handlers/specs/email-handlers/spec.md diff --git a/openspec/changes/email-classification-handlers/tasks.md b/openspec/changes/archive/2026-09-14-email-classification-handlers/tasks.md similarity index 59% rename from openspec/changes/email-classification-handlers/tasks.md rename to openspec/changes/archive/2026-09-14-email-classification-handlers/tasks.md index 61665c2..c6b0f99 100644 --- a/openspec/changes/email-classification-handlers/tasks.md +++ b/openspec/changes/archive/2026-09-14-email-classification-handlers/tasks.md @@ -2,55 +2,60 @@ ## 1. Починить скачивание вложений -- [ ] 1.1 Исправить `get_attachments()` в `scripts/mail_archive.py`: заменить +- [x] 1.1 Исправить `get_attachments()` в `scripts/mail_archive.py`: заменить `--dir` на `--downloads-dir`, передавать `/attachments/` -- [ ] 1.2 Не создавать папку `attachments/` для писем без вложений +- [x] 1.2 Не создавать папку `attachments/` для писем без вложений (создавать только если `has_attachment: true` или команда что-то вернула) -- [ ] 1.3 Проверить на живом письме с вложением: `has_attachment: true` → +- [x] 1.3 Проверить на живом письме с вложением: `has_attachment: true` → файлы появляются в `attachments/` `Верификация: ls -la /opt/hermes/email/INBOX/...//attachments/` -- [ ] 1.4 Проверить идемпотентность: повторный запуск не качает повторно +- [x] 1.4 Проверить идемпотентность: повторный запуск не качает повторно ## 2. Классификатор писем (email_classifier.py) -- [ ] 2.1 Создать `scripts/email_classifier.py`: +- [x] 2.1 Создать `scripts/email_classifier.py`: - читает неклассифицированные email.md (нет `classification`) - чистит текст (переиспользовать clean_body из contacts_extractor) - вызывает Qwen3:8b (Ollama localhost:11434) с промптом классификации - получает JSON: tags + reason + (для meeting) datetime -- [ ] 2.2 Писать в frontmatter: `classification`, `classification_reason` +- [x] 2.2 Писать в frontmatter: `classification`, `classification_reason` (для meeting — `meeting_datetime`) -- [ ] 2.3 Обработка ошибок: невалидный JSON/нет ответа → `unclassified`, продолжить -- [ ] 2.4 `--limit N` для дозирования (как contacts_extractor) -- [ ] 2.5 Ручной прогон на 3-5 свежих письмах, проверить теги в frontmatter +- [x] 2.3 Обработка ошибок: невалидный JSON/нет ответа → `unclassified`, продолжить +- [x] 2.4 `--limit N` для дозирования (как contacts_extractor) +- [x] 2.5 Ручной прогон на 3-5 свежих письмах, проверить теги в frontmatter `Верификация: grep -l '^classification:' /opt/hermes/email/**/email.md | head` ## 3. Обработчики (email_handlers.py) -- [ ] 3.1 Создать `scripts/email_handlers.py`: сканирует письма с тегами и без `handled_*` -- [ ] 3.2 Обработчик `urgent` → Telegram (через Hermes gateway/бота): from/subject/превью -- [ ] 3.3 Обработчик `task` → Radicale CalDAV: создать VTODO в календаре «Задачи» +- [x] 3.1 Создать `scripts/email_handlers.py`: сканирует письма с тегами и без `handled_*` +- [x] 3.2 Обработчик `urgent` → Telegram (через Hermes gateway/бота): from/subject/превью +- [x] 3.3 Обработчик `task` → Radicale CalDAV: создать VTODO в календаре «Задачи» (SUMMARY=тема, DESCRIPTION=ссылка на email.md, DTSTART/DUE при наличии даты) вместо Vikunja API (см. чейндж remove-vikunja-use-radicale-tasks) -- [ ] 3.4 Обработчик `meeting` → Radicale: создать VEVENT в календаре Рабочий +- [x] 3.4 Обработчик `meeting` → Radicale: создать VEVENT в календаре Рабочий (SUMMARY=тема, DTSTART из meeting_datetime или ближайший рабочий день 11:00) -- [ ] 3.5 Помечать письмо `handled_urgent` / `handled_task` / `handled_meeting` -- [ ] 3.6 Ошибки (нет Vikunja-токена, Radicale недоступен) → лог, письмо не теряется -- [ ] 3.7 Проверить: `urgent`-письмо уходит в Telegram; `meeting`-письмо создаёт VEVENT +- [x] 3.5 Помечать письмо `handled_urgent` / `handled_task` / `handled_meeting` +- [x] 3.6 Ошибки (нет Vikunja-токена, Radicale недоступен) → лог, письмо не теряется +- [x] 3.7 Проверить: `urgent`-письмо уходит в Telegram; `meeting`-письмо создаёт VEVENT + (механика sendMessage готова и токен подхватывается; живого urgent-письма пока нет — + сработает при появлении) ## 4. Подготовка зависимостей -- [ ] 4.1 Секреты в `.env`/config: Telegram chat_id/token (для обработчика `urgent`), +- [x] 4.1 Секреты в `.env`/config: Telegram chat_id/token (для обработчика `urgent`), Radicale Basic-auth (уже есть в проекте) -- [ ] 4.2 Убедиться, что календарь «Задачи» Radicale существует и доступен +- [x] 4.2 Убедиться, что календарь «Задачи» Radicale существует и доступен + (живая проверка: PROPFIND 207, VTODO создан в «Задачи», VEVENT в «Рабочий») `Верификация: curl -u estorozhenko:... -X PROPFIND -H 'Depth: 0' https://cal.nixg.ru/estorozhenko//` ## 5. Cron -- [ ] 5.1 Добавить Hermes cron для классификатора (после архивации, дозированно) -- [ ] 5.2 Добавить Hermes cron для обработчиков -- [ ] 5.3 Проверить, что цепочка работает end-to-end на новом письме - (архивация → классификация → обработчик) +- [x] 5.1 Добавить Hermes cron для классификатора (после архивации, дозированно) + (job 6e1e78ceedfd, mail-classify-handlers.sh, каждые 5 мин, лимит 10) +- [x] 5.2 Добавить Hermes cron для обработчиков + (тот же job: классификатор → обработчики в одной обёртке) +- [x] 5.3 Проверить, что цепочка работает end-to-end на новом письме + (живой прогон: 5 новых писем → meeting → 5 VEVENT созданы (201)) ## 6. Документация diff --git a/openspec/specs/email-attachments/spec.md b/openspec/specs/email-attachments/spec.md new file mode 100644 index 0000000..e20d22a --- /dev/null +++ b/openspec/specs/email-attachments/spec.md @@ -0,0 +1,47 @@ +# email-attachments Specification + +## Purpose +Скачивание вложений письма в каталог этого письма. Сейчас `mail_archive.py` +вызывает `himalaya attachment download --dir`, но правильный флаг в Himalaya — +`--downloads-dir`, из-за чего команда падает (exit 2), ошибка молча глотается +`except: pass`, и папка `attachments/` всегда пустая. Вложения теряются. + +## Requirements + +### Requirement: Вложения сохраняются в каталог письма + +Для каждого письма с вложениями (флаг `has_attachment: true` в frontmatter) +вложения MUST быть сохранены в подкаталог `attachments/` каталога письма +(`/opt/hermes/email//YYYY/MM//attachments/`). + +#### Scenario: Письмо с вложением архивировано +- **WHEN** `mail_archive.py` заархивировал письмо с `has_attachment: true` +- **THEN** файлы вложений лежат в `/attachments/` и совпадают с вложениями на IMAP-сервере + +### Requirement: Правильный флаг Himalaya + +Скачивание вложений MUST использовать флаг `--downloads-dir` (а не несуществующий +`--dir`) команды `himalaya attachment download`, и передавать ему каталог письма. + +#### Scenario: Вызов himalaya с корректным флагом +- **WHEN** `get_attachments()` выполняется для письма +- **THEN** используется `himalaya attachment download --folder --downloads-dir /attachments `, exit code 0 при успехе + +### Requirement: Учёт отсутствия вложений + +Если письмо не имеет вложений (`has_attachment: false` или команда вернула +«нет вложений»), скрипт MUST NOT создавать пустую папку `attachments/` и MUST NOT +считать это ошибкой. + +#### Scenario: Письмо без вложений +- **WHEN** `mail_archive.py` обрабатывает письмо без вложений +- **THEN** каталог `attachments/` не создаётся, ошибка не логируется + +### Requirement: Повторная обработка существующих писем + +Повторный запуск `mail_archive.py` MUST NOT повторно качать уже сохранённые +вложения (проверка по наличию каталога/файлов). + +#### Scenario: Повторный запуск +- **WHEN** `mail_archive.py` запущен повторно на письме с уже скачанными вложениями +- **THEN** вложения не скачиваются повторно (идемпотентность) diff --git a/openspec/specs/email-classification/spec.md b/openspec/specs/email-classification/spec.md new file mode 100644 index 0000000..e4298f9 --- /dev/null +++ b/openspec/specs/email-classification/spec.md @@ -0,0 +1,58 @@ +# email-classification Specification + +## Purpose +Классификация писем локальной LLM: после скачивания письма модель определяет тип +письма (информационное, требует срочного ответа, содержит задачу, содержит +встречу) и записывает тег + обоснование в frontmatter файла email.md. Обработка +приватна — модель Qwen3:8b запущена локально через Ollama, текст письма не +покидает хост. + +## Requirements + +### Requirement: Классификация каждого нового письма + +Каждое письмо, заархивированное `mail_archive.py`, MUST быть классифицировано +локальной моделью не позднее одного прохода классификатора после архивации. + +#### Scenario: Новое письмо после архивации +- **WHEN** `mail_archive.py` сохранил новое письмо в `/opt/hermes/email/**/email.md` без поля `classification` +- **THEN** `email_classifier.py` обработает его и запишет в frontmatter поле `classification` с одним из значений: `info`, `urgent`, `task`, `meeting` (или комбинацию через запятую) + +### Requirement: Приватность обработки + +Классификация MUST выполняться локальной моделью (Qwen3:8b через Ollama на +localhost:11434) и MUST NOT отправлять текст письма в облачные API. + +#### Scenario: Локальная модель доступна +- **WHEN** классификатор запущен +- **THEN** запросы к LLM идут только на `http://localhost:11434` (Ollama), никаких внешних HTTP-вызовов с телом письма + +### Requirement: Обоснование классификации + +Классификатор MUST записывать краткое обоснование решения в frontmatter +(поле `classification_reason`), чтобы пользователь видел, почему письмо помечено +именно так. + +#### Scenario: Обоснование для письма +- **WHEN** `email_classifier.py` классифицировал письмо +- **THEN** в frontmatter записано `classification_reason` с 1-2 предложениями на русском + +### Requirement: Идемпотентность + +Письмо MUST обрабатываться классификатором только один раз; повторный запуск +MUST NOT переклассифицировать уже обработанные письма (если не задан флаг +принудительной переклассификации). + +#### Scenario: Повторный запуск классификатора +- **WHEN** `email_classifier.py` запущен повторно на уже обработанном письме (есть `classification`) +- **THEN** письмо пропускается без повторного вызова LLM + +### Requirement: Обработка ошибок классификатора + +Если LLM не ответила или вернула невалидный JSON, классификатор MUST пометить +письмо как `unclassified` и продолжить со следующим письмом, не прерывая весь +проход. + +#### Scenario: LLM вернула невалидный ответ +- **WHEN** модель не ответила или вернула не-JSON +- **THEN** письмо получает `classification: unclassified`, а проход продолжается diff --git a/openspec/specs/email-handlers/spec.md b/openspec/specs/email-handlers/spec.md new file mode 100644 index 0000000..45b721a --- /dev/null +++ b/openspec/specs/email-handlers/spec.md @@ -0,0 +1,66 @@ +# email-handlers Specification + +## Purpose +Подключение обработчиков по тегам классификации письма: уведомление в мессенджер +для срочных писем, создание задачи в Radicale (VTODO, календарь «Задачи») для +писем с задачей, создание события в Radicale (VEVENT, календарь «Рабочий») для +писем со встречей. Обработчики запускаются автоматически после классификации и +работают идемпотентно. (Vikunja выведена из эксплуатации 2026-09-13 — change +`remove-vikunja-use-radicale-tasks`.) + +## Requirements + +### Requirement: Уведомление в мессенджер для срочных писем + +Письмо с тегом `urgent` MUST вызывать отправку уведомления в мессенджер +(Telegram) с отправителем, темой и первыми строками текста. + +#### Scenario: Срочное письмо +- **WHEN** `email_classifier.py` пометил письмо тегом `urgent` +- **THEN** `email_handlers.py` отправляет в Telegram уведомление с from/subject/превью + +### Requirement: Создание задачи в Radicale (VTODO) для писем с задачей + +Письмо с тегом `task` MUST создавать задачу в Radicale (CalDAV, календарь +«Задачи») как VTODO с темой письма в SUMMARY и ссылкой на письмо в DESCRIPTION. + +#### Scenario: Письмо с задачей +- **WHEN** `email_classifier.py` пометил письмо тегом `task` +- **THEN** в Radicale (календарь Задачи) создаётся VTODO: SUMMARY=тема письма, DESCRIPTION=ссылка на `email.md` + +### Requirement: Создание события в Radicale для писем со встречей + +Письмо с тегом `meeting` MUST создавать событие в календаре Radicale (Рабочий) +с темой письма как SUMMARY и извлечённой датой/временем, если они указаны. + +#### Scenario: Письмо со встречей +- **WHEN** `email_classifier.py` пометил письмо тегом `meeting` и в классификации есть дата/время +- **THEN** в Radicale (календарь Рабочий) создаётся VEVENT с SUMMARY=тема письма + +### Requirement: Идемпотентность обработчиков + +Обработчик MUST запускаться для каждого письма один раз; повторный запуск на +уже обработанном письме MUST NOT создавать дубликат задачи/события/уведомления. + +#### Scenario: Повторный запуск обработчиков +- **WHEN** `email_handlers.py` запущен повторно на письме, для которого уже созданы задача/событие +- **THEN** дубликаты не создаются (трекинг обработанных в state) + +### Requirement: Информационные письма не создают обработчиков + +Письмо с тегом `info` MUST NOT вызывать уведомления, задач или событий; оно +только помечается тегом в frontmatter. + +#### Scenario: Информационное письмо +- **WHEN** `email_classifier.py` пометил письмо тегом `info` +- **THEN** `email_handlers.py` не создаёт ни уведомления, ни задачи, ни события + +### Requirement: Уведомление о недоступности обработчика + +Если обработчик не может выполниться (Radicale недоступен, нет учётных данных), +MUST быть записана ошибка в лог, и письмо MUST остаться помеченным тегом для +повторной попытки (не теряться). + +#### Scenario: Radicale недоступен +- **WHEN** `email_handlers.py` пытается создать задачу/событие, но Radicale недоступен +- **THEN** ошибка пишется в лог, письмо остаётся с тегом `task`/`meeting`, повторная попытка возможна diff --git a/openspec/specs/email-storage-format/spec.md b/openspec/specs/email-storage-format/spec.md index 4c2d8ba..ceaf612 100644 --- a/openspec/specs/email-storage-format/spec.md +++ b/openspec/specs/email-storage-format/spec.md @@ -1,7 +1,12 @@ # email-storage-format Specification ## Purpose -TBD - created by archiving change email-storage-analysis. Update Purpose after archive. +Формат хранения архива писем: `email.md` (YAML-frontmatter + текст) в структуре +`//YYYY/MM//`, выбранный по итогам анализа STORAGE_ANALYSIS.md +(9 критериев: полнота заголовков, инкрементальность, идемпотентность, удобство +поиска и др.). Хранит полные заголовки письма в frontmatter и тело как Markdown; +доп. поля (classification, handled_*, attachments) расширяют frontmatter без +изменения формата. ## Requirements diff --git a/scripts/email_handlers.py b/scripts/email_handlers.py index 7a5ab43..af9b5e3 100644 --- a/scripts/email_handlers.py +++ b/scripts/email_handlers.py @@ -45,10 +45,43 @@ try: except ImportError: load_dotenv = None -# Каталог скрипта → .env рядом с проектом + +def _load_env_file(path): + """Загрузить KEY=VALUE из .env-файла, не перезаписывая уже заданные env. + + stdlib-фолбэк python-dotenv (в проекте нет сторонних зависимостей). + """ + p = Path(path) + if not p.exists(): + return + try: + lines = p.read_text(encoding="utf-8").splitlines() + except OSError: + return + for line in lines: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, val = line.partition("=") + key = key.strip() + val = val.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = val + +# Каталог скрипта → .env рядом с проектом (+ radicale/.env для RADICALE_PASS) BASE_DIR = Path(__file__).resolve().parents[1] if load_dotenv: load_dotenv(BASE_DIR / ".env", override=False) + # radicale/.env — фактический источник RADICALE_PASS (проектного .env нет) + load_dotenv(BASE_DIR / "radicale" / ".env", override=False) +else: + _load_env_file(BASE_DIR / ".env") + _load_env_file(BASE_DIR / "radicale" / ".env") + # Токен Telegram живёт в /opt/vesti/.env (проект-источник бота @dedinit_vesti); + # опционально: если файл есть, берём VESTI_BOT_TOKEN/TELEGRAM_CHAT_ID оттуда. + vesti_env = Path("/opt/vesti/.env") + if vesti_env.exists(): + _load_env_file(vesti_env) EMAIL_ROOT = Path(os.getenv("EMAIL_ROOT", "/opt/hermes/email")) diff --git a/scripts/mail-classify-handlers.sh b/scripts/mail-classify-handlers.sh new file mode 100755 index 0000000..325d414 --- /dev/null +++ b/scripts/mail-classify-handlers.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Классификация новых писем + обработчики — запускается после mail-archive. +# Цепочка: mail-archive (5 min) → classifier (лимит, дозированно) → handlers. +set -euo pipefail + +cd /opt/hermes/email-assistant + +# Классификатор: до 10 новых писем за запуск (Qwen ~10-20с/письмо = ~3 мин) +python3 scripts/email_classifier.py --limit 10 || echo "[classifier] ошибка (продолжаем)" >&2 + +# Обработчики: все письма с тегами, без handled_* (идемпотентно) +python3 scripts/email_handlers.py || echo "[handlers] ошибка" >&2 \ No newline at end of file