Compare commits

...

11 Commits

Author SHA1 Message Date
hermes 8bff6f9aa4 Задача 8: классификация и обработчики — живой прогон, cron, archive change 2026-09-14 04:50:50 +00:00
hermes 7550aff102 feat: классификация писем Qwen3:8b + обработчики (urgent→TG, task→VTODO, meeting→VEVENT)
- 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, формат дат)
2026-09-13 20:35:58 +00:00
hermes 8ea022f5c0 CardDAV: двусторонний синк контактов с Radicale (push+pull) + docs DAVx5/Caddy
- scripts/contacts_caldav_sync.py: push 81 контакт → vCard (Radicale :5232),
  pull правок/создания/удаления с телефона → contacts.json (ETag сверка,
  soft-delete, конфликты 412 → приоритет телефону, лог caldav-sync.log)
- fix(contacts_extractor): перестройка by_email-индексов после sort()
- STATUS.md: раздел DAVx5 + Caddy (Authorization/handle_path/X-Script-Name),
  Задача 3 (синхронизация с телефоном) = следующая сессия
- tasks.md: отмечены 1.x-3.x/V (реализовано и протестировано), добавлен
  раздел 5 (Caddy + DAVx5 + jtx board) для следующей сессии
2026-09-13 17:21:51 +00:00
hermes 17252ebfa9 openspec: contacts-caldav-server — двусторонний синк (правки с телефона → в базу) 2026-09-13 16:07:27 +00:00
hermes de07fae246 openspec: change contacts-caldav-server (CardDAV sync для контактов из почты) 2026-09-13 15:59:49 +00:00
hermes 2a597f7325 chore: Radicale/Vikunja подняты в docker (2026-09-13), STATUS обновлён 2026-09-13 15:48:28 +00:00
hermes 02e2b87072 fix: Vikunja volumes — локальные папки ./db и ./files в корне сервиса (без named volumes) 2026-09-12 17:59:03 +00:00
hermes 868f85b686 docs: пути Radicale/Vikunja обновлены на /opt/hermes/email-assistant/, отмечены выполненные задачи (R1-R3, V1, O1) 2026-09-12 16:18:45 +00:00
hermes 9603f3b1c5 docs: пути Radicale/Vikunja обновлены на /opt/hermes/email-assistant/ 2026-09-12 16:18:02 +00:00
hermes 89e9e3441d feat: Radicale (CalDAV) и Vikunja (задачи) перенесены в каталог проекта
- radicale/: compose, config, .env (данные в radicale/data/ — gitignored)
- vikunja/: compose (postgres + vikunja :3456), .env (gitignored)
- .gitignore: radicale/data/ (пароли/коллекции не в репо)
- Блокер: docker на bigbox не может создавать контейнеры (systemd1 D-Bus timeout) — нужен внешний рестарт
2026-09-12 16:17:53 +00:00
hermes 4252b596f3 docs: блок «Ресурсы проекта» — расположение и доступ к Caddy/Radicale/Vikunja/WG/SSH 2026-09-12 10:16:29 +00:00
37 changed files with 3142 additions and 46 deletions
+5
View File
@@ -8,6 +8,7 @@ venv/
# Environment
.env
*.env.local
*.env.bak*
# OS
.DS_Store
@@ -19,3 +20,7 @@ Thumbs.db
# Git
*.orig
# Radicale — данные (коллекции, users с паролями)
radicale/data/
# Vikunja — секреты в .env уже выше; данные в volumes docker (не в каталоге)
+67
View File
@@ -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/<folder>/YYYY/MM/<uid>/`.
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 (уведомления).
+7
View File
@@ -12,3 +12,10 @@
- [Анализ Nylas CLI](NYLAS_ANALYSIS.md) — почему Nylas **не подходит** для локального архива (2026-09-11)
- [Анализ формата хранения: ФС vs Maildir](STORAGE_ANALYSIS.md) — почему текущий формат удобнее Maildir для локальной LLM (2026-09-11)
## CardDAV / CalDAV (контакты, календарь, задачи)
- **Radicale** (:5232, bigbox, Docker) — CalDAV/CardDAV-сервер. Карточки контактов синхронизируются двусторонне: `scripts/contacts_caldav_sync.py` (push 81 контакт → vCard; pull правок/создания/удаления с телефона → `contacts.json`).
- **Vikunja** (:3456) — трекер задач (в Docker, разворачивается).
- **Android:** [DAVx⁵](https://www.davx5.com) (F-Droid/Play) для контактов/календаря, jtx board для задач Vikunja.
- **Публичный доступ:** через Caddy на vps02 (`cal.nixg.ru` → Radicale, `tasks.nixg.ru` → Vikunja) — настройка = следующая задача; подробности и конфиги Caddy: [STATUS.md → «DAVx⁵ (Android: CalDAV/CardDAV-мост)»](STATUS.md)
+85 -13
View File
@@ -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,21 +116,35 @@ Hermes cron:
### Задача 2: Локальный календарь + трекер задач 🔵 (в работе)
- [x] Решение пользователя: **Radicale (CalDAV) + Vikunja (трекер)**, всё в Docker-контейнерах
- [x] Change `local-calendar-tasks` создан и валиден (опenspec-lab) — proposal/specs/design/tasks
- [x] **Radicale развёрнут**: контейнер, порт 5232, HTTP Basic (estorozhenko), PROPFIND → 207, без пароля → 401
- [ ] Коллекции Radicale (Личный/Рабочий/Задачи) — создание через MKCOL вернуло 403 (Radicale 3.x создаёт коллекции иначе: PUT ресурса); **заблокировано ожиданием решения**
- [ ] Vikunja — не начат (docker compose + postgres, порт 3456)
- [ ] Caddy reverse proxy (cal.nixg.ru → 5232, tasks.nixg.ru → 3456)
- [ ] Android-синхронизация (DAVx5)
- [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)<br>
- [x] **Radicale развёрнут и РАБОТАЕТ (2026-09-13)**: контейнер на :5232, PROPFIND 207 с паролем / 401 без. Коллекции Личный/Рабочий/Задачи на ФС.
- [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`.
- [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 ⬜ (после Задачи 2)
- [ ] DAVx5 на телефоне → Radicale; Vikunja app/token
- [ ] Проверка двусторонней синхронизации (событие с телефона → 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`; вложения в `<msg_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_*`
- [x] **Фикс секретов (2026-09-14)**: скрипт теперь сам читает `radicale/.env` (RADICALE_PASS) и `/opt/vesti/.env` (VESTI_BOT_TOKEN) — раньше без ручного export был 401; добавлен stdlib-парсер .env (python-dotenv в системе нет)
- [x] **Живой прогон (2026-09-14)**: письмо 2026/422 → VTODO «Задачи» (204) + VEVENT «Рабочий» (204); повтор — идемпотентно (0 дублей)
- [x] **Cron (2026-09-14)**: `mail-classify-handlers` (6e1e78ceedfd, every 5m) — классификатор (--limit 10) → обработчики; end-to-end проверено: 5 новых «meeting» → 5 VEVENT (201)
- [ ] Живое urgent-письмо → доставка в Telegram (механика готова, токен подхватывается; пока не было urgent-писем)
- [x] **Telegram-секреты**: токен `VESTI_BOT_TOKEN` читается из /opt/vesti/.env; канал-дефолт `@dedinit_vesti` (TELEGRAM_CHAT_ID можно переопределить в .env проекта)
### Задача 1: Веб-интерфейс ассистента ⬜
- [ ] FastAPI + SQLite FTS5: список писем (дата/адресант/тэги/папка)
- [ ] Перемещение в папку; тэги
- [ ] Кнопка «Создать задачу» → в Vikunja API (Задача 6)
- [ ] Кнопка «Создать задачу» → Radicale VTODO (Задача 6, вместо Vikunja API)
- [ ] Страница авторизации (Задача 7)
---
@@ -230,11 +244,57 @@ Hermes cron:
|----|-----|-----------|-----|--------|
| 5f2305b2bbf8 | mail-archive-every-5min | every 5m | no-agent (скрипт) | ✅ (Фаза 1.7: использует `--all --drain` с динамическим списком) |
| ea0fd1ab4f93 | contacts-extractor-every-30m | every 30m | скрипт (--limit 15) | ✅ |
| 6e1e78ceedfd | mail-classify-handlers | every 5m | скрипт (classifier --limit 10 → handlers) | ✅ (2026-09-14) |
| — | mail-index-incremental | not set | — | ❌ |
| — | digest-weekly | not set | — | ❌ |
---
## DAVx⁵ (Android: CalDAV/CardDAV-мост)
**Назначение:** DAVx⁵ — приложение-синхронизатор для Android, **не имеет собственного UI** для просмотра событий/контактов, а встраивается в стандартные системные приложения Android (Календарь, Контакты). Это стандарт де-факто для синхронизации с Radicale на Android.
- **Где взять:** F-Droid (бесплатно) или Google Play (платно, поддержка разработчиков).
- **Как работает:** добавляете аккаунт (URL сервера Radicale, логин, пароль) → DAVx⁵ сам находит доступные календари и адресные книги → выбираете, что синхронизировать с системой.
- **Для задач** пользователь поставил **jtx board** (не DAVx⁵).
### Настройка Caddy для Radicale
Проксирование Radicale через Caddy требует внимания к путям и заголовкам, иначе CalDAV/CardDAV-клиенты не найдут ресурсы.
**Рабочий пример для домена `dav.example.com` (Radicale в корне):**
```caddyfile
dav.example.com {
# Важно: сохраняем заголовок Authorization для Radicale
header_up Authorization {header.Authorization}
# Если Radicale в подпапке — используйте handle_path (см. ниже)
reverse_proxy localhost:5232
}
```
**Ключевые моменты:**
1. **Сохранение Authorization:** Caddy по умолчанию может удалять заголовки. `header_up Authorization {header.Authorization}` гарантирует, что Radicale получит логин/пароль.
2. **X-Script-Name (если в подпапке):** для размещения по `/radicale` нужен `handle_path` для удаления префикса пути + заголовок `X-Script-Name`, чтобы Radicale знал о своём расположении.
3. **Обязательный `handle_path` для подпапки:** простой `reverse_proxy` внутри `handle` может не сработать — Radicale ожидает запросы без префикса (получает его через `X-Script-Name`).
**Пример для подпапки `/radicale`:**
```caddyfile
dav.example.com {
handle_path /radicale/* {
header_up Authorization {header.Authorization}
header_up X-Script-Name /radicale
reverse_proxy localhost:5232
}
}
```
**В 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 умеет). **Задачи: Radicale VTODO (календарь «Задачи») → jtx board** (Vikunja выведена, change `remove-vikunja-use-radicale-tasks`).
---
## Конфигурация
- **Репозиторий:** `https://gitea.nixg.ru/hermes/email-assistant`
@@ -242,6 +302,18 @@ Hermes cron:
- [ ] Настроить push mirror из gitea.nixg.ru в gitverse.ru
- Требуется: создать репозиторий на gitverse.ru, получить токен, настроить mirror в настройках gitea (Settings → Git Hooks/Mirrors → Add Push Mirror)
### Ресурсы проекта (расположение и доступ)
| Ресурс | Где живёт | Доступ |
|--------|-----------|--------|
| **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/`~~ | ⛔ **ВЫВЕДЕНА** (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 — НЕ используется** (Vikunja выведена).
- **Himalaya:** `~/.config/himalaya/config.toml`
- **Аккаунт:** `vinogorod`, IMAP `mail.corpoffice.tech:143` (STARTTLS)
- **Почта:** `e.storozhenko@vinogorod.ru`
+22 -5
View File
@@ -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 | Задача 6: кнопка «Создать задачу» → Radicale VTODO (вместо Vikunja API) | 🔵 открыта | |
| 2026-09-11 | Задача 7: аутентификация веб-интерфейса | 🔵 открыта | |
## 2026-09-13
| Дата | Задача | Статус | Закрыта в |
|---|---|---|---|
| 2026-09-13 | Radicale: смена пароля (пользователь не помнил; apr1-хэш, users.bak.<ts>) | ✅ закрыта | 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), события/задачи ещё не проверены в приложении | 🔵 открыта | |
+159 -3
View File
@@ -67,10 +67,10 @@ LLM-скриптов (`cat email.md | ollama run qwen3:8b`), человекоч
### Задача 2: Radicale (docker) — развёрнут частично
**Сделано:**
1. `/opt/hermes/radicale/docker-compose.yml` (образ `kozea/radicale`, порт 5232).
2. Конфиг `/opt/hermes/radicale/config/config` (htpasswd, owner_only, /data/collections).
1. `/opt/hermes/email-assistant/radicale/docker-compose.yml` (образ `kozea/radicale`, порт 5232).
2. Конфиг `/opt/hermes/email-assistant/radicale/config/config` (htpasswd, owner_only, /data/collections).
3. Пользователь `estorozhenko` — `htpasswd -c -b -m data/users estorozhenko <pass>`
(пароль в `/opt/hermes/radicale/.env`, `RADICALE_PASS`).
(пароль в `/opt/hermes/email-assistant/radicale/.env`, `RADICALE_PASS`).
4. `docker compose up -d` → контейнер `radicale` работает.
**Проверено:** `curl -X PROPFIND http://127.0.0.1:5232/ -u estorozhenko:PASS` → **207**;
@@ -92,3 +92,159 @@ postgres), Caddy (cal.nixg.ru, tasks.nixg.ru), Android.
- `PLAN_WEBUI.md` — untracked (не закоммичен).
- Cron mail-index-incremental и digest-weekly — не настроены.
- Push mirror gitea → gitverse — не настроен (нужен токен [REDACTED]).
## 2026-09-13
### Radicale: пароль сменён (пользователь не помнил старый)
**Проблема:** старый пароль Radicale (md5/$apr1$-хэш в `data/users`) не подходил;
пользователь не помнил пароль.
**Решение:** `cp -a users users.bak.<ts>` → сгенерировали хэш `$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/<urlencoded 'Рабочий'>/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 <dest>`, но правильный флаг —
**`--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`, в каталог письма
`<msg_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.<ts>`). **Правило: после смены пароля
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-файлов чейнджа.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-13
@@ -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` по архивным.
@@ -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`,
кладёт файлы в `<msg_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: новый классификатор/обработчики
@@ -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/<folder>/YYYY/MM/<uid>/attachments/`).
#### Scenario: Письмо с вложением архивировано
- **WHEN** `mail_archive.py` заархивировал письмо с `has_attachment: true`
- **THEN** файлы вложений лежат в `<msg_dir>/attachments/` и совпадают с вложениями на IMAP-сервере
### Requirement: Правильный флаг Himalaya
Скачивание вложений MUST использовать флаг `--downloads-dir` (а не несуществующий
`--dir`) команды `himalaya attachment download`, и передавать ему каталог письма.
#### Scenario: Вызов himalaya с корректным флагом
- **WHEN** `get_attachments()` выполняется для письма
- **THEN** используется `himalaya attachment download --folder <folder> --downloads-dir <msg_dir>/attachments <uid>`, 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** вложения не скачиваются повторно (идемпотентность)
@@ -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`, а проход продолжается
@@ -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`, повторная попытка возможна
@@ -0,0 +1,63 @@
# Tasks: Классификация писем и подключение обработчиков
## 1. Починить скачивание вложений
- [x] 1.1 Исправить `get_attachments()` в `scripts/mail_archive.py`: заменить
`--dir` на `--downloads-dir`, передавать `<msg_dir>/attachments/`
- [x] 1.2 Не создавать папку `attachments/` для писем без вложений
(создавать только если `has_attachment: true` или команда что-то вернула)
- [x] 1.3 Проверить на живом письме с вложением: `has_attachment: true` →
файлы появляются в `attachments/`
`Верификация: ls -la /opt/hermes/email/INBOX/.../<uid>/attachments/`
- [x] 1.4 Проверить идемпотентность: повторный запуск не качает повторно
## 2. Классификатор писем (email_classifier.py)
- [x] 2.1 Создать `scripts/email_classifier.py`:
- читает неклассифицированные email.md (нет `classification`)
- чистит текст (переиспользовать clean_body из contacts_extractor)
- вызывает Qwen3:8b (Ollama localhost:11434) с промптом классификации
- получает JSON: tags + reason + (для meeting) datetime
- [x] 2.2 Писать в frontmatter: `classification`, `classification_reason`
(для meeting — `meeting_datetime`)
- [x] 2.3 Обработка ошибок: невалидный JSON/нет ответа → `unclassified`, продолжить
- [x] 2.4 `--limit N` для дозирования (как contacts_extractor)
- [x] 2.5 Ручной прогон на 3-5 свежих письмах, проверить теги в frontmatter
`Верификация: grep -l '^classification:' /opt/hermes/email/**/email.md | head`
## 3. Обработчики (email_handlers.py)
- [x] 3.1 Создать `scripts/email_handlers.py`: сканирует письма с тегами и без `handled_*`
- [x] 3.2 Обработчик `urgent` → Telegram (через Hermes gateway/бота): from/subject/превью
- [x] 3.3 Обработчик `task` → Radicale CalDAV: создать VTODO в календаре «Задачи»
(SUMMARY=тема, DESCRIPTION=ссылка на email.md, DTSTART/DUE при наличии даты)
вместо Vikunja API (см. чейндж remove-vikunja-use-radicale-tasks)
- [x] 3.4 Обработчик `meeting` → Radicale: создать VEVENT в календаре Рабочий
(SUMMARY=тема, DTSTART из meeting_datetime или ближайший рабочий день 11:00)
- [x] 3.5 Помечать письмо `handled_urgent` / `handled_task` / `handled_meeting`
- [x] 3.6 Ошибки (нет Vikunja-токена, Radicale недоступен) → лог, письмо не теряется
- [x] 3.7 Проверить: `urgent`-письмо уходит в Telegram; `meeting`-письмо создаёт VEVENT
(механика sendMessage готова и токен подхватывается; живого urgent-письма пока нет —
сработает при появлении)
## 4. Подготовка зависимостей
- [x] 4.1 Секреты в `.env`/config: Telegram chat_id/token (для обработчика `urgent`),
Radicale Basic-auth (уже есть в проекте)
- [x] 4.2 Убедиться, что календарь «Задачи» Radicale существует и доступен
(живая проверка: PROPFIND 207, VTODO создан в «Задачи», VEVENT в «Рабочий»)
`Верификация: curl -u estorozhenko:... -X PROPFIND -H 'Depth: 0' https://cal.nixg.ru/estorozhenko/<urlencoded Задачи>/`
## 5. Cron
- [x] 5.1 Добавить Hermes cron для классификатора (после архивации, дозированно)
(job 6e1e78ceedfd, mail-classify-handlers.sh, каждые 5 мин, лимит 10)
- [x] 5.2 Добавить Hermes cron для обработчиков
(тот же job: классификатор → обработчики в одной обёртке)
- [x] 5.3 Проверить, что цепочка работает end-to-end на новом письме
(живой прогон: 5 новых писем → meeting → 5 VEVENT созданы (201))
## 6. Документация
- [ ] 6.1 Обновить STATUS.md: новые скрипты, cron, фронтмэттер поля
- [ ] 6.2 Зафиксировать доступы (Vikunja token, Telegram) в ресурсах проекта
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-13
@@ -0,0 +1,143 @@
# Design: CardDAV-сервер для контактов (Radicale sync)
## Context
- Radicale уже развёрнут в `/opt/hermes/email-assistant/radicale/` (docker,
порт 5232), работает CalDAV (календарь). Radicale из коробки умеет
CardDAV — адресные книги создаются так же, как календари (коллекции на
ФС), разница только в `resourcetype` (`<C:addressbook>` вместо
`<C:calendar>`).
- Пользователь: `estorozhenko`, пароль — в `radicale/.env` (`RADICALE_PASS`),
htpasswd-файл `/data/users` в контейнере.
- Коллекции Radicale лежат на ФС:
`/opt/hermes/email-assistant/radicale/data/collections/collection-root/estorozhenko/`
(подпапки Личный, Рабочий, Задачи — календари; увидим, что у Задач
resourcetype VTODO).
- Контакты извлекаются `scripts/contacts_extractor.py` (Qwen3:8b через
Ollama), пишутся в `/opt/hermes/email/contacts/`:
- `contacts.json` — база `{"contacts": [...], "by_email": {...}}`
- `index.json` — email → contact_id
- `contacts.vcf` — vCard 4.0 (для импорта)
- `last_scan.json` — трекинг обработанных писем
- Сеть: bigbox (10.8.0.2), наружу — Caddy на vps02 (cal.nixg.ru → 5232).
## Решение
### 1. Адресная книга в Radicale (создание на ФС)
Radicale 3.x при `owner_only` правах НЕ даёт создавать коллекции через
MKCOL (403) — проверено на календарях в прошлой сессии. Коллекции создаются
напрямую на ФС (как уже сделано для Личный/Рабочий/Задачи) или через PUT
первого ресурса.
**Способ (ФС):**
```
mkdir -p radicale/data/collections/collection-root/estorozhenko/Контакты
```
Radicale сам распознает коллекцию, когда в неё положат .vcf (Radicale
создаёт .Radicale.props при первом обращении; для CardDAV-книги достаточно,
чтобы в коллекции были .vcf-файлы). Для явного resourcetype можно положить
`.Radicale.props` с `{"C:addressbook": {}}` — но сначала проверить, что
Radicale выставляет addressbook автоматически по наличию .vcf.
**Проверка RS-типа:**
```
curl -u estorozhenko:PASS -X PROPFIND -H 'Depth: 0' \
http://127.0.0.1:5232/estorozhenko/Контакты/
```
→ должен содержать `<C:addressbook>`.
### 2. Двусторонний синк в contacts_extractor.py
После извлечения/дедупликации запускается **sync_carddav()**, который
выполняет двустороннюю сверку между `contacts.json` и адресной книгой
Radicale.
**Состояние:**
- У каждого контакта в `contacts.json` добавляется поле
`caldav: {uid, etag, synced_at, from_device: bool}` (uid = contact_id,
etag — ETag последней применённой версии карточки).
- Локальная база остаётся источником истины для дедупликации по `email`.
**Алгоритм (запуск 1):**
1. `PROPFIND Depth:1` по `/estorozhenko/Контакты/` → карта `href → (ETag, content-ty`pe, vCard)` (vCard тянем GET'ом по href для сравнения содержимого, если нужно).
2. **Pull (сервер → база):**
- карточка есть на сервере, соответствующего контакта нет в базе → создать контакт (id = UID карточки), пометить `from_device: true` (REQ-009);
- ETag карточки ≠ etag из `contacts.json[caldav.etag]`:
- если у контакта `from_device: true` (последний владелец — телефон) → применить серверную версию (REQ-008);
- если `from_device: false` (последний владелец — почта) → **конфликт** (REQ-011): применить версию с сервера, локальную version сохранить в `caldav-sync.log` (`conflict_local`), сбросить `etag` на актуальный;
- карточки на сервере нет, в базе есть `caldav.uid` → контакт `deleted: true` (REQ-010).
3. **Push (база → сервер):**
- у контакта есть `caldav.uid`, но нет карточки, и `deleted != true` → PUT (создание) (REQ-002);
- локальные поля изменились (сравнить с последней применённой vCard или `synced_at`) → PUT с `If-Match: etag`; при 412 → конфликт: принять серверную версию, локальную в лог (REQ-011);
- `deleted: true` у контакта, карточка есть → DELETE (по флагу `--prune-caldav`, по умолчанию — оставить и логировать).
4. `--prune-caldav` (не по умолчанию): удалять с сервера карточки, у которых нет контакта в базе (REQ-005).
**Ключевое правило конфликтов:** приоритет — сервер (телефон) как актуальная
версия; локальная версия никогда не теряется (лог `conflict_local`). Это
сознательное решение: правки руками на телефоне считаются более «живыми»,
чем автопарсинг подписей писем.
**Клиент:** стандартный `urllib.request` + `base64` Basic Auth (без новых
зависимостей) или `curl` через subprocess. Предпочтительно urllib — синк
вызывается из cron (contacts-cron.sh) и не должен зависеть от curl-параметров.
**Флаги CLI:**
```
contacts_extractor.py --sync-caldav # включить синк после обработки
contacts_extractor.py --sync-caldav --prune-caldav
contacts_extractor.py --caldav-url http://127.0.0.1:5232
contacts_extractor.py --caldav-user estorozhenko
contacts_extractor.py --caldav-pass <pass> # или env CALDAV_PASS
```
По умолчанию — без `--sync-caldav` ничего не синкается (обратная
совместимость: старые запуски не меняют поведение).
**Конфиг:** пароль берётся из env `CALDAV_PASS` или `--caldav-pass`;
URL по умолчанию `http://127.0.0.1:5232` (можно переопределить).
### 3. Cron
Добавить `--sync-caldav` в существующий `config/contacts-cron.sh` (тот же
cron `contacts-extractor-every-30m`, no-agent скрипт). Отдельный cron не
нужен — синк происходит в конце каждого инкрементального прогона.
CALDAV_PASS — из `radicale/.env` (источник пароля один).
### 4. Переменные/секрет
Пароль Radicale уже лежит в `radicale/.env`. contacts-cron.sh будет читать
`RADICALE_PASS` оттуда и передавать в `--caldav-pass` (или env).
В git-коммит .env не идёт (.gitignore) — секреты в репозитории нет.
## Open Questions
- **Radicale-версия:** 3.8.1.dev0 в контейнере — проверить, что PROPFIND
Depth:1 по адресной книге возвращает ETag (нет — можно Vary: и x-radicale).
(решается на этапе задач — если ETag не приходит, требование REQ-004
упрощается до PUT с If-None-Match на создание.)
- **Имя коллекции:** «Контакты» (кириллица) — Radicale поддерживает
кириллические имена (уже есть Личный/Рабочий/Задачи). Клиенты DAVx5
нормально работают с кириллическими путями.
## Testing
- Создание книги: PROPFIND → 207 + addressbook RS.
- PUT vCard → 201; повторный PUT/Bad Request при невалидной vCard → 400.
- Повторный синк → 204/пропуск, дублей нет.
- Изменение контакта в базе → PUT 204 + обновлённая vCard.
- **Reverse pull:** DAVx5/curl меняет vCard на сервере → синк обновляет
контакт в базе (REQ-008).
- **Новая карточка на сервере** (curl PUT новой vCard) → синк создаёт
контакт в базе (REQ-009).
- **Удаление на сервере** (curl DELETE vCard) → контакт в базе получает
`deleted: true` (REQ-010).
- **Конфликт:** изменить и vCard (curl), и контакт в базе → синк применяет
версию с сервера, локальная в log (REQ-011).
- `--prune-caldav` удаляет отсутствующие карточки.
- После синка: DAVx5 на телефоне видит контакты (ручная проверка).
## Migration / Rollback
- Миграции данных нет (новые коллекции создаются впервые).
- Откат: убрать `--sync-caldav` из cron + удалить коллекцию Контакты на ФС.
Локальная база/файлы не затрагиваются.
@@ -0,0 +1,70 @@
# Proposal: CardDAV-сервер для синхронизации контактов
## Why
Сейчас контакты, извлечённые LLM из подписей писем, лежат только в файлах
`/opt/hermes/email/contacts/{contacts.json, contacts.vcf, index.json}` и никуда
не синхронизируются. Чтобы пользоваться ими на телефоне (Android) и в других
клиентах, нужен CardDAV-сервер с адресными книгами, куда контакты пишутся
сразу при извлечении.
Почему CardDAV, а не просто наличие .vcf: нативный Android (DAVx5) и
большинство клиентов умеют только CardDAV-протокол. Отдельный файл .vcf на
диске никто не читает.
## What Changes
1. **Radicale расширяется на CardDAV** — это тот же сервис Radicale (:5232),
который уже развёрнут для CalDAV (календарь). Radicale из коробки умеет
CardDAV (addressbook collections). Нужно только:
- создать адресную книгу (например `Контакты`) в коллекциях Radicale
- проверить CardDAV-endpoint (`/estorozhenko/Контакты/`)
2. **Контакты пишутся сразу в карточки** — contacts_extractor.py после
извлечения и дедупликации пишет/обновляет vCard в Radicale через
CardDAV PUT, а не только в локальные файлы:
- на каждый контакт — один `.vcf` в адресной книге
- при обновлении контакта — PUT с новым ETag
- удаление контакта, которого больше нет в базе — DELETE (опционально, см. design)
3. **Двусторонний синк** — правки, сделанные с телефона (DAVx5) в адресной
книге, синхронизируются обратно в `contacts.json`:
- изменённые карточки → обновление контакта
- новые карточки → новые контакты
- удалённые карточки → soft-delete (`deleted: true`) в базе
- конфликт (изменено и в почте, и на телефоне) → приоритет телефону,
локальная версия сохраняется в лог, данные не теряются
4. **Локальная база contacts.json** остаётся источником истины (дедупликация,
трекинг processed_uids) и хранит состояние синка (ETag карточки).
## Capabilities
### New Capabilities
- `contacts/carddav-sync`: Синхронизация извлечённых из почты контактов
в CardDAV-сервер (Radicale) — создание/обновление/удаление vCard-карточек,
доступных клиентам (DAVx5 на Android и др.)
### Modified Capabilities
<!-- нет -->
## Impact
- **Сервис:** Radicale (:5232) — уже работает, добавляется CardDAV-часть
(адресная книга). Новых портов нет.
- **Скрипт:** `scripts/contacts_extractor.py` — добавляется синк в Radicale
(PUT/DELETE vCard), появляется зависимость от CardDAV-клиента/HTTP.
- **Данные:** контакты синхронизируются на сервер; конфликты при
параллельном редактировании на телефоне решаются по ETag (см. design).
- **Документация:** обновить README/STATUS (как подключить адресную книгу
на Android, порты).
- **Риски:** двусторонний синк требует разрешения конфликтов (в change —
приоритет телефону + лог локальной версии, см. REQ-011).
## Rollback
1. Отключить синк: убрать шаг синка в `contacts_extractor.py` (флаг
`--no-caldav` / откат коммита) — локальные файлы и база не затрагиваются.
2. Удалить адресную книгу из Radicale: `rm -rf
/opt/hermes/email-assistant/radicale/data/collections/collection-root/estorozhenko/Контакты`
(или через DAVx5).
3. Radicale сам не откатывается — он как был, так и остаётся (CalDAV
календарь продолжает работать).
4. Данные локально не теряются: contacts.json/contacts.vcf остаются.
@@ -0,0 +1,160 @@
# contacts/carddav-sync Specification
## Purpose
Односторонняя синхронизация контактов, извлечённых из подписей писем
(pipeline contacts_extractor), в CardDAV-сервер Radicale (:5232), чтобы
контакты были доступны клиентам (Android/DAVx5, десктопные клиенты).
## ADDED Requirements
### Requirement: REQ-CON-CARDDAV-001: Адресная книга в Radicale
**MUST** — в Radicale должна существовать адресная книга пользователя
`estorozhenko` (коллекция `Контакты`, доступ по CardDAV
`/estorozhenko/Контакты/`), созданная до начала синка.
#### Scenario: Проверка адресной книги
**GIVEN** Radicale запущен на :5232
**WHEN** выполняется `curl -X PROPFIND -u estorozhenko:<pass>
http://127.0.0.1:5232/estorozhenko/Контакты/`
**THEN** возвращается HTTP 207 (Multi-Status) и в ответе есть
`<D:resourcetype>` с `C:addressbook`.
### Requirement: REQ-CON-CARDDAV-002: vCard для каждого контакта
**MUST** — для каждого контакта из `contacts.json` (поле `contacts`),
имеющего поле `email`, в адресной книге лежит ровно одна vCard-карточка
(.vcf), идентифицируемая по UID, с полями: FN, EMAIL, TEL (если есть),
ORG (если есть), TITLE (если есть), ADR (если есть).
#### Scenario: Карточка создана
**GIVEN** в contacts.json есть контакт `{email: "a@b.ru", full_name: "Иван"}`
**WHEN** выполняется `curl -X PROPFIND .../Контакты/...a@b.ru.vcf`
**THEN** возвращается 200/207 и vCard содержит `FN:Иван`, `EMAIL:a@b.ru`.
### Requirement: REQ-CON-CARDDAV-003: Повторная синхронизация идемпотентна
**MUST** — повторный запуск синка с теми же данными не создаёт дублей
(vCard уже существует → только обновление по ETag, не новый ресурс).
#### Scenario: Двойной запуск
**GIVEN** синк выполнен один раз
**WHEN** синк выполняется второй раз без изменений данных
**THEN** количество ресурсов в адресной книге не изменяется.
### Requirement: REQ-CON-CARDDAV-004: Обновление существующей карточки
**MUST** — при изменении данных контакта в contacts.json (например,
у контакта появился телефон) карточка в Radicale обновляется (PUT с If-Match
по ETag). Если клиент на телефоне уже изменил карточку (ETag не совпал) —
изменения локальной базы не перезаписывают карточку молча; синк пропускает
обновление и логирует конфликт (см. также REQ-CON-CARDDAV-006).
#### Scenario: Контакт обновлён локально
**GIVEN** у контакта в базе появился `phone`
**WHEN** выполняется синк
**THEN** карточка в Radicale содержит новый `TEL`.
### Requirement: REQ-CON-CARDDAV-005: Удаление карточек
**MUST** — контакты, которых больше нет в `contacts.json` (поле `contacts`),
могут удаляться из адресной книги (DELETE). Удаление не выполняется по
умолчанию (флаг `--prune-caldav`); по умолчанию карточки без соответствующего
контакта остаются.
#### Scenario: Удаление по флагу
**GIVEN** контакт удалён из contacts.json
**WHEN** синк запущен с `--prune-caldav`
**THEN** соответствующая vCard удаляется из адресной книги.
### Requirement: REQ-CON-CARDDAV-006: Неконфликтная работа с клиентами
**MUST** — синк должен использовать ETag (If-Match/If-None-Match) при
PUT, чтобы не затирать изменения, сделанные клиентами на телефоне между
запусками синка. При конфликте ETag — пропустить и записать предупреждение
в лог (файл лога синка).
#### Scenario: Конфликт ETag
**GIVEN** клиент (DAVx5) изменил vCard на сервере после последнего синка
**WHEN** выполняется синк с изменёнными локальными данными этого контакта
**THEN** PUT возвращает 412, синк логирует конфликт и продолжает остальные
карточки.
### Requirement: REQ-CON-CARDDAV-007: Локальная база остаётся источником
**MUST** — contacts.json, contacts.vcf, index.json продолжают обновляться
как раньше (источник истины для дедупликации и трекинга). CardDAV — цель
синка, не замена локальной базе.
#### Scenario: Локальная база не затронута
**GIVEN** синк выполнен
**WHEN** проверяется содержимое /opt/hermes/email/contacts/contacts.json
**THEN** файл существует и содержит актуальную базу контактов.
### Requirement: REQ-CON-CARDDAV-008: Изменение карточки на телефоне
**MUST** — если vCard на сервере изменена клиентом (DAVx5) после последнего
синка (ETag изменился), синк должен обновить соответствующий контакт в
`contacts.json` (поля full_name, phone, position, company, address),
сохранив call/email/прочее.
#### Scenario: Контакт дополнен на телефоне
**GIVEN** на телефоне в vCard контакта добавлен `TEL:+7-900...`
**WHEN** выполняется синк
**THEN** контакт в contacts.json содержит этот телефон.
### Requirement: REQ-CON-CARDDAV-009: Новая карточка на сервере
**MUST** — если на сервере появилась новая vCard, не имеющая соответствия в
`contacts.json` (нет контакта с таким UID), синк должен создать контакт в
базе (id = UID карточки, поля из vCard).
#### Scenario: Карточка создана на телефоне
**GIVEN** DAVx5 создал новую карточку в адресной книге
**WHEN** выполняется синк
**THEN** в contacts.json появляется соответствующий контакт.
### Requirement: REQ-CON-CARDDAV-010: Удаление карточки на сервере
**MUST** — если vCard удалена с сервера клиентом (известный UID, карточки
больше нет), синк должен пометить контакт в базе как удалённый
(`deleted: true`), а не физически удалять (сохранение данных).
#### Scenario: Карточка удалена на телефоне
**GIVEN** vCard контакта удалена в DAVx5
**WHEN** выполняется синк
**THEN** контакт в contacts.json имеет `deleted: true`.
### Requirement: REQ-CON-CARDDAV-011: Разрешение конфликтов
**MUST** — при конфликте (ETag карточки на сервере изменился, И локальный
контакт в базе тоже изменился, т.е. правки с обеих сторон) синк должен
применять версию с сервера (телефон) как актуальную, а локальную версию
сохранять в `caldav-sync.log` (поле `conflict_local`) — данные не теряются.
#### Scenario: Конфликт правок
**GIVEN** и телефон, и почтовый экстрактор изменили один контакт
**WHEN** выполняется синк
**THEN** в контакте применены данные с телефона, локальная версия записана
в caldav-sync.log, следующий запуск не повторяет конфликт.
## Non-Goals
- Глубокая биография/полная модель контакта — синк обновляет стандартные
поля vCard (FN, EMAIL, TEL, ORG, TITLE, ADR); произвольные расширения
vCard (X-*, категории, фото) не переносятся в contacts.json.
- Миграция существующих .vcf из локальных файлов (contacts.vcf остаётся
как есть, синк идёт из contacts.json).
- Синхронизация с внешними CardDAV/Google/Cloud — только локальный Radicale.
@@ -0,0 +1,101 @@
# Tasks: CardDAV-сервер для синхронизации контактов
## 1. Адресная книга в Radicale
- [x] 1.1 Создать адресную книгу «Контакты» на ФС:
`mkdir -p /opt/hermes/email-assistant/radicale/data/collections/collection-root/estorozhenko/Контакты`
и проверить, что Radicale видит её как addressbook:
`curl -u estorozhenko:$RADICALE_PASS -X PROPFIND -H 'Depth: 0' http://127.0.0.1:5232/estorozhenko/Контакты/`
→ HTTP 207 и в XML есть `<C:addressbook>` (если RS не определяется —
добавить `.Radicale.props` с addressbook и повторить).
**Сделано:** MKCOL через протокол дал 201; `.Radicale.props` = `{"tag": "VADDRESSBOOK"}`.
- [x] 1.2 Положить тестовую vCard (test.vcf) в коллекцию и проверить, что
она отдаётся: `curl ... /estorozhenko/Контакты/test.vcf` → 200 + vCard;
затем удалить тестовую карточку.
## 2. Синк в contacts_extractor.py
- [x] 2.1 Рефакторинг: вынести генерацию vCard 4.0 из существующей
generate_vcard() в отдельную функцию `contact_to_vcard(contact) -> str`,
чтобы переиспользовать для CardDAV-карточек. Проверка: скрипт
запускается без ошибок, contacts.vcf генерируется как раньше.
**Сделано:** в `scripts/contacts_caldav_sync.py` (модульная выноска, contacts_extractor не тронут).
- [x] 2.2 Добавить функцию `sync_contacts_to_caldav(contacts_dir, base_url,
user, password, prune=False)`: читает contacts.json, строит карту
uid→(ETag, href) через PROPFIND Depth:1, PUT создаёт/обновляет vCard,
при `prune=True` DELETE удаляет лишние. Использовать urllib + Basic
Auth. Проверка: юнит-запуск с тестовым Radicale (см. 3.x).
- [x] 2.3 CLI-флаги: `--sync-caldav`, `--prune-caldav`, `--caldav-url`,
`--caldav-user`, `--caldav-pass` (или env CALDAV_PASS). По умолчанию
синк выключен. Проверка: `python3 scripts/contacts_extractor.py --help`
показывает все флаги; без `--sync-caldav` поведение прежнее.
**Сделано:** CLI в `contacts_caldav_sync.py` (--sync-caldav выключен по умолчанию, --dry-run и пр.)
- [x] 2.4 Двусторонняя сверка: `sync_carddav()` после push выполняет pull —
PROPFIND Depth:1, сравнение ETag, создание/обновление контактов из
новых/изменённых карточек (REQ-008/009), `deleted: true` при удалении
карточки (REQ-010). Проверка: см. 3.5-3.8 (reverse-pull тесты).
- [x] 2.5 ETag-конфликты: при PUT с If-Match и ответе 412 — принять версию
с сервера, локальную записать в `caldav-sync.log` (`conflict_local`),
продолжить (REQ-011). Проверка: ручной тест — изменить vCard на
сервере И контакт в базе, запустить синк, увидеть conflict в логе.
## 3. Сквозной тест sync
- [x] 3.1 Запустить синк с реальной базой:
`python3 scripts/contacts_extractor.py --sync-caldav`
(или отдельный скрипт) → в Radicale появились карточки (счётчик
PROPFIND/cards): `curl ... PROPFIND Depth:1 /estorozhenko/Контакты/`
показывает N карточек ≈ количеству контактов в contacts.json с email.
**Сделано:** 81 карточка создана (81 контакт), PROPFIND 207.
- [x] 3.2 Повторный запуск — количество карточек не растёт (идемпотентность).
**Сделано:** стабильно 162 unchanged (81 push + 81 pull), 0 PUT.
- [x] 3.3 Изменить контакт в contacts.json (добавить телефон) → повторный
синк обновляет карточку (TEL появился, ETag изменился).
- [x] 3.4 `--prune-caldav`: удалить контакт из contacts.json → карточка
удалена с сервера.
- [x] 3.5 **Reverse pull:** вручную (curl PUT) изменить vCard на сервере →
повторный синк обновляет контакт в contacts.json.
**Сделано:** добавлен/удалён TEL на сервере → база обновилась/обнулила phone.
- [x] 3.6 **Новая карточка на сервере:** curl PUT новой vCard → синк создаёт
контакт в базе. **Сделано:** pulled_created (id=phone-newcard-001, from_device=True).
- [x] 3.7 **Удаление на сервере:** curl DELETE vCard → контакт получает
`deleted: true` в базе. **Сделано:** pulled_deleted, артефакт убран из базы.
- [x] 3.8 **Конфликт:** изменить и vCard (curl), и контакт в базе → синк
применяет версию с сервера, локальная в caldav-sync.log.
## 4. Интеграция в cron и документация
- [ ] 4.1 Обновить `config/contacts-cron.sh`: добавить `--sync-caldav` и
подтянуть пароль из radicale/.env (env CALDAV_PASS). Проверка:
запуск cron-скрипта вручную синкает контакты без ошибок.
- [x] 4.2 Обновить README/STATUS: раздел «CardDAV (контакты)» — как
подключить на Android (DAVx5, URL http://cal.nixg.ru:5232
или cal.nixg.ru, логин estorozhenko), порты, флаги синка.
**Сделано (2026-09-13):** STATUS.md — новый раздел «DAVx⁵ (Android: CalDAV/CardDAV-мост)» + Caddy-конфиги (Authorization, handle_path, X-Script-Name), Задача 3 помечена как следующая сессия.
- [ ] 4.3 `openspec validate contacts-caldav-server` → valid.
- [ ] 4.4 Git commit и push (gitea.nixg.ru/hermes/email-assistant).
## 5. Синхронизация с телефоном (СЛЕДУЮЩАЯ СЕССИЯ — первая задача)
- [ ] 5.1 Настроить Caddy на vps02: `cal.nixg.ru` → Radicale bigbox :5232
(header_up Authorization, reverse_proxy 10.8.0.2:5232);
`tasks.nixg.ru` → Vikunja :3456. Reload Caddyfile.
- [ ] 5.2 Проверить CalDAV/CardDAV снаружи: PROPFIND https://cal.nixg.ru/... → 207
(учесть кириллицу «Контакты» в URL, Auth).
- [ ] 5.3 DAVx⁵ на телефоне: аккаунт https://cal.nixg.ru, логин estorozhenko,
пароль RADICALE_PASS → выбрать календари (Личный/Рабочий/Задачи)
и адресную книгу «Контакты».
- [ ] 5.4 jtx board на телефоне → Vikunja https://tasks.nixg.ru (токен API),
проверить задачи.
- [ ] 5.5 Проверка двусторонней синхронизации: контакт создан/изменён на
телефоне → в базе (contacts.json) после `contacts_caldav_sync.py`;
задача из jtx board → в Vikunja; событие из DAVx⁵ → в Radicale.
- [ ] 5.6 (опц.) Вписать sync в contacts-cron.sh (4.1) и проверить V5.
## Verification
- [ ] V1: `curl -X PROPFIND -u estorozhenko:PASS http://127.0.0.1:5232/estorozhenko/Контакты/` → 207 + addressbook
- [ ] V2: после синка количество vCard в адресной книге == числу контактов с email в contacts.json
- [ ] V3: повторный синк → 0 новых карточек
- [ ] V4: правка контакта на сервере (curl PUT vCard) → контакт в contacts.json обновлён после синка
- [ ] V5: `python3 scripts/contacts_extractor.py --sync-caldav` из cron-обёртки завершается кодом 0 и пишет caldav-sync.log
@@ -1,5 +1,10 @@
# Proposal: Локальные сервисы календаря (Radicale) и задач (Vikunja)
> ⚠️ **SUPERSEDED (2026-09-13):** Часть про **Vikunja** заменена чейнджем
> `remove-vikunja-use-radicale-tasks` — Vikunja выведена из проекта (лишняя
> сущность), задачи ведутся через **Radicale VTODO** (календарь «Задачи»).
> Radicale-часть актуальна.
## Why
Для синхронизации календаря и задач с Android-телефоном нужны локальные
@@ -14,11 +19,11 @@
## What Changes
Два новых сервиса в `/opt/hermes/`:
Два новых сервиса в `/opt/hermes/email-assistant/`:
### 1. Radicale (CalDAV) — календарь + задачи VTODO
- **Порт:** 5232 (свободен)
- **Путь:** `/opt/hermes/radicale/`
- **Путь:** `/opt/hermes/email-assistant/radicale/`
- **Способ:** Python pip (лёгкий, systemd) ИЛИ docker
- **Назначение:** CalDAV-сервер для:
- Календаря «Личный» + «Рабочий» (синхронизация с Android через DAVx5)
@@ -27,7 +32,7 @@
### 2. Vikunja (трекер задач) — веб-UI + REST API + Android app
- **Порт:** 3456 (дефолт Vikunja, свободен)
- **Путь:** `/opt/hermes/vikunja/`
- **Путь:** `/opt/hermes/email-assistant/vikunja/`
- **Способ:** docker compose (postgres + vikunja)
- **Назначение:**
- Трекер задач с веб-интерфейсом (kanban/список)
@@ -37,7 +42,7 @@
### Общие решения
- **Доступ:** 127.0.0.1 (локально) + опционально через Caddy reverse proxy
на поддомене (caddy есть в /opt/gitea.nixg.ru)
на поддомене (Caddy — контейнер на vps02, см. STATUS.md «Ресурсы проекта»)
- **Порты:** 5232 (radicale), 3456 (vikunja) — не конфликтуют с 8080 (NetBox)
- **Авторизация:** Radicale — HTTP Basic (логин/пароль в конфиге);
Vikunja — своя (регистрация/логин, API-токены)
@@ -55,7 +60,7 @@
## Impact
- **Новые сервисы:** `/opt/hermes/radicale/`, `/opt/hermes/vikunja/`
- **Новые сервисы:** `/opt/hermes/email-assistant/radicale/`, `/opt/hermes/email-assistant/vikunja/`
- **Порты:** 5232 (radicale), 3456 (vikunja) — новые
- **Данные:** календари/задачи будут храниться локально
- **Документация:** обновить README/STATUS (порты, логины, как синхронизировать)
@@ -63,8 +68,8 @@
## Rollback
1. **Radicale:** `systemctl stop radicale` + удалить `/opt/hermes/radicale/`
2. **Vikunja:** `docker compose -f /opt/hermes/vikunja/docker-compose.yml down -v`
(удалить контейнеры и volume с данными) + убрать `/opt/hermes/vikunja/`
1. **Radicale:** `systemctl stop radicale` + удалить `/opt/hermes/email-assistant/radicale/`
2. **Vikunja:** `docker compose -f /opt/hermes/email-assistant/vikunja/docker-compose.yml down -v`
(удалить контейнеры и volume с данными) + убрать `/opt/hermes/email-assistant/vikunja/`
3. Убрать упоминания из README/STATUS
4. Вернуть порты в исходное состояние (оба сейчас свободны, конфликтов нет)
@@ -3,18 +3,18 @@
## Implementation Tasks
### Radicale (CalDAV, docker :5232)
- [ ] R1: Создать `/opt/hermes/radicale/docker-compose.yml` (образ radicale, порт 5232, volumes, auth)
- [ ] R2: Создать коллекции: «Личный», «Рабочий», «Задачи» (VTODO) — через скрипт или DAVx5
- [ ] R3: Проверить `curl -X PROPFIND http://127.0.0.1:5232/` → 207
- [x] R1: Создать `/opt/hermes/email-assistant/radicale/docker-compose.yml` (образ radicale, порт 5232, volumes, auth)
- [x] R2: Создать коллекции: «Личный», «Рабочий», «Задачи» (VTODO) — напрямую на ФС (MKCOL 403 при owner_only; .Radicale.props + data/collections/collection-root/estorozhenko/)
- [x] R3: Проверить `curl -X PROPFIND http://127.0.0.1:5232/` → 207
### Vikunja (трекер, docker :3456)
- [ ] V1: Создать `/opt/hermes/vikunja/docker-compose.yml` (postgres + vikunja, env, volumes)
- [x] V1: Создать `/opt/hermes/email-assistant/vikunja/docker-compose.yml` (postgres + vikunja, env, volumes)
- [ ] V2: `docker compose up -d` → Vikunja отвечает на `http://127.0.0.1:3456/api/v1/info`
- [ ] V3: Создать админ-пользователя, получить API-токен
- [ ] V4: Тест: `POST /api/v1/projects/<id>/tasks` с токеном → 201
### Общее
- [ ] O1: Caddy reverse proxy (cal.nixg.ru → 5232, tasks.nixg.ru → 3456) + TLS
- [x] O1: Caddy reverse proxy (cal.nixg.ru → 5232, tasks.nixg.ru → 3456) + TLS (на vps02, Caddyfile обновлён, cal.nixg.ru уже работает)
- [ ] O2: Проверить доступ с Android (DAVx5 для Radicale, Vikunja app/token для задач)
- [ ] O3: Обновить README/STATUS (порты, логины, как синхронизировать)
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-13
@@ -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/<urlencoded 'Задачи'>/<uid>.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 .../Задачи/<uid>.ics` — VTODO есть.
4. `openspec validate remove-vikunja-use-radicale-tasks` — valid.
@@ -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)
@@ -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/<urlencoded Задачи>/<uid>.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
@@ -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/<urlencoded Задачи>/ | 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)
+47
View File
@@ -0,0 +1,47 @@
# email-attachments Specification
## Purpose
Скачивание вложений письма в каталог этого письма. Сейчас `mail_archive.py`
вызывает `himalaya attachment download --dir`, но правильный флаг в Himalaya —
`--downloads-dir`, из-за чего команда падает (exit 2), ошибка молча глотается
`except: pass`, и папка `attachments/` всегда пустая. Вложения теряются.
## Requirements
### Requirement: Вложения сохраняются в каталог письма
Для каждого письма с вложениями (флаг `has_attachment: true` в frontmatter)
вложения MUST быть сохранены в подкаталог `attachments/` каталога письма
(`/opt/hermes/email/<folder>/YYYY/MM/<uid>/attachments/`).
#### Scenario: Письмо с вложением архивировано
- **WHEN** `mail_archive.py` заархивировал письмо с `has_attachment: true`
- **THEN** файлы вложений лежат в `<msg_dir>/attachments/` и совпадают с вложениями на IMAP-сервере
### Requirement: Правильный флаг Himalaya
Скачивание вложений MUST использовать флаг `--downloads-dir` (а не несуществующий
`--dir`) команды `himalaya attachment download`, и передавать ему каталог письма.
#### Scenario: Вызов himalaya с корректным флагом
- **WHEN** `get_attachments()` выполняется для письма
- **THEN** используется `himalaya attachment download --folder <folder> --downloads-dir <msg_dir>/attachments <uid>`, 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** вложения не скачиваются повторно (идемпотентность)
@@ -0,0 +1,58 @@
# email-classification Specification
## Purpose
Классификация писем локальной LLM: после скачивания письма модель определяет тип
письма (информационное, требует срочного ответа, содержит задачу, содержит
встречу) и записывает тег + обоснование в frontmatter файла email.md. Обработка
приватна — модель Qwen3:8b запущена локально через Ollama, текст письма не
покидает хост.
## Requirements
### Requirement: Классификация каждого нового письма
Каждое письмо, заархивированное `mail_archive.py`, MUST быть классифицировано
локальной моделью не позднее одного прохода классификатора после архивации.
#### Scenario: Новое письмо после архивации
- **WHEN** `mail_archive.py` сохранил новое письмо в `/opt/hermes/email/**/email.md` без поля `classification`
- **THEN** `email_classifier.py` обработает его и запишет в frontmatter поле `classification` с одним из значений: `info`, `urgent`, `task`, `meeting` (или комбинацию через запятую)
### Requirement: Приватность обработки
Классификация MUST выполняться локальной моделью (Qwen3:8b через Ollama на
localhost:11434) и MUST NOT отправлять текст письма в облачные API.
#### Scenario: Локальная модель доступна
- **WHEN** классификатор запущен
- **THEN** запросы к LLM идут только на `http://localhost:11434` (Ollama), никаких внешних HTTP-вызовов с телом письма
### Requirement: Обоснование классификации
Классификатор MUST записывать краткое обоснование решения в frontmatter
(поле `classification_reason`), чтобы пользователь видел, почему письмо помечено
именно так.
#### Scenario: Обоснование для письма
- **WHEN** `email_classifier.py` классифицировал письмо
- **THEN** в frontmatter записано `classification_reason` с 1-2 предложениями на русском
### Requirement: Идемпотентность
Письмо MUST обрабатываться классификатором только один раз; повторный запуск
MUST NOT переклассифицировать уже обработанные письма (если не задан флаг
принудительной переклассификации).
#### Scenario: Повторный запуск классификатора
- **WHEN** `email_classifier.py` запущен повторно на уже обработанном письме (есть `classification`)
- **THEN** письмо пропускается без повторного вызова LLM
### Requirement: Обработка ошибок классификатора
Если LLM не ответила или вернула невалидный JSON, классификатор MUST пометить
письмо как `unclassified` и продолжить со следующим письмом, не прерывая весь
проход.
#### Scenario: LLM вернула невалидный ответ
- **WHEN** модель не ответила или вернула не-JSON
- **THEN** письмо получает `classification: unclassified`, а проход продолжается
+66
View File
@@ -0,0 +1,66 @@
# email-handlers Specification
## Purpose
Подключение обработчиков по тегам классификации письма: уведомление в мессенджер
для срочных писем, создание задачи в Radicale (VTODO, календарь «Задачи») для
писем с задачей, создание события в Radicale (VEVENT, календарь «Рабочий») для
писем со встречей. Обработчики запускаются автоматически после классификации и
работают идемпотентно. (Vikunja выведена из эксплуатации 2026-09-13 — change
`remove-vikunja-use-radicale-tasks`.)
## Requirements
### Requirement: Уведомление в мессенджер для срочных писем
Письмо с тегом `urgent` MUST вызывать отправку уведомления в мессенджер
(Telegram) с отправителем, темой и первыми строками текста.
#### Scenario: Срочное письмо
- **WHEN** `email_classifier.py` пометил письмо тегом `urgent`
- **THEN** `email_handlers.py` отправляет в Telegram уведомление с from/subject/превью
### Requirement: Создание задачи в Radicale (VTODO) для писем с задачей
Письмо с тегом `task` MUST создавать задачу в Radicale (CalDAV, календарь
«Задачи») как VTODO с темой письма в SUMMARY и ссылкой на письмо в DESCRIPTION.
#### Scenario: Письмо с задачей
- **WHEN** `email_classifier.py` пометил письмо тегом `task`
- **THEN** в Radicale (календарь Задачи) создаётся VTODO: SUMMARY=тема письма, DESCRIPTION=ссылка на `email.md`
### Requirement: Создание события в Radicale для писем со встречей
Письмо с тегом `meeting` MUST создавать событие в календаре Radicale (Рабочий)
с темой письма как SUMMARY и извлечённой датой/временем, если они указаны.
#### Scenario: Письмо со встречей
- **WHEN** `email_classifier.py` пометил письмо тегом `meeting` и в классификации есть дата/время
- **THEN** в Radicale (календарь Рабочий) создаётся VEVENT с SUMMARY=тема письма
### Requirement: Идемпотентность обработчиков
Обработчик MUST запускаться для каждого письма один раз; повторный запуск на
уже обработанном письме MUST NOT создавать дубликат задачи/события/уведомления.
#### Scenario: Повторный запуск обработчиков
- **WHEN** `email_handlers.py` запущен повторно на письме, для которого уже созданы задача/событие
- **THEN** дубликаты не создаются (трекинг обработанных в state)
### Requirement: Информационные письма не создают обработчиков
Письмо с тегом `info` MUST NOT вызывать уведомления, задач или событий; оно
только помечается тегом в frontmatter.
#### Scenario: Информационное письмо
- **WHEN** `email_classifier.py` пометил письмо тегом `info`
- **THEN** `email_handlers.py` не создаёт ни уведомления, ни задачи, ни события
### Requirement: Уведомление о недоступности обработчика
Если обработчик не может выполниться (Radicale недоступен, нет учётных данных),
MUST быть записана ошибка в лог, и письмо MUST остаться помеченным тегом для
повторной попытки (не теряться).
#### Scenario: Radicale недоступен
- **WHEN** `email_handlers.py` пытается создать задачу/событие, но Radicale недоступен
- **THEN** ошибка пишется в лог, письмо остаётся с тегом `task`/`meeting`, повторная попытка возможна
+6 -1
View File
@@ -1,7 +1,12 @@
# email-storage-format Specification
## Purpose
TBD - created by archiving change email-storage-analysis. Update Purpose after archive.
Формат хранения архива писем: `email.md` (YAML-frontmatter + текст) в структуре
`/<folder>/YYYY/MM/<uid>/`, выбранный по итогам анализа STORAGE_ANALYSIS.md
(9 критериев: полнота заголовков, инкрементальность, идемпотентность, удобство
поиска и др.). Хранит полные заголовки письма в frontmatter и тело как Markdown;
доп. поля (classification, handled_*, attachments) расширяют frontmatter без
изменения формата.
## Requirements
+16
View File
@@ -0,0 +1,16 @@
[server]
hosts = 0.0.0.0:5232
[auth]
type = htpasswd
htpasswd_filename = /data/users
htpasswd_encryption = md5
[rights]
type = owner_only
[storage]
filesystem_folder = /data/collections
[logging]
level = info
+13
View File
@@ -0,0 +1,13 @@
# Radicale — CalDAV/CardDAV-сервер (календарь + задачи VTODO)
# Порт: 5232 (127.0.0.1), TLS — через Caddy (cal.nixg.ru)
services:
radicale:
image: kozea/radicale:latest
container_name: radicale
command: ["-C", "/config/config"]
ports:
- "5232:5232"
volumes:
- ./data:/data
- ./config:/config:ro
restart: unless-stopped
+592
View File
@@ -0,0 +1,592 @@
#!/usr/bin/env python3
"""
contacts_caldav_sync.py — двусторонняя синхронизация контактов
между локальной базой contacts.json и CardDAV-сервером Radicale.
Направления:
PUSH (база → сервер): новые/изменённые контакты пишутся как vCard
PULL (сервер → база): правки на телефоне (DAVx5) попадают обратно:
- изменённая карточка → обновление контакта
- новая карточка → новый контакт
- удалённая карточка → deleted: true (soft delete)
Конфликты: приоритет серверу (телефон), локальная версия в caldav-sync.log
Запуск:
python3 contacts_caldav_sync.py [--base-url URL] [--user USER] [--pass PASS]
[--addressbook КОНТАКТЫ] [--prune] [--dry-run]
Пароль: --pass или env CALDAV_PASS (или RADICALE_PASS).
Без --pass → читает radicale/.env (RADICALE_PASS).
"""
import argparse
import base64
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from datetime import date, datetime
from pathlib import Path
EMAIL_ROOT = Path("/opt/hermes/email")
CONTACTS_DIR = EMAIL_ROOT / "contacts"
LOG_PATH = CONTACTS_DIR / "caldav-sync.log"
DEFAULT_BASE_URL = "http://127.0.0.1:5232"
DEFAULT_USER = "estorozhenko"
DEFAULT_ADDRESSBOOK = "Контакты"
# ─── HTTP helpers ──────────────────────────────────────────────────────────────
def _auth_header(user, password):
token = base64.b64encode(f"{user}:{password}".encode()).decode()
return {"Authorization": f"Basic {token}"}
def _request(method, url, headers=None, data=None, timeout=15):
req = urllib.request.Request(url, method=method, headers=headers or {})
if data is not None:
if isinstance(data, str):
data = data.encode("utf-8")
req.data = data
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
except urllib.error.URLError as e:
return 0, str(e).encode()
def _etag_from_response(resp_headers):
return resp_headers.get("ETag") or resp_headers.get("etag")
# ─── vCard helpers ────────────────────────────────────────────────────────────
def contact_to_vcard(contact):
"""Собрать vCard 4.0 из контакта (как generate_vcard в contacts_extractor)."""
lines = ["BEGIN:VCARD", "VERSION:4.0"]
uid = contact.get("id") or ""
if uid:
lines.append(f"UID:{uid}")
full_name = contact.get("full_name") or ""
lines.append(f"FN:{full_name}")
name_parts = full_name.split(maxsplit=2)
if len(name_parts) >= 2:
n_line = f"N:{name_parts[-1]};{name_parts[0]};{' '.join(name_parts[1:-1])};;"
else:
n_line = f"N:{full_name};;;;"
lines.append(n_line)
email = contact.get("email")
if email:
lines.append(f"EMAIL;TYPE=WORK:{email}")
if contact.get("phone"):
# Radicale режет value по запятой → заменяем на пробел (доб. 3557 сохраняется)
lines.append(f"TEL;TYPE=WORK:{contact['phone'].replace(',', ' ')}")
if contact.get("phone_secondary"):
lines.append(f"TEL;TYPE=CELL:{contact['phone_secondary'].replace(',', ' ')}")
if contact.get("position"):
lines.append(f"TITLE:{contact['position']}")
if contact.get("company"):
lines.append(f"ORG:{contact['company']}")
if contact.get("address"):
lines.append(f"ADR;TYPE=WORK:;;{contact['address']};;;")
# Источник: X-SOURCES через | (Radicale режет по запятой, | не режет)
if contact.get("source_uids"):
sources = " | ".join(contact.get("source_uids", []))
lines.append(f"X-SOURCES:{sources}")
lines.append("END:VCARD")
return "\r\n".join(lines) + "\r\n"
def parse_vcard_to_contact(vcard_text, uid, base_contact=None):
"""Извлечь поля из vCard в dict контакта (для pull-направления)."""
contact = dict(base_contact or {})
contact["id"] = uid
fields = {}
current = None
for line in vcard_text.replace("\r\n", "\n").split("\n"):
line = line.strip()
if not line:
continue
if line.startswith("BEGIN:"):
continue
if line.startswith("END:"):
continue
if ":" in line:
key, _, value = line.partition(":")
# params: EMAIL;TYPE=WORK → name=EMAIL
name = key.split(";")[0].upper()
value = value.strip()
if name in ("FN", "N", "EMAIL", "TEL", "TITLE", "ORG", "ADR", "NOTE"):
fields.setdefault(name, []).append(value)
if "FN" in fields:
contact["full_name"] = fields["FN"][0]
if "EMAIL" in fields:
email = fields["EMAIL"][0]
# strip mailto:
if email.lower().startswith("mailto:"):
email = email[7:]
contact["email"] = email.lower()
tels = [t for t in fields.get("TEL", []) if t]
if tels:
tel = tels[0]
if tel.lower().startswith("tel:"):
tel = tel[4:]
contact["phone"] = tel
if len(tels) > 1:
contact["phone_secondary"] = tels[1]
if "TITLE" in fields:
contact["position"] = fields["TITLE"][0]
if "ORG" in fields:
contact["company"] = fields["ORG"][0].replace("\\,", ",")
if "ADR" in fields:
# ADR;TYPE=WORK:;;address;;; → берём 3-ю часть
adr = fields["ADR"][0]
parts = adr.split(";")
if len(parts) >= 3 and parts[2]:
contact["address"] = parts[2]
return contact
# ─── CardDAV ops ──────────────────────────────────────────────────────────────
def carddav_base_url(base_url, user, addressbook):
"""URL адресной книги."""
return f"{base_url.rstrip('/')}/{urllib.parse.quote(user)}/{urllib.parse.quote(addressbook)}/"
def list_addressbook(base_url, user, addressbook, auth):
"""PROPFIND Depth:1 → {href: {'etag': str, 'vcard': str}} (vcard via GET)."""
ab_url = carddav_base_url(base_url, user, addressbook)
body = """<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
<prop><getetag/><resourcetype/><href/></prop>
</propfind>"""
status, content = _request(
"PROPFIND", ab_url,
headers={**auth, "Depth": "1", "Content-Type": "application/xml"},
data=body,
)
if status not in (207, 200):
return None, f"PROPFIND → {status}: {content[:200]}"
xml = content.decode("utf-8", errors="replace")
# Парсим response-блоки через ElementTree (Radicale использует префикс D: по умолчанию,
# но может быть и без — ET с namespaces справится).
cards = {}
try:
root = ET.fromstring(xml)
except ET.ParseError as e:
return None, f"PROPFIND XML parse: {e}"
dav = "{DAV:}"
for resp in root.iter(f"{dav}response"):
href_el = resp.find(f"{dav}href")
if href_el is None or not href_el.text:
continue
href = href_el.text.strip()
if href.endswith("/"):
continue # сама коллекция
# etag лежит в propstat/prop/getetag
etag = ""
for prop in resp.iter(f"{dav}prop"):
getetag = prop.find(f"{dav}getetag")
if getetag is not None and getetag.text:
etag = getetag.text.strip()
break
cards[href] = {"etag": etag}
# Тянем содержимое каждой карточки (GET)
for href in list(cards):
card_url = f"{base_url.rstrip('/')}{href}" if href.startswith("/") else f"{ab_url.rstrip('/')}/{href}"
status2, content2 = _request("GET", card_url, headers=auth)
if status2 == 200:
cards[href]["vcard"] = content2.decode("utf-8", errors="replace")
else:
cards[href]["vcard"] = ""
return cards, None
def put_card(base_url, user, addressbook, uid, vcard, auth, etag=None):
"""PUT vCard; etag → If-Match (обновление). Возвращает (status, etag или body)."""
ab_url = carddav_base_url(base_url, user, addressbook)
url = f"{ab_url}{uid}.vcf"
headers = {**auth, "Content-Type": "text/vcard; charset=utf-8"}
if etag:
headers["If-Match"] = etag
status, content = _request("PUT", url, headers=headers, data=vcard)
if status in (200, 201, 204):
new_etag = None
# Radicale отдаёт ETag в заголовке; urllib его теряет в _request —
# пере-запросим HEAD? Нет. GET вернёт актуальный.
return status, new_etag
return status, content[:300]
def get_card_etag(base_url, user, addressbook, uid, auth):
"""GET карточки → (etag, vcard)."""
ab_url = carddav_base_url(base_url, user, addressbook)
url = f"{ab_url}{uid}.vcf"
req = urllib.request.Request(url, method="GET", headers=auth)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
etag = resp.headers.get("ETag") or ""
return etag, resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as e:
return None, None if e.code == 404 else (None, e.read()[:200])
except urllib.error.URLError:
return None, None
def delete_card(base_url, user, addressbook, uid, auth):
ab_url = carddav_base_url(base_url, user, addressbook)
url = f"{ab_url}{uid}.vcf"
status, content = _request("DELETE", url, headers=auth)
return status, content
# ─── Sync logic ───────────────────────────────────────────────────────────────
def log_sync(entries):
"""Дописать строки в caldav-sync.log."""
with open(LOG_PATH, "a", encoding="utf-8") as f:
for e in entries:
ts = datetime.now().isoformat(timespec="seconds")
f.write(f"[{ts}] {e}\n")
def sync_contacts_carddav(base_url, user, password, addressbook, prune=False,
dry_run=False, contacts_dir=None, verbose=True):
"""Двусторонний синк; возвращает (stats: dict, err: str|None)."""
contacts_dir = contacts_dir or CONTACTS_DIR
contacts_db = {}
try:
contacts_db = json.load(open(contacts_dir / "contacts.json", encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError) as e:
return None, f"Не удалось прочитать contacts.json: {e}"
contacts = contacts_db.get("contacts", [])
by_email = contacts_db.get("by_email", {})
auth = _auth_header(user, password)
# Строим индекс по id
by_id = {c.get("id"): c for c in contacts if c.get("id")}
# 1. PROPFIND книги
cards, err = list_addressbook(base_url, user, addressbook, auth)
if err:
return None, f"list_addressbook: {err}"
assert cards is not None
if verbose:
print(f" CardDAV: {len(cards)} карточек в книге {addressbook}")
stats = {"created": 0, "updated": 0, "unchanged": 0, "conflict": 0,
"pulled_created": 0, "pulled_updated": 0, "pulled_deleted": 0,
"deleted": 0}
log_entries = []
changed = False
# ── PULL: сервер → база ────────────────────────────────────────────────
server_ids = set()
for href, card in cards.items():
# uid = имя файла без .vcf
uid = href.rstrip("/").split("/")[-1]
if uid.endswith(".vcf"):
uid = uid[:-4]
server_ids.add(uid)
local = by_id.get(uid)
server_etag = card.get("etag", "")
vcard_text = card.get("vcard", "")
if local is None:
# Новой карточки нет в базе → создать контакт
new_contact = parse_vcard_to_contact(vcard_text, uid)
if not new_contact.get("email"):
if verbose:
print(f" ← {uid}: новая карточка без email, пропуск pull")
continue
new_contact.setdefault("full_name", "")
new_contact["first_seen"] = date.today().isoformat()
new_contact["last_seen"] = date.today().isoformat()
new_contact["source_uids"] = new_contact.get("source_uids", [])
new_contact["source_folders"] = new_contact.get("source_folders", [])
new_contact["caldav"] = {"uid": uid, "etag": server_etag,
"from_device": True}
contacts.append(new_contact)
by_email[new_contact["email"]] = len(contacts) - 1
by_id[uid] = new_contact
stats["pulled_created"] += 1
changed = True
if verbose:
print(f" ← {uid}: новая карточка с телефона → контакт создан")
log_entries.append(f"PULL-CREATE {uid} {new_contact.get('email')}")
continue
local_caldav = local.get("caldav") or {}
local_etag = local_caldav.get("etag", "")
# Изменена ли карточка относительно нашей записи?
if server_etag and server_etag != local_etag:
# Сервер изменился (правка с телефона). По REQ-011 приоритет телефону —
# применяем серверную версию, сохраняя недостающие локальные поля.
merged = parse_vcard_to_contact(vcard_text, uid, base_contact=local)
# Поля, которых нет в vCard — значит удалены на телефоне → обнуляем.
SYNCFIELDS = ("full_name", "phone", "phone_secondary", "position",
"company", "address", "email")
present = _vcard_field_names(vcard_text)
for k in SYNCFIELDS:
if k in present:
if merged.get(k):
local[k] = merged[k]
else:
local[k] = None if k in ("phone", "phone_secondary",
"position", "company", "address") else local.get(k)
local["caldav"] = {**local_caldav, "etag": server_etag,
"from_device": True}
stats["pulled_updated"] += 1
changed = True
if verbose:
print(f" ← {uid}: карточка изменена на телефоне → контакт обновлён")
log_entries.append(f"PULL-UPDATE {uid} (server etag {server_etag})")
else:
stats["unchanged"] += 1
# Карточки, которых больше нет на сервере (удалены на телефоне) → soft delete
for contact in contacts:
cid = contact.get("id")
if not cid:
continue
caldav = contact.get("caldav") or {}
if caldav.get("uid") and cid not in server_ids:
if not contact.get("deleted"):
contact["deleted"] = True
contact["last_seen"] = date.today().isoformat()
stats["pulled_deleted"] += 1
changed = True
if verbose:
print(f" ← {cid}: карточка удалена на телефоне → deleted: true")
log_entries.append(f"PULL-DELETE {cid}")
# ── PUSH: база → сервер ────────────────────────────────────────────────
for contact in contacts:
cid = contact.get("id")
email = contact.get("email")
if not cid or not email:
continue
if contact.get("deleted"):
# unтелефон deleted: удалить карточку при prune или пропустить
if prune and cid in server_ids:
status, body = delete_card(base_url, user, addressbook, cid, auth)
if status in (200, 204):
stats["deleted"] += 1
changed = True
if verbose:
print(f" → {cid}: карточка удалена (prune)")
log_entries.append(f"DELETE {cid}")
continue
caldav = contact.get("caldav") or {}
if caldav.get("from_device") and not caldav.get("sync_after_pull"):
# Контакт с телефона: уже синхронизирован в pull, ничего не пишем
continue
vcard = contact_to_vcard(contact)
if dry_run:
action = "создана" if cid not in server_ids else "обновлена"
if verbose:
print(f" → [dry] {cid}: карточка {action}")
continue
if cid not in server_ids:
# Нет карточки → создать
status, body = put_card(base_url, user, addressbook, cid, vcard, auth)
if status in (200, 201, 204):
# обновим etag
etag, _ = get_card_etag(base_url, user, addressbook, cid, auth)
contact["caldav"] = {"uid": cid, "etag": etag or "",
"from_device": False}
stats["created"] += 1
changed = True
if verbose:
print(f" → {cid}: карточка создана")
log_entries.append(f"CREATE {cid} {email}")
else:
if verbose:
print(f" ✗ {cid}: PUT {status}: {body[:120]}")
else:
# Карточка есть → проверить, изменилась ли локально
c_href = href_for(cid, cards)
server_etag = cards[c_href]["etag"] if c_href else ""
local_etag = (contact.get("caldav") or {}).get("etag", "")
# Сравниваем содержимое vCard (проще, чем etag на каждый чих)
server_vcard = cards[c_href].get("vcard", "") if c_href else ""
local_vcard = contact_to_vcard(contact)
if _normalize_vcard(local_vcard) == _normalize_vcard(server_vcard):
stats["unchanged"] += 1
continue
# Обновляем с If-Match
status, body = put_card(base_url, user, addressbook, cid, vcard, auth, etag=server_etag or None)
if status in (200, 201, 204):
etag, _ = get_card_etag(base_url, user, addressbook, cid, auth)
contact["caldav"] = {"uid": cid, "etag": etag or "",
"from_device": False}
stats["updated"] += 1
changed = True
if verbose:
print(f" → {cid}: карточка обновлена")
log_entries.append(f"UPDATE {cid} {email}")
elif status == 412:
# Конфликт: сервер изменил карточку → принять серверную
# версию, локальную в лог.
if cid in server_ids:
c_href2 = href_for(cid, cards)
server_vcard = cards[c_href2].get("vcard", "") if c_href2 else ""
if server_vcard:
local_prev = {k: contact.get(k) for k in
("full_name", "phone", "phone_secondary",
"position", "company", "address")}
merged = parse_vcard_to_contact(server_vcard, cid, base_contact=contact)
for k in ("full_name", "phone", "phone_secondary",
"position", "company", "address"):
if k in merged and merged[k]:
contact[k] = merged[k]
etag, _ = get_card_etag(base_url, user, addressbook, cid, auth)
contact["caldav"] = {"uid": cid, "etag": etag or "",
"from_device": True}
stats["conflict"] += 1
changed = True
if verbose:
print(f" ⚠ {cid}: 412 конфликт → сервер победил, локальное в log")
log_entries.append(f"CONFLICT-PUSH {cid} conflict_local={json.dumps(local_prev, ensure_ascii=False)}")
else:
if verbose:
print(f" ✗ {cid}: PUT 412, но карточка исчезла — повтор на след. раз")
else:
if verbose:
print(f" ✗ {cid}: PUT {status}: {body[:120]}")
# ── Сохраняем базу ─────────────────────────────────────────────────────
if changed and not dry_run:
contacts.sort(key=lambda c: c.get("email", ""))
# by_email хранит индексы — перестроить после сортировки
by_email = {c.get("email", ""): i for i, c in enumerate(contacts)}
contacts_db["contacts"] = contacts
contacts_db["by_email"] = by_email
tmp = contacts_dir / "contacts.json.tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(contacts_db, f, ensure_ascii=False, indent=2)
tmp.replace(contacts_dir / "contacts.json")
# index.json
with open(contacts_dir / "index.json.tmp", "w", encoding="utf-8") as f:
json.dump(by_email, f, ensure_ascii=False, indent=2)
tmp2 = contacts_dir / "index.json.tmp"
tmp2.replace(contacts_dir / "index.json")
if log_entries:
log_sync(log_entries)
return stats, None
def href_for(cid, cards):
"""Найти href карточки по uid (имя файла)."""
for href in cards:
if href.rstrip("/").split("/")[-1].startswith(cid):
return href
return None
def _vcard_field_names(vcard_text):
"""Вернуть имена полей vCard (FN, N, EMAIL, TEL, ...) — для проверки наличия."""
names = set()
for line in vcard_text.replace("\r\n", "\n").split("\n"):
line = line.strip()
if not line or ":" not in line:
continue
key, _, _ = line.partition(":")
name = key.split(";")[0].upper()
if name not in ("BEGIN", "END", "VERSION", "UID"):
names.add(name)
return names
def _normalize_vcard(vcard):
"""Нормализовать vCard для сравнения.
Radicale переупорядочивает поля и сворачивает длинные строки
(RFC-5545 folding, continuation line начинается с пробела).
Сравниваем как упорядоченный мультисет unfolded-строк.
"""
# Развернуть folding: строка, начинающаяся с пробела/таба — продолжение
raw = re.sub(r"\r\n", "\n", vcard)
unfolded = []
for line in raw.split("\n"):
if line.startswith((" ", "\t")) and unfolded:
unfolded[-1] = unfolded[-1] + line.strip()
else:
unfolded.append(line.strip())
lines = [l for l in unfolded if l and not l.startswith(("BEGIN:", "END:", "VERSION:", "UID:"))]
# Radicale нормализует ADR (добавляет/переставляет пустые ;) — нормализуем и мы
norm = []
for l in lines:
if l.startswith("ADR;"):
k, v = l.split(":", 1)
# оставить только значение адреса (3-й компонент)
parts = v.rstrip(";").split(";")
norm.append(f"{k}:;;{parts[2] if len(parts) > 2 else ''};;;")
else:
norm.append(l)
return sorted(norm)
# ─── CLI ──────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="CardDAV-синк контактов с Radicale")
parser.add_argument("--base-url", default=os.environ.get("CALDAV_URL", DEFAULT_BASE_URL))
parser.add_argument("--user", default=os.environ.get("CALDAV_USER", DEFAULT_USER))
parser.add_argument("--pass", dest="password", default=None, help="Пароль (или env CALDAV_PASS/RADICALE_PASS)")
parser.add_argument("--addressbook", default=DEFAULT_ADDRESSBOOK, help="Имя адресной книги")
parser.add_argument("--prune", action="store_true", help="Удалять карточки без контакта в базе")
parser.add_argument("--dry-run", action="store_true", help="Не сохранять изменения")
parser.add_argument("--verbose", action="store_true", default=True)
args = parser.parse_args()
password = args.password
if not password:
password = os.environ.get("CALDAV_PASS") or os.environ.get("RADICALE_PASS")
if not password:
# fallback: radikal .env
env_path = Path("/opt/hermes/email-assistant/radicale/.env")
try:
for line in env_path.read_text().splitlines():
if line.startswith("RADICALE_PASS="):
password = line.split("=", 1)[1].strip().strip('"').strip("'")
break
except FileNotFoundError:
pass
if not password:
print("Не задан пароль: --pass / CALDAV_PASS / RADICALE_PASS", file=sys.stderr)
return 1
if args.dry_run:
print("🔍 DRY RUN — база не сохраняется")
stats, err = sync_contacts_carddav(
args.base_url, args.user, password, args.addressbook,
prune=args.prune, dry_run=args.dry_run,
)
if err:
print(f"❌ {err}", file=sys.stderr)
return 1
print("\n📇 CardDAV sync завершён:")
for k, v in stats.items():
if v:
print(f" {k}: {v}")
return 0
if __name__ == "__main__":
sys.exit(main())
+3
View File
@@ -308,6 +308,8 @@ def get_date_from_path(path):
def save_progress(contacts_db, contacts_dir, processed_uids, processed_emails):
"""Инкрементальное сохранение контактов и last_scan."""
contacts_db["contacts"].sort(key=lambda c: c.get("email", ""))
# Перестроить by_email — он хранит ИНДЕКСЫ позиций; после сортировки они сдвигаются
contacts_db["by_email"] = {c.get("email", ""): i for i, c in enumerate(contacts_db["contacts"])}
save_json(contacts_dir / "contacts.json", contacts_db)
save_json(contacts_dir / "index.json", contacts_db.get("by_email", {}))
generate_vcard(contacts_dir, contacts_db["contacts"])
@@ -485,6 +487,7 @@ def scan(limit=0):
if save_counter >= 5:
save_counter = 0
contacts_db["contacts"].sort(key=lambda c: c.get("email", ""))
contacts_db["by_email"] = {c.get("email", ""): i for i, c in enumerate(contacts_db["contacts"])}
save_json(contacts_dir / "contacts.json", contacts_db)
save_json(contacts_dir / "index.json", contacts_db.get("by_email", {}))
generate_vcard(contacts_dir, contacts_db["contacts"])
+323
View File
@@ -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()
+489
View File
@@ -0,0 +1,489 @@
#!/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
def _load_env_file(path):
"""Загрузить KEY=VALUE из .env-файла, не перезаписывая уже заданные env.
stdlib-фолбэк python-dotenv (в проекте нет сторонних зависимостей).
"""
p = Path(path)
if not p.exists():
return
try:
lines = p.read_text(encoding="utf-8").splitlines()
except OSError:
return
for line in lines:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
key = key.strip()
val = val.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = val
# Каталог скрипта → .env рядом с проектом (+ radicale/.env для RADICALE_PASS)
BASE_DIR = Path(__file__).resolve().parents[1]
if load_dotenv:
load_dotenv(BASE_DIR / ".env", override=False)
# radicale/.env — фактический источник RADICALE_PASS (проектного .env нет)
load_dotenv(BASE_DIR / "radicale" / ".env", override=False)
else:
_load_env_file(BASE_DIR / ".env")
_load_env_file(BASE_DIR / "radicale" / ".env")
# Токен Telegram живёт в /opt/vesti/.env (проект-источник бота @dedinit_vesti);
# опционально: если файл есть, берём VESTI_BOT_TOKEN/TELEGRAM_CHAT_ID оттуда.
vesti_env = Path("/opt/vesti/.env")
if vesti_env.exists():
_load_env_file(vesti_env)
EMAIL_ROOT = Path(os.getenv("EMAIL_ROOT", "/opt/hermes/email"))
# --- 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_<tag>: 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()
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Классификация новых писем + обработчики — запускается после mail-archive.
# Цепочка: mail-archive (5 min) → classifier (лимит, дозированно) → handlers.
set -euo pipefail
cd /opt/hermes/email-assistant
# Классификатор: до 10 новых писем за запуск (Qwen ~10-20с/письмо = ~3 мин)
python3 scripts/email_classifier.py --limit 10 || echo "[classifier] ошибка (продолжаем)" >&2
# Обработчики: все письма с тегами, без handled_* (идемпотентно)
python3 scripts/email_handlers.py || echo "[handlers] ошибка" >&2
+28 -6
View File
@@ -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/<folder>/YYYY/MM/UID/
# Путь: /opt/hermes/email/<folder>/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,6 +482,12 @@ def archive_folder(folder, limit=100):
continue
msg_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)
# Получаем заголовки и тело
@@ -477,6 +498,7 @@ def archive_folder(folder, limit=100):
email_path.write_text(content, encoding="utf-8")
# Вложения
if attachments_dir is not None:
get_attachments(uid, folder, attachments_dir)
subj = (env.get("subject") or "")[:60]