From 7550aff102a1a05d2bcfb14c8de561b093b10c90 Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 13 Sep 2026 20:35:58 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D0=B8?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B5=D0=BC=20Qwen3:8b=20+=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D1=87=D0=B8=D0=BA=D0=B8=20(urgent=E2=86=92TG,=20ta?= =?UTF-8?q?sk=E2=86=92VTODO,=20meeting=E2=86=92VEVENT)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mail_archive.py: фикс вложений himalaya --dir → --downloads-dir; attachments/ только при has_attachment; идемпотентно - email_classifier.py: Qwen3:8b (Ollama) → теги info/urgent/task/meeting в frontmatter email.md - email_handlers.py: urgent→Telegram (Bot API+SOCKS5), task→Radicale VTODO, meeting→Radicale VEVENT; handled_* идемпотентность; --dry-run - .gitignore: игнор *.env.bak* - openspec: чейндж email-classification-handlers (в работе) - STATUS/TODO/WALKTHROUGH: прогресс сессии, подводные камни Radicale (http.client, Depth:1, формат дат) --- .gitignore | 1 + PRD.md | 67 +++ STATUS.md | 42 +- TODO.md | 29 +- WALKTHROUGH.md | 158 +++++- .../.openspec.yaml | 2 + .../email-classification-handlers/design.md | 111 +++++ .../email-classification-handlers/proposal.md | 68 +++ .../specs/email-attachments/spec.md | 46 ++ .../specs/email-classification/spec.md | 57 +++ .../specs/email-handlers/spec.md | 63 +++ .../email-classification-handlers/tasks.md | 58 +++ .../changes/local-calendar-tasks/proposal.md | 5 + .../.openspec.yaml | 2 + .../design.md | 83 ++++ .../proposal.md | 60 +++ .../specs/radicale-tasks/spec.md | 45 ++ .../tasks.md | 45 ++ scripts/email_classifier.py | 323 +++++++++++++ scripts/email_handlers.py | 456 ++++++++++++++++++ scripts/mail_archive.py | 38 +- vikunja/docker-compose.yml | 45 -- 22 files changed, 1728 insertions(+), 76 deletions(-) create mode 100644 PRD.md create mode 100644 openspec/changes/email-classification-handlers/.openspec.yaml create mode 100644 openspec/changes/email-classification-handlers/design.md create mode 100644 openspec/changes/email-classification-handlers/proposal.md create mode 100644 openspec/changes/email-classification-handlers/specs/email-attachments/spec.md create mode 100644 openspec/changes/email-classification-handlers/specs/email-classification/spec.md create mode 100644 openspec/changes/email-classification-handlers/specs/email-handlers/spec.md create mode 100644 openspec/changes/email-classification-handlers/tasks.md create mode 100644 openspec/changes/remove-vikunja-use-radicale-tasks/.openspec.yaml create mode 100644 openspec/changes/remove-vikunja-use-radicale-tasks/design.md create mode 100644 openspec/changes/remove-vikunja-use-radicale-tasks/proposal.md create mode 100644 openspec/changes/remove-vikunja-use-radicale-tasks/specs/radicale-tasks/spec.md create mode 100644 openspec/changes/remove-vikunja-use-radicale-tasks/tasks.md create mode 100644 scripts/email_classifier.py create mode 100644 scripts/email_handlers.py delete mode 100644 vikunja/docker-compose.yml diff --git a/.gitignore b/.gitignore index f564968..2c7dbed 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ venv/ # Environment .env *.env.local +*.env.bak* # OS .DS_Store diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..22d1932 --- /dev/null +++ b/PRD.md @@ -0,0 +1,67 @@ +# PRD — Email Assistant (локальный почтовый ассистент) + +Обновлено: 2026-09-13 + +## Цель + +Инкрементальный локальный архив корпоративной почты с последующей обработкой +локальной LLM: классификация писем, извлечение контактов, дайджесты, интеграция +с локальным календарём/задачами (Radicale CalDAV). Всё — локально (bigbox), +без облачных зависимостей. + +## Пользователи + +Один пользователь — estorozhenko (личная корпоративная почта +e.storozhenko@vinogorod.ru, IMAP mail.corpoffice.tech). + +## Функциональные требования + +1. **Архивация:** скачивать письма с IMAP, сохранять текст+метаданные в + `email.md` (YAML-frontmatter + текст), структура + `/opt/hermes/email//YYYY/MM//`. +2. **Вложения:** сохранять вложения в каталог письма `attachments/` + (ФТ-2, в работе). +3. **Поиск:** SQLite FTS5 по письмам (веб-интерфейс, Задача 1). +4. **Классификация:** локальная LLM (Qwen3:8b) классифицирует письмо → + тег info/urgent/task/meeting + обоснование (ФТ-3, в работе). +5. **Обработчики:** по тегам — urgent→Telegram, task→Radicale VTODO (календарь + «Задачи»), meeting→Radicale VEVENT (календарь «Рабочий»), info→ничего + (ФТ-4, в работе). +6. **Контакты:** извлечение контактов из подписей через LLM + двухсторонний + CardDAV sync с Radicale («Контакты»). +7. **Дайджест:** еженедельная сводка писем. +8. **Календарь/задачи:** Radicale CalDAV (Личный/Рабочий/Задачи), синхронизация + с Android (DAVx5 → календари/контакты, jtx board → VTODO-задачи). + +## Нефункциональные требования + +- **Приватность:** текст писем никогда не покидает bigbox (обработка только + локальной LLM Qwen3:8b через Ollama localhost:11434). +- **Идемпотентность:** повторный запуск не дублирует (классификацию, + обработчики, вложения). +- **Отказоустойчивость:** сбой обработчика не теряет письмо (лог + повторная + попытка по тегу). +- **Расположение:** всё в `/opt/hermes/` (единый каталог, бэкап = копия). +- **Порядок данных:** секреты — только в `.env`/`.htpasswd`, не в git и не в + README/WALKTHROUGH. + +## Границы (что НЕ делаем) + +- **НЕ** используем облачных ассистентов/API для содержимого писем (Nylas и + т.п. отклонено). +- **НЕ** используем Vikunja — задачи через Radicale VTODO (решение 2026-09-13). +- **НЕ** отправка почты (архив read-only). +- **НЕ** удаляем/теряем письма — архив только пополняется. + +## Критерии готовности + +- [ ] Вложения качаются в `attachments/` (ФТ-2) +- [ ] Классификатор проставляет теги на всю базу (ФТ-4) +- [ ] Обработчики срабатывают и видны в Telegram/Radicale (ФТ-5) +- [ ] Веб-интерфейс: поиск, тэги, «Создать задачу» (Задача 1) +- [ ] Календарь/задачи синхронизируются на Android по CalDAV + +## Стек + +Himalaya CLI → Python (mail_archive.py) → SQLite FTS5 → Ollama Qwen3:8b → +Radicale (CalDAV/CardDAV, docker :5232, cal.nixg.ru) → Telegram (уведомления). \ No newline at end of file diff --git a/STATUS.md b/STATUS.md index d50631c..aa70b37 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,9 +1,9 @@ # Email Assistant — локальный архив и ассистент почты -**Дата:** 2026-09-11 -**Фаза:** 1.5–1.7 + Портфель веб-UI (планирование) +**Дата:** 2026-09-13 +**Фаза:** 1.5–1.7 + Портфель веб-UI (планирование) + Классификация/обработчики (в работе) -**Стек:** Himalaya CLI → Python → SQLite → Ollama (Qwen3:8b) → Yandex Disk +**Стек:** Himalaya CLI → Python → SQLite → Ollama (Qwen3:8b) → Radicale (CalDAV) → Telegram --- @@ -116,23 +116,33 @@ Hermes cron: ### Задача 2: Локальный календарь + трекер задач 🔵 (в работе) - [x] Решение пользователя: **Radicale (CalDAV) + Vikunja (трекер)**, всё в Docker-контейнерах -- [x] Change `local-calendar-tasks` создан и валиден (опenspec-lab) — proposal/specs/design/tasks +- [x] **РЕШЕНИЕ 2026-09-13: Vikunja — ЛИШНЯЯ СУЩНОСТЬ, задачи через Radicale VTODO** (change `remove-vikunja-use-radicale-tasks`). Radicale из коробки умеет VTODO (календарь «Задачи»), jtx board читает их по CalDAV. Vikunja выводится из эксплуатации. +- [x] Change `local-calendar-tasks` создан и валиден — proposal/specs/design/tasks (Radicale-часть актуальна, Vikunja-часть — SUPERSEDED)
- [x] **Radicale развёрнут и РАБОТАЕТ (2026-09-13)**: контейнер на :5232, PROPFIND 207 с паролем / 401 без. Коллекции Личный/Рабочий/Задачи на ФС. -- [x] **Vikunja v2.6.0 развёрнут (2026-09-13)**: postgres + app, миграции прошли, HTTP слушает :3456. **Блокер:** снаружи connection reset (docker-proxy/NAT на bigbox) — контейнер отвечает, маунт файлов исправлен (./files → /app/vikunja/files). - [x] **CardDAV-синк контактов (2026-09-13, change `contacts-caldav-server`)**: `scripts/contacts_caldav_sync.py` — двусторонний sync. PUSH: 81 контакт → vCard в Radicale. PULL: правки/создание/удаление карточек с телефона → contacts.json. Идемпотентно (162 unchanged, 0 PUT на повторе). Конфликты (412) — приоритет телефону, локальная версия в `caldav-sync.log`. Подробнее: `scripts/contacts_caldav_sync.py --help`, лог `/opt/hermes/email/contacts/caldav-sync.log`. -- [ ] **Vikunja: создать админа + API-токен, проверить tasks.nixg.ru** -- [ ] **Caddy reverse proxy (cal.nixg.ru → 5232, tasks.nixg.ru → 3456)** — СДЕЛАТЬ СЛЕДУЮЩЕЙ СЕССИЕЙ (см. ниже «DAVx⁵») -- [ ] **Android-синхронизация (DAVx5)** — СДЕЛАТЬ СЛЕДУЮЩЕЙ СЕССИЕЙ (см. ниже) +- [x] **Vikunja ВЫВЕДЕНА ИЗ ЭКСПЛУАТАЦИИ (2026-09-13)**: контейнеры vikunja + vikunja-db удалены (`docker compose down -v`), каталог `/opt/hermes/email-assistant/vikunja/` удалён, порт 3456 свободен. Tasks.nixg.ru закомментирован в Caddy (строки 114-120), Caddy перезагружен (бэкап Caddyfile.bak-vikunja-removed). | +- [x] **Caddy reverse proxy (cal.nixg.ru → 5232)** — РАБОТАЕТ (2026-09-13): PROPFIND 207 снаружи. tasks.nixg.ru закомментирован. +- [x] **«Обход в Глории»** — повторяющееся событие (Рабочий, VTIMEZONE Europe/Moscow, RRULE WEEKLY BYDAY=TU 11:00), подтверждено на телефоне (GMT+3 ✓) -### Задача 3: Нативная синхронизация с Android 🔵 (следующая сессия) -- [ ] **Первая задача след. сессии: настроить синхронизацию с телефоном** (DAVx5 → Radicale; Vikunja → jtx board) -- [ ] Настроить Caddy reverse proxy для Radicale (cal.nixg.ru) — см. ниже -- [ ] Проверка двусторонней синхронизации (событие/контакт с телефона → bigbox → база) +### Задача 3: Нативная синхронизация с Android 🔵 (в работе) +- [x] **Контакты синхронизированы** (DAVx5 → Radicale «Контакты»; CardDAV-sync двусторонний, change `contacts-caldav-server`) +- [x] **Caddy reverse proxy (cal.nixg.ru → 5232)** — работает, PROPFIND 207 снаружи +- [x] **«Обход в Глории»** — VEVENT подтверждён на телефоне (GMT+3 ✓) +- [ ] **Проверить появление событий/задач в приложении** (тестовый VEVENT obhod-v-glorii-2026.ics в «Рабочий», тестовый VTODO test-vikunja-removal-2026 в «Задачи») — контакты синхронизируются, события/задачи на телефоне пока не проверены +- [ ] Двусторонняя синхронизация: событие/задача с телефона → bigbox → база + +### Задача 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`) ### Задача 1: Веб-интерфейс ассистента ⬜ - [ ] FastAPI + SQLite FTS5: список писем (дата/адресант/тэги/папка) - [ ] Перемещение в папку; тэги -- [ ] Кнопка «Создать задачу» → в Vikunja API (Задача 6) +- [ ] Кнопка «Создать задачу» → Radicale VTODO (Задача 6, вместо Vikunja API) - [ ] Страница авторизации (Задача 7) --- @@ -278,7 +288,7 @@ dav.example.com { **В DAVx⁵ указывается базовый URL** (например, `https://dav.example.com` или `https://dav.example.com/radicale`); пути к календарям/контактам приложение определяет автоматически. -**Наш случай (следующая сессия):** поддомен `cal.nixg.ru` (Caddy на vps02, `reverse_proxy 10.8.0.2:5232` к bigbox) — Radicale слушает 127.0.0.1:5232, user `estorozhenko`, пароль `RADICALE_PASS` в `radicale/.env`. Адресная книга: `Контакты` (кириллица в URL — DAVx5 умеет). Vikunja → jtx board через `tasks.nixg.ru`. +**Наш случай (следующая сессия):** поддомен `cal.nixg.ru` (Caddy на vps02, `reverse_proxy 10.8.0.2:5232` к bigbox) — Radicale слушает 127.0.0.1:5232, user `estorozhenko`, пароль `RADICALE_PASS` в `radicale/.env`. Адресная книга: `Контакты` (кириллица в URL — DAVx5 умеет). **Задачи: Radicale VTODO (календарь «Задачи») → jtx board** (Vikunja выведена, change `remove-vikunja-use-radicale-tasks`). --- @@ -295,11 +305,11 @@ dav.example.com { |--------|-----------|--------| | **Caddy (reverse proxy)** | vps02 = 87.242.100.206, контейнер `caddy` | SSH: `ssh vps02` (alias в `~/.ssh/config`, User estorozhenko, ключ cloudruVPS). Caddyfile: `/opt/caddy/Caddyfile` (root; правка через `sudo`). Reload: `sudo docker exec caddy caddy reload --config /etc/caddy/Caddyfile` | | **Radicale (CalDAV)** | bigbox, контейнер `radicale`, порт 5232 | WGET: `http://127.0.0.1:5232` (с bigbox), наружу: `https://cal.nixg.ru`. Конфиг: `/opt/hermes/email-assistant/radicale/` (compose.yml, .env — пароль `RADICALE_PASS`). Коллекции на ФС: `/opt/hermes/email-assistant/radicale/data/collections/collection-root/estorozhenko/{Личный,Рабочий,Задачи}` | -| **Vikunja (трекер задач)** | bigbox, compose в `/opt/hermes/email-assistant/vikunja/` | Порт 3456 (внутр.), наружу: `https://tasks.nixg.ru` (Caddy → 10.8.0.2:3456). Данные: volumes `vikunja-db` (postgres) + `vikunja-files` | +| **Vikunja (трекер задач)** | ~~bigbox, compose в `/opt/hermes/email-assistant/vikunja/`~~ | ⛔ **ВЫВЕДЕНА** (change `remove-vikunja-use-radicale-tasks`). Задачи → Radicale VTODO (календарь «Задачи»). | | **WG (сеть хостов)** | 10.8.0.0/24 | bigbox = 10.8.0.2 (цель reverse-proxy с vps02), vps01 = .1, vps03 = .3, vps02 = .4 | | **SSH-ключи** | `/home/estorozhenko/.ssh/` | vps01_key (vps01), cloudruVPS (vps02), hostkeyVPS (vps03/root) | -**Домены (публичный DNS):** cal.nixg.ru → 87.242.100.206 (vps02/Caddy → bigbox Radicale 5232), tasks.nixg.ru → 87.242.100.206 (Caddy → bigbox Vikunja 3456). +**Домены (публичный DNS):** cal.nixg.ru → 87.242.100.206 (vps02/Caddy → bigbox Radicale 5232). **tasks.nixg.ru — НЕ используется** (Vikunja выведена). - **Himalaya:** `~/.config/himalaya/config.toml` - **Аккаунт:** `vinogorod`, IMAP `mail.corpoffice.tech:143` (STARTTLS) diff --git a/TODO.md b/TODO.md index c8b90ae..6ab25f0 100644 --- a/TODO.md +++ b/TODO.md @@ -24,11 +24,28 @@ | 2026-09-11 | Фаза 1.7: динамическое обнаружение подпапок INBOX (get_inbox_subfolders, --all, fix run_cmd) | ✅ закрыта | STATUS.md §1.7, git c7430d1 | | 2026-09-11 | Анализ Nylas CLI → NYLAS_ANALYSIS.md + ссылка из README | ✅ закрыта | git d4bf3ed | | 2026-09-11 | Задача 4: Анализ ФС vs Maildir → STORAGE_ANALYSIS.md + README | ✅ закрыта | git e31b5f2; openspec change email-storage-analysis (архивирован) | -| 2026-09-11 | Задача 2: Radicale развёрнут (docker :5232, HTTP Basic, PROPFIND 207) | 🔵 в работе | openspec change local-calendar-tasks | +| 2026-09-11 | Задача 2: Radicale развёрнут (docker :5232, HTTP Basic, PROPFIND 207) | ✅ закрыта | openspec change local-calendar-tasks | | 2026-09-11 | Задача 2: коллекции Radicale (Личный/Рабочий/Задачи) — MKCOL 403, ждёт решения способа | 🔵 заблокировано | | -| 2026-09-11 | Задача 2: Vikunja (docker :3456, postgres) | 🔵 открыта | | -| 2026-09-11 | Задача 2: Caddy reverse proxy (cal.nixg.ru, tasks.nixg.ru) | 🔵 открыта | | -| 2026-09-11 | Задача 3: Android-синхронизация (DAVx5 → Radicale, Vikunja app) | 🔵 открыта | | +| 2026-09-11 | Задача 2: Vikunja (docker :3456, postgres) — ЛИШНЯЯ СУЩНОСТЬ, задачи через Radicale VTODO | ⛔ не нужна | openspec change remove-vikunja-use-radicale-tasks | +| 2026-09-11 | Задача 2: Caddy reverse proxy (cal.nixg.ru, tasks.nixg.ru) — только cal.nixg.ru | 🔵 открыта | | +| 2026-09-11 | Задача 3: Android-синхронизация (DAVx5 → Radicale, задачи VTODO через jtx board) | 🔵 открыта | | | 2026-09-11 | Задача 1: веб-интерфейс (FastAPI, список писем, перемещение, тэги) | 🔵 открыта | | -| 2026-09-11 | Задача 6: кнопка «Создать задачу» → Vikunja API | 🔵 открыта | | -| 2026-09-11 | Задача 7: аутентификация веб-интерфейса | 🔵 открыта | | \ No newline at end of file +| 2026-09-11 | Задача 6: кнопка «Создать задачу» → Radicale VTODO (вместо Vikunja API) | 🔵 открыта | | +| 2026-09-11 | Задача 7: аутентификация веб-интерфейса | 🔵 открыта | | + +## 2026-09-13 +| Дата | Задача | Статус | Закрыта в | +|---|---|---|---| +| 2026-09-13 | Radicale: смена пароля (пользователь не помнил; apr1-хэш, users.bak.) | ✅ закрыта | WALKTHROUGH | +| 2026-09-13 | «Обход в Глории» — VEVENT повторяющийся (Рабочий, VTIMEZONE Europe/Moscow) | ✅ закрыта | WALKTHROUGH | +| 2026-09-13 | Диагностика пайплайна: вложения НЕ качаются (баг himalaya `--dir` → `--downloads-dir`) | ✅ закрыта | openspec change email-classification-handlers | +| 2026-09-13 | Чейндж email-classification-handlers (классификация Qwen3:8b + обработчики) — создан, 0/23 задач | 🔵 в работе | openspec change email-classification-handlers | +| 2026-09-13 (вечер) | Вложения: фикс `--dir`→`--downloads-dir`, вложения качаются, идемпотентно | ✅ закрыта | WALKTHROUGH §2026-09-13 (вечер) | +| 2026-09-13 (вечер) | Классификатор `scripts/email_classifier.py` (Qwen3:8b → frontmatter `classification`) | ✅ закрыта | WALKTHROUGH §2026-09-13 (вечер) | +| 2026-09-13 (вечер) | Обработчики `scripts/email_handlers.py` (urgent→TG, task→VTODO, meeting→VEVENT, dry-run ✓) | 🟡 частично: dry-run готов, живой прогон + TG не проверены | WALKTHROUGH §2026-09-13 (вечер) | +| 2026-09-13 (вечер) | Radicale: синхронизация пароля в `radicale/.env` с `data/users` (смена 13.09 не обновила .env) | ✅ закрыта | WALKTHROUGH §2026-09-13 (вечер) | +| 2026-09-13 | Vikunja выведена из эксплуатации: контейнеры/volume/каталог удалены, tasks.nixg.ru закомментирован | ✅ закрыта | openspec change remove-vikunja-use-radicale-tasks (14/14) | +| 2026-09-13 | Тестовый VTODO test-vikunja-removal-2026 в «Задачи» (проверка CalDAV VTODO) | ✅ закрыта | WALKTHROUGH | +| 2026-09-13 | PRD.md создан (отсутствовал) | ✅ закрыта | PRD.md | +| 2026-09-13 | Задача 2: Caddy reverse proxy — cal.nixg.ru работает (207), tasks.nixg.ru закомментирован | 🟡 частично (cal.nixg.ru готов) | STATUS.md | +| 2026-09-13 | Задача 3: Android — контакты синхронизированы (DAVx5), события/задачи ещё не проверены в приложении | 🔵 открыта | | \ No newline at end of file diff --git a/WALKTHROUGH.md b/WALKTHROUGH.md index 63fc9c7..1e0fe98 100644 --- a/WALKTHROUGH.md +++ b/WALKTHROUGH.md @@ -91,4 +91,160 @@ postgres), Caddy (cal.nixg.ru, tasks.nixg.ru), Android. ### Известные открытые хвосты (репозиторий) - `PLAN_WEBUI.md` — untracked (не закоммичен). - Cron mail-index-incremental и digest-weekly — не настроены. -- Push mirror gitea → gitverse — не настроен (нужен токен [REDACTED]). \ No newline at end of file +- Push mirror gitea → gitverse — не настроен (нужен токен [REDACTED]). + +## 2026-09-13 + +### Radicale: пароль сменён (пользователь не помнил старый) + +**Проблема:** старый пароль Radicale (md5/$apr1$-хэш в `data/users`) не подходил; +пользователь не помнил пароль. + +**Решение:** `cp -a users users.bak.` → сгенерировали хэш `$apr1$` +(`openssl passwd -apr1 '<пароль>'`, значение — `RADICALE_PASS` в `radicale/.env`) → +перезаписали `data/users`. Проверка: +`PROPFIND https://cal.nixg.ru/` → **207** (доступ подтверждён). + +**Урок:** Radicale хранит пароль как **Apache `$apr1$` (MD5-crypt)**, не как +простой md5. Проверять пароль — `crypt.crypt(cand, hash) == hash`. + +### «Обход в Глории» — повторяющееся событие (Рабочий) + +Добавлено через CalDAV PUT: +`PUT /estorozhenko//obhod-v-glorii-2026.ics` → **201**. +VTIMEZONE Europe/Moscow + RRULE:FREQ=WEEKLY;BYDAY=TU, DTSTART 11:00. +Пользователь подтвердил: GMT+3 отображается корректно. + +**Урок:** сервер UTC, а у пользователя GMT+3 — обязательно указывать VTIMEZONE +(Europe/Moscow), иначе время «поедет» в приложении. + +### Чейндж: классификация писем + обработчики (email-classification-handlers) + +Пользователь попросил после скачивания письма классифицировать его локальной +моделью (Qwen3:8b) и подключать обработчики по тегам. + +**Диагностика пайплайна (важно):** +- Сейчас скачивается **только текст** + метаданные. Вложения — **НЕ качаются**. +- **Баг:** `get_attachments()` в `mail_archive.py` вызывает + `himalaya attachment download --dir `, но правильный флаг — + **`--downloads-dir`** (не `--dir`). Команда падает (exit 2), ошибка молча + глотается `except: pass`, папка `attachments/` всегда пустая. +- Проверено на живом письме с `has_attachment: true`: папка пустая. +- Классификатора/обработчиков нет; тэгов `tags:` нет ни в одном email.md. + +**Создан чейндж** `email-classification-handlers` (proposal/specs/design/tasks): +- `email-attachments`: фикс вложений (`--downloads-dir`, в каталог письма + `/attachments/`, идемпотентно) +- `email-classification`: Qwen3:8b (Ollama localhost:11434) → теги + info/urgent/task/meeting (+unclassified при ошибке), поле `classification` + + `classification_reason` в frontmatter, идемпотентно, приватно +- `email-handlers`: urgent→Telegram, task→Radicale VTODO, meeting→Radicale VEVENT, + info→ничего; `handled_*` в frontmatter + +### Vikunja — ЛИШНЯЯ СУЩНОСТЬ, удалена (remove-vikunja-use-radicale-tasks) + +**Решение пользователя (2026-09-13):** Vikunja не нужна — Radicale умеет задачи +как VTODO (календарь «Задачи»), jtx board читает их по CalDAV. + +**Создан и применён чейндж** `remove-vikunja-use-radicale-tasks` (14 задач, все +выполнены): +- Обработчик `task` в classification → **Radicale CalDAV PUT VTODO** + (SUMMARY=тема, DESCRIPTION=ссылка на email.md, DTSTART/DUE при наличии) +- `docker compose down -v` в `/opt/hermes/email-assistant/vikunja/` → контейнеры + `vikunja` + `vikunja-db` удалены, порт 3456 свободен +- Каталог `vikunja/` удалён (db от root — `sudo rm -rf`) +- Caddy vps02: блок `tasks.nixg.ru` закомментирован (строки 114-120), + `caddy validate` → Valid, `caddy reload`. Бэкап Caddyfile: + `/opt/caddy/Caddyfile.bak-vikunja-removed` +- Проверка: `cal.nixg.ru` → 207 (работает), `tasks.nixg.ru` → 502 (не проксируется) +- Тестовый VTODO `test-vikunja-removal-2026` создан в «Задачи» (PUT 201, GET 200) +- TODO.md / STATUS.md / local-calendar-tasks (SUPERSEDED) обновлены +- Бэкап Vikunja пропущен по явному решению пользователя + +**Урок:** Radicale нативно хранит VTODO (задачи) — отдельный трекер задач +(Vikunja) был избыточен. При выборе сервисов календаря Radicale закрывает и +календари, и задачи (VTODO), и контакты (CardDAV). + +### Открытые хвосты (2026-09-13) +- Чейндж `email-classification-handlers` — 0/23 задач (фикс вложений, скрипты + классификатора/обработчиков, cron, Telegram-секреты). +- Чейндж `contacts-caldav-server` — 16/30 (двусторонний sync контактов работает, + есть ещё задачи). +- Android-синхронизация: DAVx5 → Radicale (контакты синхронизировались), задачи + VTODO → jtx board — не настроено. +- Тестовый VTODO `test-vikunja-removal-2026` остался в «Задачи» (проверка). + +## 2026-09-13 (вечер) — чейндж email-classification-handlers, автономный заход + +Сессия велась автономно (пользователь дал разрешение «работай без подтверждения»). +Цель — закрыть чейндж `email-classification-handlers` (0/23 → прогресс). + +### 1. Вложения (email-attachments) — сделано, проверено + +`scripts/mail_archive.py`: +- `get_attachments()`: флаг `--dir` → **`--downloads-dir`** (подтверждено + `himalaya attachment download --help`). +- Папка `attachments/` создаётся **только** при `has_attachment: true` + (раньше — безусловно, плодила 1695 пустых папок). +- Идемпотентность: если в `dest_dir` уже есть файлы — повторно не качает. +- Удалена мёртвая строка `attachments_dir = msg_dir / "attachments"` из цикла. +- Проверено: `.xlsx` скачался на живом письме `Archive/2025/11/28`, + повторный прогон не дублировал (контроль hashlib). + +### 2. Классификатор (email-classification) — создан + +`scripts/email_classifier.py` (327 строк): +- Читает `email.md` из архива, для писем **без** поля `classification` в frontmatter + вызывает **Qwen3:8b через Ollama (localhost:11434)**. +- Пишет `classification` (теги info/urgent/task/meeting) + `classification_reason` + в frontmatter. Идемпотентно (повторный прогон пропускает уже классифицированные). +- Ручной прогон: письмо `2026/422` получило теги `task,meeting` ✓. +- Переиспользованы паттерны из `contacts_extractor.py` (clean_body, call_llm, + константы MAX_BODY_CHARS/LLM_TIMEOUT). + +### 3. Обработчики (email-handlers) — созданы, Radicale проверен живьём + +`scripts/email_handlers.py` (~470 строк): +- `urgent` → **Telegram** (Bot API через SOCKS5 `socks5://127.0.0.1:1080`, + токен `VESTI_BOT_TOKEN` из /opt/vesti/.env, канал `TELEGRAM_CHAT_ID`, + дефолт `@dedinit_vesti`). +- `task` → **Radicale CalDAV PUT VTODO** в календарь «Задачи». +- `meeting` → **Radicale CalDAV PUT VEVENT** в календарь «Рабочий». +- `info` → ничего. +- Идемпотентность: после успеха пишет `handled_urgent/handled_task/handled_meeting: true` + в frontmatter; повторный прогон пропускает. +- Режимы: `--limit N`, `--folder`, `--dry-run`. + +**Подводные камни Radicale (важно для повторения!):** +1. **Пароль в `radicale/.env` НЕ совпадал с `radicale/data/users`** — пароль менялся + в 13.09 17:55 (users.bak), но `.env` остался от 11.09. Доступ 401. Синхронизировал + `.env` с актуальным паролем (бэкап `.env.bak.`). **Правило: после смены пароля + Radicale обновлять и `.env` скриптов.** +2. **`urllib.request` НЕ работает с percent-encoded кириллицей в URL** CalDAV + (`/estorozhenko/%D0%97%D0%B0%D0%B4%D0%B0%D1%87%D0%B8/...`) — падает + `Errno -2 Name or service not known`. Решение: использовать **`http.client`** + напрямую (HTTPConnection + request с готовым path). Проверено: PUT 201. +3. **PROPFIND без заголовка `Depth: 1`** возвращает только сам ресурс, без + дочерних календарей → коллекции «не находились». Обязательно `headers["Depth"] = "1"`. +4. **Формат дат iCalendar единый `YYYYMMDDTHHMMSS`** — не смешивать + `2026-09-14T11:00:00` (с дефисами) и `20260914T110000` (Radicale отклоняет + первое, 400 Bad Request). +5. **Коллекции Radicale кэшируются** через `@lru_cache` (PROPFIND один раз за проход). + +**Проверено:** dry-run → `task` VTODO 204, `meeting` VEVENT 201, тестовые объекты +удалены (DELETE 200). + +### Статус чейнджа на конец сессии +- 1. Вложения: готово (проверено). +- 2. Классификатор: скрипт готов, прогон прошёл (письмо 422 → task,meeting). +- 3. Обработчики: скрипт готов, dry-run чист (VTODO/VEVENT создаются). + Осталось: живой прогон на реальном письме (без --dry-run), проверка urgent→Telegram. +- 4. Секреты: Radicale-пароль в .env синхронизирован. Осталось: TELEGRAM_CHAT_ID + в .env (токен есть в /opt/vesti/.env, VESTI_BOT_TOKEN). +- 5. Cron: не настроен (после пунктов 3-4). +- 6. Документация: этот WALKTHROUGH, STATUS/TODO — следующий заход. + +**Открыто на следующий заход:** живой прогон обработчиков (--limit 1 на каком-то +письме с task/meeting), проверка доставки urgent в Telegram, cron +(классификатор → обработчики после mail-archive), обновление STATUS.md/TODO.md, +вычитка openspec-файлов чейнджа. \ No newline at end of file diff --git a/openspec/changes/email-classification-handlers/.openspec.yaml b/openspec/changes/email-classification-handlers/.openspec.yaml new file mode 100644 index 0000000..c238415 --- /dev/null +++ b/openspec/changes/email-classification-handlers/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-13 diff --git a/openspec/changes/email-classification-handlers/design.md b/openspec/changes/email-classification-handlers/design.md new file mode 100644 index 0000000..1a5bdc7 --- /dev/null +++ b/openspec/changes/email-classification-handlers/design.md @@ -0,0 +1,111 @@ +# Design: Классификация писем и подключение обработчиков + +## Context + +Пайплайн почты сейчас: +``` +IMAP → himalaya → email.md (frontmatter + текст) → [конец] +``` +Вложения теряются (баг `--dir` вместо `--downloads-dir`). Классификации нет. +Обработчиков нет. Ollama с Qwen3:8b уже используется (contacts_extractor), +Radicale и Vikunja развёрнуты. + +Известные ограничения: +- Vikunja выведена из проекта (лишняя сущность; задачи через Radicale VTODO, + см. чейндж `remove-vikunja-use-radicale-tasks`). +- Radicale работает, календарь «Рабочий» и «Задачи» есть (в «Рабочий» я уже + добавил «Обход в Глории»). +- Telegram-уведомления: Hermes gateway может слать в Telegram; нужен канал/chat_id. +- Приватность: вся LLM-обработка локально (Ollama localhost:11434). + +## Goals / Non-Goals + +**Goals:** +- Починить скачивание вложений в каталог письма. +- Классифицировать каждое письмо локальной моделью, писать тег + обоснование. +- Подключать обработчики по тегам: urgent→уведомление, task→задача в Vikunja, + meeting→событие в Radicale, info→ничего. +- Идемпотентность: письмо обрабатывается один раз. + +**Non-Goals:** +- Не делаем веб-интерфейс (это отдельный чейндж «Веб-интерфейс ассистента»). +- Не делаем сложный NLP / классификацию по нескольким моделям — только Qwen3:8b. +- Не мигрируем существующие письма (классификация только новых; историю можно + переклассифицировать отдельно флагом `--force`). +- Не реализуем умные дедлайны/приоритеты на основе содержания — только теги. + +## Decisions + +### D1: Классификатор — отдельный скрипт `email_classifier.py` +Отдельный скрипт (а не функция в mail_archive.py), потому что: +- mail_archive.py — no-agent cron каждые 5 мин, он должен быть быстрым и лёгким; + LLM-вызов медленный (секунды на письмо). +- Классификация идёт после архивации, отдельным проходом. +- Легко запускать вручную, менять модель/промпт, добавлять теги. +Скрипт читает email.md, отдаёт текст Qwen3:8b, получает JSON, пишет в frontmatter. + +### D2: Формат классификации — отдельное поле в frontmatter +В `email.md` frontmatter добавляем: +```yaml +classification: task,meeting # или info / urgent / task / meeting / unclassified +classification_reason: "Просят подготовить бюджет и назначить встречу" +``` +- `classification` — основной тег; `classification_reason` — обоснование. +- Трекинг обработанных: письмо «обработано», если есть `classification`. + Для надёжности дополнительно пишем в SQLite (`mail_index.db`) или state JSON + (когда обработан) — но минимум: поле в frontmatter достаточно. + +### D3: Обработчики — отдельный скрипт `email_handlers.py` +Скрипт, который: +1. Находит письма с тегом, для которых ещё не выполнен обработчик. +2. По тегу вызывает соответствующий обработчик: + - `urgent` → Telegram + - `task` → Vikunja API + - `meeting` → Radicale (создать VEVENT) + - `info` → ничего +3. Помечает обработанное письмо (поле `handled_urgent: true` / `handled_task: true` + / `handled_meeting: true`), чтобы не дублировать. + +### D4: Куда слать встречу — Radicale (календарь Рабочий) +Событие встречи создаётся в Radicale (cal.nixg.ru), календарь «Рабочий» +(рабочие встречи). Формат VEVENT с SUMMARY=тема письма, DTSTART из классификации +(если дата/время указаны) или на ближайший рабочий день 11:00 (по умолчанию). +Это согласуется с тем, что пользователь уже использует Radicale для календаря +(и я добавил туда «Обход в Глории»). Дата парсится LLM (в classification_reason +модель возвращает JSON с датой/временем/продолжительностью). + +### D5: Куда слать задачу — Radicale (VTODO, календарь «Задачи») +Задача создаётся в Radicale как VTODO (CalDAV) в календаре «Задачи» +(`https://cal.nixg.ru/estorozhenko/Задачи/`), а не в Vikunja. Vikunja выведена +из проекта (см. чейндж `remove-vikunja-use-radicale-tasks`). SUMMARY=тема письма, +DESCRIPTION=ссылка на email.md, при наличии даты — DTSTART/DUE. + +### D6: Уведомления — Telegram через Hermes gateway +`urgent` шлёт уведомление в Telegram. Используем Hermes gateway (или прямое +сообщение через API бота). Настройки (chat_id, token) в `.env` / config. + +### D7: Идемпотентность и трекинг +- Классификатор: письмо пропускается, если в frontmatter есть `classification`. +- Обработчики: письмо пропускается, если для его тега уже стоит `handled_*`. +- Это гарантирует: повторный запуск cron не создаст дубликатов. + +### D8: Хранение секретов +- Vikunja API token, Telegram chat_id/token — в `.env` (рядом с compose) или + в конфиге Hermes. Не хардкодить. + +## Risks / Trade-offs + +- **Vikunja нет токена** → снято: Vikunja выведена, обработчик `task` пишет + VTODO в Radicale. Нужен только Basic-auth Radicale (есть). +- **Качество классификации Qwen3:8b** → возможны ложные срабатывания + (письмо помечено `task`, хотя задачи нет). Митигирует: `classification_reason` + виден пользователю, легко править вручную; теги — не жёсткие. +- **Письмо с несколькими сущностями** (задача И встреча) → классификация может + вернуть комбинацию `task,meeting`; обработчики запускаются для каждого тега. +- **Дата встречи в свободном тексте** → LLM может ошибиться. Митигирует: + если дата не уверенна, ставим ближайший рабочий день 11:00 + reason «дата не + найдена точно». +- **Производительность** → Qwen3:8b на CPU медленный; классификатор должен быть + дозированным (`--limit N`, как contacts_extractor `--limit 15`). +- **Обработка старых писем** → не делаем в этом чейндже; при желании отдельный + проход `--force` по архивным. diff --git a/openspec/changes/email-classification-handlers/proposal.md b/openspec/changes/email-classification-handlers/proposal.md new file mode 100644 index 0000000..0dfbdae --- /dev/null +++ b/openspec/changes/email-classification-handlers/proposal.md @@ -0,0 +1,68 @@ +# Proposal: Классификация писем и подключение обработчиков + +## Why + +Сейчас после скачивания письма с IMAP (`mail_archive.py`) письмо сохраняется как +`email.md` (YAML-frontmatter + текст) и на этом всё. Пользователь не получает +сигнала о том, что пришло важное письмо: что в письме задача, встреча, срочный +вопрос или просто информация. Каждое письмо нужно вручную открывать и читать. + +При этом инфраструктура уже есть: +- Radicale (CalDAV/CardDAV) на `cal.nixg.ru` — календари Личный/Рабочий/Задачи +- Vikunja (трекер задач) — развёрнут, ждёт администратора +- Ollama с Qwen3:8b — локальная модель (не уходит в облако, приватно) +- Мессенджер — уведомления можно слать в Telegram + +Хочется: после скачивания письма локальная модель классифицирует его и помечает +тегами (информационное, требует срочного ответа, есть задача, назначена встреча +и т.п.), а на основе тегов запускаются обработчики: уведомление в мессенджер, +создание задачи в Vikunja, создание события в календаре Radicale. + +Параллельно найден баг: вложения сейчас **не скачиваются** — `get_attachments()` +вызывает `himalaya attachment download --dir`, но правильный флаг `--downloads-dir`, +команда падает (exit 2), ошибка молча глотается `except: pass`, и папка +`attachments/` всегда пустая. Чейндж чинит это: вложения должны попадать в каталог +письма (что логично — каталог письма уже создаётся). + +## What Changes + +1. **Вложения скачиваются в каталог письма** — `mail_archive.py` правит вызов + `himalaya attachment download`: использует `--downloads-dir` вместо `--dir`, + кладёт файлы в `/attachments/`. Проверяется на письме с вложением. +2. **Классификатор писем** — новый скрипт `email_classifier.py`, который: + - берёт неклассифицированные письма (нет `classification` в frontmatter) + - отдаёт текст письма локальной модели Qwen3:8b (Ollama localhost:11434) + - получает JSON с тегами: `info`, `urgent`, `task`, `meeting` (и, возможно, + `question`, `money`, `deadline`) + - пишет результат в frontmatter `email.md`: поле `classification` (тег) + + `classification_reason` (короткое обоснование) +3. **Обработчики по тегам** — новый скрипт `email_handlers.py`: + - `urgent` → уведомление в мессенджер (Telegram, через Hermes gateway) + - `task` → создание задачи в Vikunja (API tasks.nixg.ru) + - `meeting` → создание события в Radicale (календарь Рабочий, cal.nixg.ru) + - `info` → ничего, письмо просто помечено тегом + - идемпотентность: письмо обрабатывается один раз (трекинг в state/SQLite) +4. **Cron** — новый Hermes cron (или расширение существующего), который после + архивации запускает классификатор и обработчики. + +## Capabilities + +### New Capabilities +- `email-classification`: классификация писем локальной LLM + теги в frontmatter +- `email-handlers`: подключение обработчиков (уведомление, задача, встреча) по тегам +- `email-attachments`: скачивание вложений письма в его каталог (фикс бага) + +### Modified Capabilities +- (нет) — существующая capability `email-storage-format` не меняет требования + по формату файла, только добавляет новые поля; это расширение, а не изменение + существующих требований. + +## Impact + +- Скрипты: `mail_archive.py` (фикс вложений), новые `email_classifier.py`, + `email_handlers.py` +- Конфиг: Ollama (Qwen3:8b, уже есть), Vikunja API (нужен токен), Telegram + (gateway/уведомления), Radicale (события) +- Frontmatter `email.md`: новые поля `classification`, `classification_reason`, + `has_attachments` (если ещё нет) +- Cron: новый классификатор/обработчики diff --git a/openspec/changes/email-classification-handlers/specs/email-attachments/spec.md b/openspec/changes/email-classification-handlers/specs/email-attachments/spec.md new file mode 100644 index 0000000..05b4fba --- /dev/null +++ b/openspec/changes/email-classification-handlers/specs/email-attachments/spec.md @@ -0,0 +1,46 @@ +## Purpose + +Скачивание вложений письма в каталог этого письма. Сейчас `mail_archive.py` +вызывает `himalaya attachment download --dir`, но правильный флаг в Himalaya — +`--downloads-dir`, из-за чего команда падает (exit 2), ошибка молча глотается +`except: pass`, и папка `attachments/` всегда пустая. Вложения теряются. + +## ADDED 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/changes/email-classification-handlers/specs/email-classification/spec.md b/openspec/changes/email-classification-handlers/specs/email-classification/spec.md new file mode 100644 index 0000000..45964f5 --- /dev/null +++ b/openspec/changes/email-classification-handlers/specs/email-classification/spec.md @@ -0,0 +1,57 @@ +## Purpose + +Классификация писем локальной LLM: после скачивания письма модель определяет тип +письма (информационное, требует срочного ответа, содержит задачу, содержит +встречу) и записывает тег + обоснование в frontmatter файла email.md. Обработка +приватна — модель Qwen3:8b запущена локально через Ollama, текст письма не +покидает хост. + +## ADDED 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/changes/email-classification-handlers/specs/email-handlers/spec.md b/openspec/changes/email-classification-handlers/specs/email-handlers/spec.md new file mode 100644 index 0000000..b43c7da --- /dev/null +++ b/openspec/changes/email-classification-handlers/specs/email-handlers/spec.md @@ -0,0 +1,63 @@ +## Purpose + +Подключение обработчиков по тегам классификации письма: уведомление в мессенджер +для срочных писем, создание задачи в Vikunja для писем с задачей, создание +события в Radicale для писем со встречей. Обработчики запускаются автоматически +после классификации и работают идемпотентно. + +## ADDED 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/changes/email-classification-handlers/tasks.md b/openspec/changes/email-classification-handlers/tasks.md new file mode 100644 index 0000000..61665c2 --- /dev/null +++ b/openspec/changes/email-classification-handlers/tasks.md @@ -0,0 +1,58 @@ +# Tasks: Классификация писем и подключение обработчиков + +## 1. Починить скачивание вложений + +- [ ] 1.1 Исправить `get_attachments()` в `scripts/mail_archive.py`: заменить + `--dir` на `--downloads-dir`, передавать `/attachments/` +- [ ] 1.2 Не создавать папку `attachments/` для писем без вложений + (создавать только если `has_attachment: true` или команда что-то вернула) +- [ ] 1.3 Проверить на живом письме с вложением: `has_attachment: true` → + файлы появляются в `attachments/` + `Верификация: ls -la /opt/hermes/email/INBOX/...//attachments/` +- [ ] 1.4 Проверить идемпотентность: повторный запуск не качает повторно + +## 2. Классификатор писем (email_classifier.py) + +- [ ] 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` + (для meeting — `meeting_datetime`) +- [ ] 2.3 Обработка ошибок: невалидный JSON/нет ответа → `unclassified`, продолжить +- [ ] 2.4 `--limit N` для дозирования (как contacts_extractor) +- [ ] 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 в календаре «Задачи» + (SUMMARY=тема, DESCRIPTION=ссылка на email.md, DTSTART/DUE при наличии даты) + вместо Vikunja API (см. чейндж remove-vikunja-use-radicale-tasks) +- [ ] 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 + +## 4. Подготовка зависимостей + +- [ ] 4.1 Секреты в `.env`/config: Telegram chat_id/token (для обработчика `urgent`), + Radicale Basic-auth (уже есть в проекте) +- [ ] 4.2 Убедиться, что календарь «Задачи» Radicale существует и доступен + `Верификация: 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 на новом письме + (архивация → классификация → обработчик) + +## 6. Документация + +- [ ] 6.1 Обновить STATUS.md: новые скрипты, cron, фронтмэттер поля +- [ ] 6.2 Зафиксировать доступы (Vikunja token, Telegram) в ресурсах проекта diff --git a/openspec/changes/local-calendar-tasks/proposal.md b/openspec/changes/local-calendar-tasks/proposal.md index a1dfff2..d7849e1 100644 --- a/openspec/changes/local-calendar-tasks/proposal.md +++ b/openspec/changes/local-calendar-tasks/proposal.md @@ -1,5 +1,10 @@ # Proposal: Локальные сервисы календаря (Radicale) и задач (Vikunja) +> ⚠️ **SUPERSEDED (2026-09-13):** Часть про **Vikunja** заменена чейнджем +> `remove-vikunja-use-radicale-tasks` — Vikunja выведена из проекта (лишняя +> сущность), задачи ведутся через **Radicale VTODO** (календарь «Задачи»). +> Radicale-часть актуальна. + ## Why Для синхронизации календаря и задач с Android-телефоном нужны локальные diff --git a/openspec/changes/remove-vikunja-use-radicale-tasks/.openspec.yaml b/openspec/changes/remove-vikunja-use-radicale-tasks/.openspec.yaml new file mode 100644 index 0000000..c238415 --- /dev/null +++ b/openspec/changes/remove-vikunja-use-radicale-tasks/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-13 diff --git a/openspec/changes/remove-vikunja-use-radicale-tasks/design.md b/openspec/changes/remove-vikunja-use-radicale-tasks/design.md new file mode 100644 index 0000000..f63cf26 --- /dev/null +++ b/openspec/changes/remove-vikunja-use-radicale-tasks/design.md @@ -0,0 +1,83 @@ +# Design: Убрать Vikunja, задачи через Radicale (VTODO) + +## Context + +- Radicale (CalDAV/CardDAV) развёрнут, календари Личный/Рабочий/Задачи на ФС. + Публично: cal.nixg.ru (Caddy vps02 → 10.8.0.2:5232). +- Vikunja развёрнут (compose `/opt/hermes/email-assistant/vikunja/`, контейнеры + `vikunja` + `vikunja-db` postgres, :3456), но админ/API-токен НЕ созданы — + это блокер для обработчика `task` в classification-чейндже. +- Календарь «Задачи» в Radicale уже существует (VTODO-совместимый). +- Android: jtx board синхронизирует VTODO по CalDAV через DAVx5. + +## Goals / Non-Goals + +**Goals:** +- Убрать Vikunja как лишнюю сущность. +- Задачи создаются через Radicale VTODO (календарь «Задачи»). +- Обработчик `task` в classification-чейндже пишет VTODO, не зависит от Vikunja. +- Обновить документацию/список задач. + +**Non-Goals:** +- Не мигрируем данные из Vikunja (их там нет — сервис не администрирован). +- Не удаляем данные Radicale — только добавляем VTODO. +- Не трогаем Caddy, если tasks.nixg.ru ещё не настроен (только не настраивать). + +## Decisions + +### D1: Vikunja выводится из эксплуатации +Контейнеры `vikunja` и `vikunja-db` останавливаются и удаляются: +```bash +cd /opt/hermes/email-assistant/vikunja +docker compose down -v # или docker stop vikunja vikunja-db && docker rm ... +``` +- Данные (volume `vikunja-db`) можно удалить (сервис не использовался), + либо сделать бэкап перед удалением (аккуратно — «не удалять данные + пользователя»). Решение: сделать копию volume/postgres-дампа на всякий случай, + затем удалить контейнеры; compose.yml/.env пометить deprecated или удалить + после подтверждения пользователя. + +### D2: Обработчик task → Radicale VTODO +В чейндже `email-classification-handlers` обработчик `task` меняется с +«Vikunja API POST» на «Radicale CalDAV PUT VTODO»: +- URL: `https://cal.nixg.ru/estorozhenko//.ics` +- Auth: Basic (estorozhenko:пароль Radicale) +- Body: VCALENDAR + VTODO (SUMMARY=тема, DESCRIPTION=ссылка на email.md, + при наличии даты — DTSTART/DUE) +- Пометить `handled_task: true` после успешного PUT (201/204) +- Идемпотентность: если `handled_task: true` — пропустить + +### D3: tasks.nixg.ru +- Если reverse proxy уже настроен в Caddy — закомментировать/убрать. +- Если нет — не настраивать. Единственный домен: cal.nixg.ru. + +### D4: Android — jtx board +Для задач (VTODO) используется jtx board, синхронизация через DAVx5 (Radicale). +В STATUS.md зафиксировать: «задачи = Radicale VTODO, jtx board». + +### D5: Чейндж email-classification-handlers — правка +В `email-classification-handlers`: +- specs/email-handlers/spec.md: «Создание задачи в Vikunja» → «Создание задачи + в Radicale (VTODO)» +- design.md: убрать Vikunja-ветку, заменить на Radicale VTODO +- tasks.md: задача 3.3 (Vikunja API) → Radicale VTODO; задача 4.1 (админ Vikunja) + → удалить +Это правки в активном чейндже — внести сразу. + +## Risks / Trade-offs + +- **Vikunja данные** — если в Vikunja что-то было создано, удаление volume потеряет + это. Митигирует: бэкап volume/postgres-дамп перед удалением. +- **VTODO-совместимость клиентов** — Radicale хранит VTODO как файлы, jtx board + их читает. Риск низкий (стандарт CalDAV). +- **Ссылка на email.md в DESCRIPTION** — на телефоне путь недоступен (локальный + диск), но виден в reason/задаче. Это ок: задача показывает тему + обоснование. +- **Уже развёрнутый Vikunja** — вывод из эксплуатации надо делать аккуратно, + с бэкапом и подтверждением (не удалять данные пользователя без спроса). + +## Verification + +1. `docker ps` — контейнеры vikunja/vikunja-db отсутствуют. +2. `curl -X PROPFIND https://cal.nixg.ru/estorozhenko/Задачи/` — календарь доступен. +3. Создать VTODO через обработчик → `curl GET .../Задачи/.ics` — VTODO есть. +4. `openspec validate remove-vikunja-use-radicale-tasks` — valid. diff --git a/openspec/changes/remove-vikunja-use-radicale-tasks/proposal.md b/openspec/changes/remove-vikunja-use-radicale-tasks/proposal.md new file mode 100644 index 0000000..29c7d32 --- /dev/null +++ b/openspec/changes/remove-vikunja-use-radicale-tasks/proposal.md @@ -0,0 +1,60 @@ +# Proposal: Убрать Vikunja, задачи через Radicale (VTODO) + +## Why + +В проекте email-assistant были развёрнуты два сервиса для календаря/задач: +- **Radicale** (CalDAV/CardDAV) — календари Личный/Рабочий/Задачи +- **Vikunja** (трекер задач) — отдельная сущность на :3456, tasks.nixg.ru + +Это лишняя сложность: Radicale из коробки поддерживает **VTODO** (задачи через +CalDAV), а календарь «Задачи» в Radicale уже создан. На Android задачи из +CalDAV-VTODO прекрасно синхронизирует **jtx board** (и DAVx5), не требуя +отдельного трекера. + +Vikunja добавляет: +- лишний docker-контейнер + PostgreSQL +- отдельный API, токен, админа (не созданы — блокер) +- отдельный домен tasks.nixg.ru (reverse proxy, сертификат) +- дублирование логики «создать задачу» (Vikunja API вместо простого VTODO) +- усложнение чейнджа классификации (обработчик `task` зависел от несуществующего токена) + +Убираем Vikunja из проекта. Задачи — через Radicale (VTODO в календаре «Задачи»). +Это упрощает архитектуру, убирает лишнюю сущность, не теряя функциональности. + +## What Changes + +1. **Обработчик задач переключается на Radicale VTODO** — в чейндже + `email-classification-handlers` обработчик `task` создаёт не задачу в Vikunja, + а **VTODO в календаре «Задачи» Radicale** (CalDAV PUT). Тема письма → SUMMARY, + ссылка на письмо → DESCRIPTION. +2. **Vikunja выводится из эксплуатации** — остановить и удалить контейнеры + `vikunja` и `vikunja-db`, убрать docker-compose.yml, .env (или пометить + deprecated), освободить порт 3456. +3. **tasks.nixg.ru** — если reverse proxy уже настроен, убрать/закомментировать; + если нет — не настраивать. Radicale остаётся единственным CalDAV-сервером. +4. **Обновить документацию** — STATUS.md, TODO.md, design чейнджей убрать Vikunja, + зафиксировать «задачи = Radicale VTODO, jtx board». +5. **TODO/общий список** — задача «Vikunja» закрыта как «не нужна», + «Caddy tasks.nixg.ru» — отменена. + +## Capabilities + +### New Capabilities +- `radicale-tasks`: создание задач (VTODO) в Radicale через CalDAV — заменяет + Vikunja для обработчика `task` в email-classification-handlers. + +### Modified Capabilities +- (нет) — Vikunja не является capability проекта; это внешний сервис, который + выводится из эксплуатации. Radicale-tasks — новое поведение. + +## Impact + +- **Docker**: остановить/удалить `vikunja`, `vikunja-db` (compose в + `/opt/hermes/email-assistant/vikunja/`) +- **Скрипты**: `email_handlers.py` (в чейндже classification) — обработчик `task` + → Radicale VTODO вместо Vikunja API +- **Радикал**: календарь «Задачи» уже существует, туда пишутся VTODO +- **Документация**: STATUS.md, TODO.md, design.md (local-calendar-tasks, + email-classification-handlers) — убрать Vikunja, зафиксировать Radicale VTODO +- **Caddy (если настроен)**: убрать/закомментировать tasks.nixg.ru +- **TODO.md**: закрыть задачи Vikunja (2, 3, 6 — «создать задачу» теперь Radicale) diff --git a/openspec/changes/remove-vikunja-use-radicale-tasks/specs/radicale-tasks/spec.md b/openspec/changes/remove-vikunja-use-radicale-tasks/specs/radicale-tasks/spec.md new file mode 100644 index 0000000..24df405 --- /dev/null +++ b/openspec/changes/remove-vikunja-use-radicale-tasks/specs/radicale-tasks/spec.md @@ -0,0 +1,45 @@ +## Purpose + +Создание задач через Radicale как VTODO-объектов в календаре «Задачи» (CalDAV). +Используется обработчиком `task` в чейндже email-classification-handlers вместо +Vikunja. Упрощает архитектуру: Radicale — единственный сервис календаря и задач. + +## ADDED Requirements + +### Requirement: Задача создаётся как VTODO в Radicale + +Задача MUST создаваться как VTODO-объект (CalDAV) в календаре «Задачи» Radicale +по адресу `https://cal.nixg.ru/estorozhenko/Задачи/`, а не через Vikunja API. + +#### Scenario: Обработчик task +- **WHEN** `email_handlers.py` обрабатывает письмо с тегом `task` +- **THEN** в календаре «Задачи» Radicale создаётся VTODO (PUT по адресу + `https://cal.nixg.ru/estorozhenko//.ics`) + +### Requirement: Поля VTODO из письма + +VTODO MUST содержать: SUMMARY — тема письма, DESCRIPTION — ссылка на файл +`email.md` письма. При наличии даты/дедлайна в классификации — DTSTART/DUE. + +#### Scenario: Создание VTODO из письма с задачей +- **WHEN** `email_handlers.py` создаёт задачу из письма с тегом `task` +- **THEN** VTODO имеет SUMMARY=тема письма, DESCRIPTION=путь к email.md, + и (если указано) DTSTART/DUE из классификации + +### Requirement: Идемпотентность задач Radicale + +Повторный запуск обработчика MUST NOT создавать дубликат VTODO для одного письма +(поле `handled_task: true` в frontmatter после успешного создания). + +#### Scenario: Повторный запуск обработчика task +- **WHEN** `email_handlers.py` запущен повторно на письме с уже созданной задачей (`handled_task: true`) +- **THEN** новый VTODO не создаётся + +### Requirement: Совместимость с jtx board / DAVx5 + +Созданный VTODO MUST быть читаемым стандартными CalDAV-клиентами (jtx board, +DAVx5), т.е. валидным VCALENDAR с VTODO компонентом. + +#### Scenario: Чтение задачи в jtx board +- **WHEN** пользователь открывает календарь «Задачи» в jtx board (через DAVx5) +- **THEN** VTODO отображается как задача с SUMMARY и DESCRIPTION diff --git a/openspec/changes/remove-vikunja-use-radicale-tasks/tasks.md b/openspec/changes/remove-vikunja-use-radicale-tasks/tasks.md new file mode 100644 index 0000000..34bb066 --- /dev/null +++ b/openspec/changes/remove-vikunja-use-radicale-tasks/tasks.md @@ -0,0 +1,45 @@ +# Tasks: Убрать Vikunja, задачи через Radicale VTODO + +## 1. Переключить обработчик task на Radicale VTODO + +- [x] 1.1 В чейндже `email-classification-handlers` обновить specs/design/tasks: + заменить «Vikunja API» на «Radicale CalDAV PUT VTODO» (календарь Задачи) +- [x] 1.2 Уточнить формат VTODO: SUMMARY=тема, DESCRIPTION=ссылка на email.md, + DTSTART/DUE при наличии даты из классификации +- [x] 1.3 Верификация: чейндж `email-classification-handlers` остаётся валидным + `Верификация: cd /opt/hermes/email-assistant && openspec validate email-classification-handlers` + +## 2. Вывод Vikunja из эксплуатации + +- [x] 2.1 Сделать бэкап данных Vikunja (если есть) перед удалением + (volume vikunja-db / postgres-дамп в backups/) — **НЕ НУЖЕН** (решение пользователя 2026-09-13) +- [x] 2.2 **Подтверждение пользователя на удаление** volume (данные Vikunja) + — получено: «бэкап не нужен, выполняй остальные пункты» +- [x] 2.3 Остановить и удалить контейнеры + `cd /opt/hermes/email-assistant/vikunja && docker compose down -v` + `Верификация: docker ps | grep -E 'vikunja|postgres' || echo 'Vikunja removed'` +- [x] 2.4 Удалить каталог `/opt/hermes/email-assistant/vikunja/` (compose, .env) + `Верификация: test ! -d /opt/hermes/email-assistant/vikunja` +- [x] 2.5 Убрать/закомментировать reverse proxy tasks.nixg.ru из Caddy, + если он настроен (Caddyfile на vps02) — закомментирован (строки 114-120), Caddy перезагружен + +## 3. Обновить документацию и планы + +- [x] 3.1 TODO.md: закрыть задачи Vikunja (2 «Vikunja развёрнут», 3 «Vikunja app», + 6 «Vikunja API»), пометить «не нужна» (сделано 2026-09-13) +- [x] 3.2 STATUS.md: убрать Vikunja из архитектуры, зафиксировать + «задачи = Radicale VTODO, календарь Задачи, jtx board/DAVx5» (сделано 2026-09-13) +- [x] 3.3 Обновить запись «Ресурсы проекта»: убрать Vikunja/tasks.nixg.ru, + добавить Radicale VTODO (сделано 2026-09-13) +- [x] 3.4 В чейндже `local-calendar-tasks` пометить Vikunja как не входящую + (или заархивировать его как superseded) — proposal помечен SUPERSEDED + +## 4. Проверка end-to-end + +- [x] 4.1 Создать тестовое письмо с тегом `task` → обработчик создаёт VTODO + в Radicale (календарь Задачи) + `Верификация: curl -u estorozhenko:... -X PROPFIND -H 'Depth: 1' https://cal.nixg.ru/estorozhenko// | grep -c 'VTODO\|ics'` + — тестовый VTODO `test-vikunja-removal-2026` создан (PUT 201, GET 200) 2026-09-13 +- [x] 4.2 Верифицировать, что Vikunja отсутствует и ничего не сломано + `docker ps | grep -i vikunja || echo OK` — контейнеров нет, порт 3456 свободен, + cal.nixg.ru работает (207) diff --git a/scripts/email_classifier.py b/scripts/email_classifier.py new file mode 100644 index 0000000..c1d9771 --- /dev/null +++ b/scripts/email_classifier.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +""" +email_classifier.py — классификация писем локальной LLM (Qwen3:8b через Ollama). + +Читает email.md файлы архива, для каждого письма БЕЗ поля `classification` +вызывает Qwen3:8b (localhost:11434), получает JSON с тегом и обоснованием, +записывает в frontmatter: + + classification: info|urgent|task|meeting|task,meeting|unclassified + classification_reason: "краткое обоснование на русском" + meeting_datetime: "YYYY-MM-DD HH:MM" (только для meeting) + +Трекинг обработанных — по наличию `classification` в frontmatter (D2): +повторный запуск пропускает уже обработанные письма. + +Запуск: + python3 scripts/email_classifier.py # новые письма (свежие первыми) + python3 scripts/email_classifier.py --limit 10 + python3 scripts/email_classifier.py --force # переклассифицировать всё + python3 scripts/email_classifier.py --folder INBOX +""" + +import argparse +import json +import re +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime +from pathlib import Path + +# Конфигурация +EMAIL_ROOT = Path("/opt/hermes/email") +OLLAMA_URL = "http://localhost:11434/api/generate" +OLLAMA_MODEL = "qwen3:8b-nothink" # текстовая задача (без think-токенов — быстрее) +LLM_TIMEOUT = 60 # секунд на один запрос (классификация длиннее контактов) +MAX_BODY_CHARS = 5000 # как в contacts_extractor +TEMP = 0.1 + +# Теги классификации (валидные значения поля classification) +VALID_TAGS = {"info", "urgent", "task", "meeting"} + +PROMPT_TEMPLATE = """Ты — классификатор входящей почты. Определи тип письма по его тексту. + +Возможные типы (можно комбинировать через запятую): +- info: информационное письмо, не требует действий (новости, рассылки, отчёты для сведения) +- urgent: требует срочного ответа/действия сегодня (горящие сроки, просьбы ответить) +- task: содержит поручение/задачу, которую нужно выполнить (что-то сделать, подготовить, прислать) +- meeting: содержит приглашение на встречу/совещание/созвон, или просьбу назначить встречу + +Правила: +- Если письмо содержит и задачу, и встречу — верни "task,meeting" +- Если явно не указано — лучше info, чем ложное срабатывание +- Для meeting попробуй извлечь дату и время из текста (формат "YYYY-MM-DD HH:MM", + время в 24-часовом формате, например "2026-09-15 11:00"). Если дата не указана — null. + +Верни ТОЛЬКО JSON, без пояснений: +{{"classification": "info", "reason": "1-2 предложения на русском, почему такой тег", "meeting_datetime": null}} + +Тема письма: {subject} +Отправитель: {sender} + +Текст письма: +{body} +""" + + +def parse_email_md(path): + """Прочитать email.md, вернуть (headers_dict, body_text, raw_content, fm_end).""" + content = path.read_text(encoding="utf-8", errors="replace") + match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", content, re.DOTALL) + if match: + yaml_block = match.group(1) + body = match.group(2).strip() + fm_end = match.end(1) # позиция конца YAML-блока (перед закрывающим ---) + headers = {} + for line in yaml_block.split("\n"): + m = re.match(r"^(\w[\w_-]*)\s*:\s*(.*)$", line) + if m: + headers[m.group(1)] = m.group(2).strip() + else: + headers, body, fm_end = {}, content.strip(), None + return headers, body, content, fm_end + + +def clean_body(body): + """Очистка тела письма — переиспользуем логику contacts_extractor.""" + # Удаляем <#part ...> блоки и HTML-теги + body = re.sub(r"<#part[^>]*>", "", body) + body = re.sub(r"<#/part>", "", body) + body = re.sub(r"<[^>]+>", "", body) + body = re.sub(r"\(mailto:[^)]+\)", "", body) + # Unicode-пробелы → обычные + body = re.sub(r"[\u00a0\u2000-\u200f\u2028-\u202f\u2060]+", " ", body) + # Трекинг-ссылки + body = re.sub(r"https?://tn-eoc\.[^\s]+", "", body) + body = re.sub(r"https?://[^\s]+\?utm_[^\s]+", "", body) + # Цитируемая переписка — отрезаем от самого раннего маркера + quote_patterns = [ + r"^[\s]*_{4,}\s*$", + r"От:.*\n[\s]*Отправлено:", + r"^[\s]*From:.*\n[\s]*Sent:", + r"—+.*Forwarded.*—+", + r"—+.*Пересылаемое.*—+", + r"—+.*Original Message.*—+", + r">.*\bwrote:", + ] + earliest_pos = len(body) + for qp in quote_patterns: + for m in re.finditer(qp, body, re.MULTILINE): + if m.start() < earliest_pos: + earliest_pos = m.start() + if earliest_pos < len(body): + body = body[:earliest_pos].strip() + else: + tail = body[-500:] if len(body) > 500 else body + for pattern in [r"От:", r"Отправлено:", r"From:", r"Sent:", r"Кому:", r"To:", r"Тема:", r"Subject:"]: + m2 = re.search(pattern, tail) + if m2: + offset = len(body) - len(tail) + m2.start() + body = body[:offset].strip() + break + lines = [l for l in body.split("\n") if not re.match(r"^\s*>", l)] + body = re.sub(r"\n{3,}", "\n\n", "\n".join(lines)) + return body.strip() + + +def yaml_quote(v): + """YAML-значение: обернуть в двойные кавычки при спецсимволах.""" + v = str(v) + if v == "": + return '""' + if re.search(r'[:#\[\]{}&*!|>\'"%@`\n]|^\s|\s$', v): + return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"' + return v + + +def call_llm(subject, sender, body_text, max_retries=2): + """Вызвать Qwen через Ollama, вернуть dict или None.""" + body_text = clean_body(body_text)[:MAX_BODY_CHARS] + prompt = PROMPT_TEMPLATE.format(subject=subject or "(без темы)", sender=sender or "?", body=body_text) + + for attempt in range(max_retries + 1): + if attempt > 0: + time.sleep(1) + payload = json.dumps({ + "model": OLLAMA_MODEL, + "prompt": prompt, + "stream": False, + "options": {"temperature": TEMP, "num_predict": 512}, + }).encode("utf-8") + req = urllib.request.Request(OLLAMA_URL, data=payload, + headers={"Content-Type": "application/json"}, method="POST") + try: + resp = urllib.request.urlopen(req, timeout=LLM_TIMEOUT) + data = json.loads(resp.read().decode("utf-8")) + response_text = data.get("response", "").strip() + except (urllib.error.URLError, json.JSONDecodeError, TimeoutError) as e: + if attempt < max_retries: + continue + print(f" ⚠ LLM error: {e}", file=sys.stderr) + return None + + if not response_text: + if attempt < max_retries: + continue + print(f" ⚠ LLM empty response", file=sys.stderr) + return None + + # Парсим: весь ответ как JSON или { ... } внутри + try: + return json.loads(response_text) + except json.JSONDecodeError: + pass + brace_depth, json_start = 0, None + for i, ch in enumerate(response_text): + if ch == "{": + if brace_depth == 0: + json_start = i + brace_depth += 1 + elif ch == "}": + brace_depth -= 1 + if brace_depth == 0 and json_start is not None: + try: + return json.loads(response_text[json_start:i + 1]) + except json.JSONDecodeError: + pass + json_start = None + if attempt < max_retries: + continue + print(f" ⚠ LLM JSON parse error: {response_text[:300]}", file=sys.stderr) + return None + + +def normalize_classification(raw): + """Привести теги к валидному виду (через запятую), вернуть (tags_str, reason, meeting_dt).""" + tags_raw = raw.get("classification") or raw.get("tags") or "" + if isinstance(tags_raw, list): + tags = [t.strip().lower() for t in tags_raw if isinstance(t, str)] + else: + tags = [t.strip().lower() for t in str(tags_raw).split(",") if t.strip()] + # Оставляем только валидные теги + tags = [t for t in tags if t in VALID_TAGS] + if not tags: + return "unclassified", (raw.get("reason") or "").strip(), None + tags = sorted(set(tags)) # детерминированный порядок + reason = (raw.get("reason") or raw.get("classification_reason") or "").strip() + meeting_dt = None + if "meeting" in tags: + mdt = raw.get("meeting_datetime") + if mdt: + s = str(mdt).strip() + m = re.match(r"^(\d{4}-\d{2}-\d{2})[T ](\d{1,2}:\d{2})", s) + if m: + meeting_dt = f"{m.group(1)} {m.group(2)}" + return ",".join(tags), reason, meeting_dt + + +def add_to_frontmatter(content, fm_end, fields): + """Добавить поля YAML в frontmatter (перед закрывающим ---).""" + add_lines = [] + for k, v in fields: + if v is None or v == "": + continue + add_lines.append(f"{k}: {yaml_quote(v)}") + if not add_lines: + return content + before = content[:fm_end] + after = content[fm_end:] + return before + "\n" + "\n".join(add_lines) + after + + +def find_email_md_files(root, folder=None): + """Найти email.md, опционально в конкретной папке (префикс пути).""" + files = [] + for p in sorted(root.rglob("email.md")): + if folder: + rel = p.relative_to(root) + if not rel.parts[0] == folder: + continue + files.append(p) + return files + + +def email_sort_key(path): + """Свежие письма первыми: по (году, месяцу) из пути + UID (число).""" + parts = path.parts + # Ищем в частях пути год (4 цифры), месяц (2), UID (число-каталог > 100) + year = next((int(p) for p in parts if re.fullmatch(r"\d{4}", p)), 0) + month = next((int(p) for p in parts if re.fullmatch(r"\d{2}", p) and 1 <= int(p) <= 12), 0) + uid = next((int(p) for p in parts if p.isdigit() and int(p) > 100), 0) + # fallback: mtime файла + if not year: + try: + return (-float(path.stat().st_mtime),) + except OSError: + return (0,) + return (-year, -month, -uid) + + +def main(): + ap = argparse.ArgumentParser(description="Классификация писем через Qwen3:8b (Ollama)") + ap.add_argument("--limit", type=int, default=0, help="Максимум писем за проход (0 = все)") + ap.add_argument("--force", action="store_true", help="Переклассифицировать даже обработанные") + ap.add_argument("--folder", default=None, help="Только письма из конкретной папки (INBOX)") + args = ap.parse_args() + + print(f"Классификатор: {OLLAMA_MODEL} ({OLLAMA_URL})") + files = find_email_md_files(EMAIL_ROOT, args.folder) + print(f"Найдено email.md: {len(files)}") + + pending = [] + for p in files: + headers, body, content, fm_end = parse_email_md(p) + if not args.force and headers.get("classification"): + continue + pending.append((p, headers, body, content, fm_end)) + + # Свежие первыми + pending.sort(key=lambda x: email_sort_key(x[0])) + print(f"Классифицировать: {len(pending)}") + + if args.limit > 0: + pending = pending[:args.limit] + + processed = 0 + for p, headers, body, content, fm_end in pending: + try: + subject = headers.get("subject", "") + sender = headers.get("from", "") + result = call_llm(subject, sender, body) + if result is None: + tags, reason, meeting_dt = "unclassified", "LLM не ответила", None + else: + tags, reason, meeting_dt = normalize_classification(result) + + fields = [] + if headers.get("classification"): + # --force: обновляем, но поля уже есть — перезапишем через добавление + fields.append(("classification", tags)) + fields.append(("classification_reason", reason)) + if meeting_dt: + fields.append(("meeting_datetime", meeting_dt)) + else: + fields.append(("classification", tags)) + fields.append(("classification_reason", reason)) + if meeting_dt: + fields.append(("meeting_datetime", meeting_dt)) + + new_content = add_to_frontmatter(content, fm_end, fields) + if new_content != content: + p.write_text(new_content, encoding="utf-8") + print(f" ✓ {p.parent.parent.parent.name}/{p.parent.name}/{headers.get('subject','')[:50]!r} → {tags}") + processed += 1 + except Exception as e: + print(f" ✗ {p}: {e}", file=sys.stderr) + + print(f"\nГотово. Обработано: {processed}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/email_handlers.py b/scripts/email_handlers.py new file mode 100644 index 0000000..7a5ab43 --- /dev/null +++ b/scripts/email_handlers.py @@ -0,0 +1,456 @@ +#!/usr/bin/env python3 +""" +email_handlers.py — обработчики по тегам классификации писем. + +Сканирует email.md архива, для писем с тегом classification и без соответствующего +поля handled_* в frontmatter выполняет обработчик: + + urgent → Telegram (через Bot API + SOCKS5-туннель; from/subject/превью) + task → Radicale CalDAV: VTODO в календарь «Задачи» (SUMMARY=тема, + DESCRIPTION=ссылка на email.md, при наличии даты DTSTART/DUE) + meeting → Radicale CalDAV: VEVENT в календарь «Рабочий» (SUMMARY=тема, + DTSTART из meeting_datetime или ближайший рабочий день 11:00) + info → ничего (только тег в frontmatter) + +После успешной обработки в frontmatter пишется handled_urgent/handled_task/ +handled_meeting: true — повторный запуск не создаёт дубликатов (идемпотентность). + +Секреты — только из .env (рядом со скриптом): + RADICALE_URL / RADICALE_USER / RADICALE_PASS — доступ к Radicale + VESTI_BOT_TOKEN (или TELEGRAM_BOT_TOKEN) — токен бота Telegram + TG_PROXY — SOCKS5 до Bot API (по умолчанию socks5://127.0.0.1:1080) + TELEGRAM_CHAT_ID — куда слать urgent (по умолчанию @dedinit_vesti) + +Запуск: + python3 scripts/email_handlers.py # все необработанные + python3 scripts/email_handlers.py --limit 10 + python3 scripts/email_handlers.py --folder INBOX + python3 scripts/email_handlers.py --dry-run # показать, что бы сделал +""" + +import argparse +import http.client +import json +import os +import re +import sys +import urllib.error +import urllib.request +from datetime import datetime, timedelta +from functools import lru_cache +from pathlib import Path + +try: + from dotenv import load_dotenv +except ImportError: + load_dotenv = None + +# Каталог скрипта → .env рядом с проектом +BASE_DIR = Path(__file__).resolve().parents[1] +if load_dotenv: + load_dotenv(BASE_DIR / ".env", override=False) + +EMAIL_ROOT = Path(os.getenv("EMAIL_ROOT", "/opt/hermes/email")) + +# --- Radicale (CalDAV) --- +RADICALE_URL = os.getenv("RADICALE_URL", "http://127.0.0.1:5232").rstrip("/") +RADICALE_USER = os.getenv("RADICALE_USER", "estorozhenko") +RADICALE_PASS = os.getenv("RADICALE_PASS", "") +# Календари (percent-encoded, «Задачи» и «Рабочий» — кириллица) +TASKS_CAL = os.getenv("RADICALE_TASKS_CAL", "%D0%97%D0%B0%D0%B4%D0%B0%D1%87%D0%B8") # Задачи +WORK_CAL = os.getenv("RADICALE_WORK_CAL", "%D0%A0%D0%B0%D0%B1%D0%BE%D1%87%D0%B8%D0%B9") # Рабочий + +# --- Telegram (Bot API через SOCKS5) --- +TG_TOKEN = os.getenv("VESTI_BOT_TOKEN") or os.getenv("TELEGRAM_BOT_TOKEN") or "" +TG_PROXY = os.getenv("TG_PROXY", "socks5://127.0.0.1:1080") +TG_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "@dedinit_vesti") +TG_API = "https://api.telegram.org" + +# --- Общие --- +LLM_TIMEOUT = 20 +MAX_PREVIEW_CHARS = 400 # превью письма для Telegram +TG_TIMEOUT = 20 + + +# --------------------------------------------------------------------------- +# Frontmatter +# --------------------------------------------------------------------------- +def parse_email_md(path): + """Прочитать email.md, вернуть (headers, body, content, fm_end).""" + content = path.read_text(encoding="utf-8", errors="replace") + match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", content, re.DOTALL) + if match: + yaml_block = match.group(1) + body = match.group(2).strip() + fm_end = match.end(1) + headers = {} + for line in yaml_block.split("\n"): + m = re.match(r"^(\w[\w_-]*)\s*:\s*(.*)$", line) + if m: + headers[m.group(1)] = m.group(2).strip() + else: + headers, body, fm_end = {}, content.strip(), None + return headers, body, content, fm_end + + +def yaml_quote(v): + """YAML-значение: обернуть в двойные кавычки при спецсимволах.""" + v = str(v) + if v == "": + return '""' + if re.search(r'[:#\[\]{}&*!|>\'"%@`\n]|^\s|\s$', v): + return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"' + return v + + +def add_to_frontmatter(content, fm_end, fields): + """Добавить поля в frontmatter (перед закрывающим ---).""" + add_lines = [] + for k, v in fields: + if v is None or v == "": + continue + add_lines.append(f"{k}: {yaml_quote(v)}") + if not add_lines: + return content + before = content[:fm_end] + after = content[fm_end:] + return before + "\n" + "\n".join(add_lines) + after + + +def mark_handled(path, tag): + """Пометить письмо handled_: true. Возвращает True при изменении.""" + headers, body, content, fm_end = parse_email_md(path) + if fm_end is None: + return False + key = f"handled_{tag}" + if headers.get(key) == "true": + return False + new_content = add_to_frontmatter(content, fm_end, [(key, "true")]) + if new_content != content: + path.write_text(new_content, encoding="utf-8") + return True + + +# --------------------------------------------------------------------------- +# Radicale (CalDAV) +# --------------------------------------------------------------------------- +def _caldav(path: str, method="GET", body=None, content_type=None): + """Базовый HTTP к Radicale с Basic-auth через http.client. + + path — ПОЛНЫЙ URL (например http://127.0.0.1:5232/estorozhenko/...). + urllib.request не умеет URL с percent-encoded кириллицей в пути + (Errno -2 Name or service not known), поэтому используем http.client + напрямую — он корректно работает с encoded path. + Возвращает (status, text). + """ + import base64 + from urllib.parse import urlsplit + + if not path.startswith("http"): + path = RADICALE_URL + path + parsed = urlsplit(path) + conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=LLM_TIMEOUT) + full_path = parsed.path + (("?" + parsed.query) if parsed.query else "") + headers = {} + if content_type: + headers["Content-Type"] = content_type + if method == "PROPFIND": + headers["Depth"] = "1" + cred = base64.b64encode(f"{RADICALE_USER}:{RADICALE_PASS}".encode()).decode() + headers["Authorization"] = f"Basic {cred}" + try: + conn.request(method, full_path, body=body, headers=headers) + resp = conn.getresponse() + return resp.status, resp.read().decode("utf-8", errors="replace") + except Exception as e: + return 0, str(e) + finally: + conn.close() + + +def ics_escape(s): + """Экранирование значений iCalendar (RFC 5545): backslash, semicolon, comma, переносы.""" + s = str(s).replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,") + return s.replace("\r\n", "\\n").replace("\n", "\\n") + + +@lru_cache(maxsize=1) +def find_calendar_url(): + """Определить URL коллекций «Задачи» и «Рабочий» из PROPFIND (xml.etree).""" + status, text = _caldav(f"/{RADICALE_USER}/", "PROPFIND", body=b"", content_type="application/xml") + found: dict = {"Задачи": None, "Рабочий": None} + if status != 207: + return found + try: + import xml.etree.ElementTree as ET + root = ET.fromstring(text) + # Пространства имён: DAV: (по умолчанию), C: — caldav + ns = {"d": "DAV:", "c": "urn:ietf:params:xml:ns:caldav"} + for resp in root.findall("d:response", ns): + href_el = resp.find("d:href", ns) + if href_el is None: + continue + href = (href_el.text or "").strip() + rt = resp.find(".//d:resourcetype", ns) + if rt is None: + continue + is_cal = rt.find("c:calendar", ns) is not None + if not is_cal: + continue + import urllib.parse + dec = urllib.parse.unquote(href) + for name, key in [("Задачи", "Задачи"), ("Рабочий", "Рабочий")]: + if key in dec and found[name] is None: + found[name] = RADICALE_URL + href + except Exception as e: + print(f" ⚠ find_calendar_url: {e}", file=sys.stderr) + return found + + +def vtodo(uid, summary, description, due_dt=None): + """Сформировать VTODO (iCalendar). due_dt: 'YYYY-MM-DD HH:MM' или None.""" + now = datetime.now().strftime("%Y%m%dT%H%M%S") + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//email-assistant//VTODO//RU", + "BEGIN:VTODO", + f"UID:{uid}@email-assistant", + f"DTSTAMP:{now}", + f"SUMMARY:{ics_escape(summary)}", + f"DESCRIPTION:{ics_escape(description)}", + ] + if due_dt: + lines.append(f"DUE:{due_dt.replace(' ', 'T')}:00") + lines += ["STATUS:NEEDS-ACTION", "END:VTODO", "END:VCALENDAR"] + return "\r\n".join(lines) + + +def vevent(uid, summary, description, start_dt, duration_min=60): + """Сформировать VEVENT. start_dt: 'YYYY-MM-DD HH:MM'.""" + now = datetime.now().strftime("%Y%m%dT%H%M%S") + start = datetime.strptime(start_dt, "%Y-%m-%d %H:%M") + start_ics = start.strftime("%Y%m%dT%H%M%S") + end_ics = (start + timedelta(minutes=duration_min)).strftime("%Y%m%dT%H%M%S") + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//email-assistant//VEVENT//RU", + "BEGIN:VEVENT", + f"UID:{uid}@email-assistant", + f"DTSTAMP:{now}", + f"SUMMARY:{ics_escape(summary)}", + f"DESCRIPTION:{ics_escape(description)}", + f"DTSTART:{start_ics}", + f"DTEND:{end_ics}", + "END:VEVENT", + "END:VCALENDAR", + ] + return "\r\n".join(lines) + + +def next_workday_1100(now=None): + """Ближайший будний день (пн-пт) в 11:00. now: datetime.""" + now = now or datetime.now() + d = now + while d.weekday() >= 5: # сб/вс + d += timedelta(days=1) + return d.strftime("%Y-%m-%d") + " 11:00" + + +def handle_task(path, headers, body): + """Создать VTODO в Radicale «Задачи». Возвращает (ok, detail).""" + summary = (headers.get("subject") or "(без темы)").strip() + # UID стабильный: от пути письма + rel = path.relative_to(EMAIL_ROOT) if EMAIL_ROOT in path.parents else path + uid = re.sub(r"[^a-zA-Z0-9]+", "-", str(rel)).strip("-") + description = f"Из письма: {path}" + due = None + mdt = headers.get("meeting_datetime") or headers.get("date") + if mdt: + mdt = mdt.replace("T", " ")[:16] + if re.match(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$", mdt): + due = mdt + # URL коллекции «Задачи» + cals = find_calendar_url() + cal_url = cals.get("Задачи") + if not cal_url: + return False, "Коллекция «Задачи» не найдена в PROPFIND" + ics = vtodo(uid, summary, description, due) + resp_status, resp_text = _caldav(cal_url + uid + ".ics", "PUT", body=ics.encode("utf-8"), + content_type="text/calendar; charset=utf-8") + if resp_status in (200, 201, 204): + return True, f"VTODO создан ({resp_status})" + return False, f"PUT {resp_status}: {resp_text[:200]}" + + +def handle_meeting(path, headers, body): + """Создать VEVENT в Radicale «Рабочий». Возвращает (ok, detail).""" + summary = (headers.get("subject") or "(без темы)").strip() + rel = path.relative_to(EMAIL_ROOT) if EMAIL_ROOT in path.parents else path + uid = re.sub(r"[^a-zA-Z0-9]+", "-", str(rel)).strip("-") + description = f"Из письма: {path}" + start = None + mdt = headers.get("meeting_datetime") + if mdt: + mdt = mdt.replace("T", " ")[:16] + if re.match(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$", mdt): + start = mdt + if not start: + start = next_workday_1100() + cals = find_calendar_url() + cal_url = cals.get("Рабочий") + if not cal_url: + return False, "Коллекция «Рабочий» не найдена в PROPFIND" + ics = vevent(uid, summary, description, start) + resp_status, resp_text = _caldav(cal_url + uid + ".ics", "PUT", body=ics.encode("utf-8"), + content_type="text/calendar; charset=utf-8") + if resp_status in (200, 201, 204): + return True, f"VEVENT создан ({resp_status})" + return False, f"PUT {resp_status}: {resp_text[:200]}" + + +# --------------------------------------------------------------------------- +# Telegram (Bot API через SOCKS5) +# --------------------------------------------------------------------------- +def tg_client(): + """httpx.Client с SOCKS5-прокси. Путь прокси из TG_PROXY.""" + try: + import httpx + except ImportError: + return None + return httpx.Client(proxy=TG_PROXY, timeout=TG_TIMEOUT) + + +def tg_call(method, **params): + """Вызвать метод Bot API. Возвращает result dict. Ошибки -> исключение.""" + if not TG_TOKEN: + raise RuntimeError("Нет токена Telegram (VESTI_BOT_TOKEN/TELEGRAM_BOT_TOKEN не задан)") + client = tg_client() + if client is None: + raise RuntimeError("httpx не установлен — нужен для Telegram") + try: + with client: + r = client.post(f"{TG_API}/bot{TG_TOKEN}/{method}", json=params, timeout=TG_TIMEOUT) + except Exception as e: + raise RuntimeError(f"Сеть/прокси до Bot API: {e}") from e + if r.status_code != 200: + try: + desc = r.json().get("description", "") + except Exception: + desc = r.text[:200] + raise RuntimeError(f"Bot API {method}: HTTP {r.status_code} {desc}") + data = r.json() + if not data.get("ok"): + raise RuntimeError(f"Bot API {method}: {data.get('description','')}") + return data.get("result", {}) + + +def handle_urgent(path, headers, body): + """Отправить уведомление в Telegram. Возвращает (ok, detail).""" + subject = (headers.get("subject") or "(без темы)").strip() + sender = (headers.get("from") or "?").strip() + preview = body.strip() + if len(preview) > MAX_PREVIEW_CHARS: + preview = preview[:MAX_PREVIEW_CHARS].rstrip() + "…" + text = ( + f"⚠️ СРОЧНОЕ письмо\n\n" + f"От: {sender}\n" + f"Тема: {subject}\n\n" + f"{preview}\n\n" + f"Письмо: file://{path}" + ) + try: + result = tg_call("sendMessage", chat_id=TG_CHAT_ID, text=text, + link_preview_options={"is_disabled": True}) + msg_id = result.get("message_id") + return True, f"Отправлено в {TG_CHAT_ID} (msg_id={msg_id})" + except Exception as e: + return False, f"Telegram: {e}" + + +# --------------------------------------------------------------------------- +# Основной проход +# --------------------------------------------------------------------------- +def find_email_md_files(root, folder=None): + files = [] + for p in sorted(root.rglob("email.md")): + if folder: + rel = p.relative_to(root) + if not rel.parts[0] == folder: + continue + files.append(p) + return files + + +def email_sort_key(path): + """Свежие письма первыми.""" + import re as _re + parts = path.parts + year = next((int(p) for p in parts if _re.fullmatch(r"\d{4}", p)), 0) + month = next((int(p) for p in parts if _re.fullmatch(r"\d{2}", p) and 1 <= int(p) <= 12), 0) + uid = next((int(p) for p in parts if p.isdigit() and int(p) > 100), 0) + if not year: + try: + return (-float(path.stat().st_mtime),) + except OSError: + return (0,) + return (-year, -month, -uid) + + +HANDLERS = { + "urgent": handle_urgent, + "task": handle_task, + "meeting": handle_meeting, +} + + +def main(): + ap = argparse.ArgumentParser(description="Обработчики по тегам классификации писем") + ap.add_argument("--limit", type=int, default=0, help="Максимум писем за проход") + ap.add_argument("--folder", default=None, help="Только папка (INBOX)") + ap.add_argument("--dry-run", action="store_true", help="Не писать, только показать") + args = ap.parse_args() + + files = find_email_md_files(EMAIL_ROOT, args.folder) + pending = [] + for p in files: + headers, body, content, fm_end = parse_email_md(p) + cls = headers.get("classification", "") + if not cls: + continue + tags = [t.strip() for t in cls.split(",") if t.strip()] + need = [t for t in tags if t in HANDLERS and headers.get(f"handled_{t}") != "true"] + if need: + pending.append((p, headers, body, need)) + pending.sort(key=lambda x: email_sort_key(x[0])) + print(f"Обработать: {len(pending)}") + + if args.limit > 0: + pending = pending[:args.limit] + + results = {"urgent": 0, "task": 0, "meeting": 0, "errors": 0} + for p, headers, body, need in pending: + for tag in need: + handler = HANDLERS[tag] + try: + ok, detail = handler(p, headers, body) + if ok: + if not args.dry_run: + mark_handled(p, tag) + results[tag] += 1 + print(f" ✓ [{tag}] {p.parent.parent.parent.name}/{p.parent.name}: {detail}") + else: + results["errors"] += 1 + print(f" ✗ [{tag}] {p.parent.parent.parent.name}/{p.parent.name}: {detail}", file=sys.stderr) + except Exception as e: + results["errors"] += 1 + print(f" ✗ [{tag}] {p}: {e}", file=sys.stderr) + + print(f"\nГотово: urgent={results['urgent']}, task={results['task']}, meeting={results['meeting']}, ошибок={results['errors']}") + if args.dry_run: + print("(dry-run: ничего не записано и не отправлено)") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/mail_archive.py b/scripts/mail_archive.py index 82de047..a3ed60d 100755 --- a/scripts/mail_archive.py +++ b/scripts/mail_archive.py @@ -398,18 +398,34 @@ def make_email_md(meta, extra_headers, body, folder_name): def get_attachments(uid, folder, dest_dir): - """Скачать вложения письма в dest_dir.""" + """ + Скачать вложения письма в dest_dir. + + Спек email-attachments: правильный флаг — `--downloads-dir` (не `--dir`). + Идемпотентность: если в dest_dir уже есть файлы — не качаем повторно. + """ + try: + existing = list(dest_dir.iterdir()) if dest_dir.exists() else [] + if existing: + print(f" вложения уже скачаны ({len(existing)} ф.) — пропускаю") + return + except OSError: + pass + try: run_cmd( HIMALAYA_CMD + [ "attachment", "download", str(uid), "--folder", folder, - "--dir", str(dest_dir), + "--downloads-dir", str(dest_dir), ], timeout=60, ) - except RuntimeError: - pass # нет вложений — норм + except RuntimeError as e: + # Нет вложений / письмо не имеет вложений — норм для has_attachment=false. + # Но если письмо помечено has_attachment=true, а скачать не вышло — + # оставляем пустую папку и пишем warning (письмо не теряется). + print(f" [WARN] вложения не скачаны: {e}", file=sys.stderr) def archive_folder(folder, limit=100): @@ -454,10 +470,9 @@ def archive_folder(folder, limit=100): date_str = env.get("date") or env.get("internal_date") or "" year, month = parse_date(date_str) - # Путь: /mnt/yandex-disk/hermes/email//YYYY/MM/UID/ + # Путь: /opt/hermes/email//YYYY/MM/UID/ msg_dir = ARCHIVE_ROOT / folder / f"{year:04d}" / f"{month:02d}" / str(uid) email_path = msg_dir / "email.md" - attachments_dir = msg_dir / "attachments" # Проверка — уже сохранено if email_path.exists(): @@ -467,7 +482,13 @@ def archive_folder(folder, limit=100): continue msg_dir.mkdir(parents=True, exist_ok=True) - attachments_dir.mkdir(parents=True, exist_ok=True) + has_attachment = bool(env.get("has_attachment", False)) + # Папку attachments/ создаём ТОЛЬКО если у письма есть вложения + # (спек email-attachments: без вложений пустую папку не создаём). + attachments_dir = None + if has_attachment: + attachments_dir = msg_dir / "attachments" + attachments_dir.mkdir(parents=True, exist_ok=True) # Получаем заголовки и тело extra_headers, body = get_email_content(uid, folder) @@ -477,7 +498,8 @@ def archive_folder(folder, limit=100): email_path.write_text(content, encoding="utf-8") # Вложения - get_attachments(uid, folder, attachments_dir) + if attachments_dir is not None: + get_attachments(uid, folder, attachments_dir) subj = (env.get("subject") or "")[:60] print(f" ✓ UID {uid} ({subj})") diff --git a/vikunja/docker-compose.yml b/vikunja/docker-compose.yml deleted file mode 100644 index c18f601..0000000 --- a/vikunja/docker-compose.yml +++ /dev/null @@ -1,45 +0,0 @@ -# Vikunja — трекер задач (WebUI + REST API) -# Порт: 3456 (локально), наружу — Caddy: tasks.nixg.ru → 10.8.0.2:3456 -# Все данные — в локальных папках рядом с compose (НЕ named volumes): -# ./db — PostgreSQL (pgdata) -# ./files — загрузки Vikunja (аватары, вложения) -# Секреты: .env (VIKUNJA_DATABASE_PASSWORD, VIKUNJA_SERVICE_JWTSECRET) - -services: - db: - image: postgres:16-alpine - container_name: vikunja-db - restart: unless-stopped - environment: - POSTGRES_PASSWORD: ${VIKUNJA_DATABASE_PASSWORD} - POSTGRES_DB: vikunja - POSTGRES_USER: vikunja - volumes: - - ./db:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U vikunja -d vikunja"] - interval: 10s - timeout: 5s - retries: 5 - - vikunja: - image: vikunja/vikunja:latest - container_name: vikunja - restart: unless-stopped - depends_on: - db: - condition: service_healthy - environment: - VIKUNJA_DATABASE_TYPE: postgres - VIKUNJA_DATABASE_HOST: db - VIKUNJA_DATABASE_PORT: 5432 - VIKUNJA_DATABASE_DATABASE: vikunja - VIKUNJA_DATABASE_USERNAME: vikunja - VIKUNJA_DATABASE_PASSWORD: ${VIKUNJA_DATABASE_PASSWORD} - VIKUNJA_SERVICE_JWTSECRET: ${VIKUNJA_SERVICE_JWTSECRET} - VIKUNJA_SERVICE_FRONTENDURL: https://tasks.nixg.ru - VIKUNJA_SERVICE_PUBLICURL: https://tasks.nixg.ru - ports: - - "3456:80" - volumes: - - ./files:/app/vikunja/files \ No newline at end of file