Compare commits
6 Commits
02e2b87072
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bff6f9aa4 | |||
| 7550aff102 | |||
| 8ea022f5c0 | |||
| 17252ebfa9 | |||
| de07fae246 | |||
| 2a597f7325 |
@@ -8,6 +8,7 @@ venv/
|
|||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
*.env.local
|
*.env.local
|
||||||
|
*.env.bak*
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -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 (уведомления).
|
||||||
@@ -12,3 +12,10 @@
|
|||||||
|
|
||||||
- [Анализ Nylas CLI](NYLAS_ANALYSIS.md) — почему Nylas **не подходит** для локального архива (2026-09-11)
|
- [Анализ Nylas CLI](NYLAS_ANALYSIS.md) — почему Nylas **не подходит** для локального архива (2026-09-11)
|
||||||
- [Анализ формата хранения: ФС vs Maildir](STORAGE_ANALYSIS.md) — почему текущий формат удобнее Maildir для локальной LLM (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)
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# Email Assistant — локальный архив и ассистент почты
|
# Email Assistant — локальный архив и ассистент почты
|
||||||
|
|
||||||
**Дата:** 2026-09-11
|
**Дата:** 2026-09-13
|
||||||
**Фаза:** 1.5–1.7 + Портфель веб-UI (планирование)
|
**Фаза:** 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: Локальный календарь + трекер задач 🔵 (в работе)
|
### Задача 2: Локальный календарь + трекер задач 🔵 (в работе)
|
||||||
- [x] Решение пользователя: **Radicale (CalDAV) + Vikunja (трекер)**, всё в Docker-контейнерах
|
- [x] Решение пользователя: **Radicale (CalDAV) + Vikunja (трекер)**, всё в Docker-контейнерах
|
||||||
- [x] Change `local-calendar-tasks` создан и валиден (опenspec-lab) — proposal/specs/design/tasks
|
- [x] **РЕШЕНИЕ 2026-09-13: Vikunja — ЛИШНЯЯ СУЩНОСТЬ, задачи через Radicale VTODO** (change `remove-vikunja-use-radicale-tasks`). Radicale из коробки умеет VTODO (календарь «Задачи»), jtx board читает их по CalDAV. Vikunja выводится из эксплуатации.
|
||||||
- [x] **Radicale развёрнут**: контейнер, порт 5232, HTTP Basic (estorozhenko), PROPFIND → 207, без пароля → 401
|
- [x] Change `local-calendar-tasks` создан и валиден — proposal/specs/design/tasks (Radicale-часть актуальна, Vikunja-часть — SUPERSEDED)<br>
|
||||||
- [ ] Коллекции Radicale (Личный/Рабочий/Задачи) — создание через MKCOL вернуло 403 (Radicale 3.x создаёт коллекции иначе: PUT ресурса); **заблокировано ожиданием решения**
|
- [x] **Radicale развёрнут и РАБОТАЕТ (2026-09-13)**: контейнер на :5232, PROPFIND 207 с паролем / 401 без. Коллекции Личный/Рабочий/Задачи на ФС.
|
||||||
- [ ] Vikunja — не начат (docker compose + postgres, порт 3456)
|
- [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`.
|
||||||
- [ ] Caddy reverse proxy (cal.nixg.ru → 5232, tasks.nixg.ru → 3456)
|
- [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). |
|
||||||
- [ ] Android-синхронизация (DAVx5)
|
- [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)
|
### Задача 3: Нативная синхронизация с Android 🔵 (в работе)
|
||||||
- [ ] DAVx5 на телефоне → Radicale; Vikunja app/token
|
- [x] **Контакты синхронизированы** (DAVx5 → Radicale «Контакты»; CardDAV-sync двусторонний, change `contacts-caldav-server`)
|
||||||
- [ ] Проверка двусторонней синхронизации (событие с телефона → bigbox)
|
- [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: Веб-интерфейс ассистента ⬜
|
### Задача 1: Веб-интерфейс ассистента ⬜
|
||||||
- [ ] FastAPI + SQLite FTS5: список писем (дата/адресант/тэги/папка)
|
- [ ] FastAPI + SQLite FTS5: список писем (дата/адресант/тэги/папка)
|
||||||
- [ ] Перемещение в папку; тэги
|
- [ ] Перемещение в папку; тэги
|
||||||
- [ ] Кнопка «Создать задачу» → в Vikunja API (Задача 6)
|
- [ ] Кнопка «Создать задачу» → Radicale VTODO (Задача 6, вместо Vikunja API)
|
||||||
- [ ] Страница авторизации (Задача 7)
|
- [ ] Страница авторизации (Задача 7)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -230,11 +244,57 @@ Hermes cron:
|
|||||||
|----|-----|-----------|-----|--------|
|
|----|-----|-----------|-----|--------|
|
||||||
| 5f2305b2bbf8 | mail-archive-every-5min | every 5m | no-agent (скрипт) | ✅ (Фаза 1.7: использует `--all --drain` с динамическим списком) |
|
| 5f2305b2bbf8 | mail-archive-every-5min | every 5m | no-agent (скрипт) | ✅ (Фаза 1.7: использует `--all --drain` с динамическим списком) |
|
||||||
| ea0fd1ab4f93 | contacts-extractor-every-30m | every 30m | скрипт (--limit 15) | ✅ |
|
| 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 | — | ❌ |
|
| — | mail-index-incremental | not set | — | ❌ |
|
||||||
| — | digest-weekly | 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`
|
- **Репозиторий:** `https://gitea.nixg.ru/hermes/email-assistant`
|
||||||
@@ -248,11 +308,11 @@ Hermes cron:
|
|||||||
|--------|-----------|--------|
|
|--------|-----------|--------|
|
||||||
| **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` |
|
| **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/{Личный,Рабочий,Задачи}` |
|
| **Radicale (CalDAV)** | bigbox, контейнер `radicale`, порт 5232 | WGET: `http://127.0.0.1:5232` (с bigbox), наружу: `https://cal.nixg.ru`. Конфиг: `/opt/hermes/email-assistant/radicale/` (compose.yml, .env — пароль `RADICALE_PASS`). Коллекции на ФС: `/opt/hermes/email-assistant/radicale/data/collections/collection-root/estorozhenko/{Личный,Рабочий,Задачи}` |
|
||||||
| **Vikunja (трекер задач)** | bigbox, compose в `/opt/hermes/email-assistant/vikunja/` | Порт 3456 (внутр.), наружу: `https://tasks.nixg.ru` (Caddy → 10.8.0.2:3456). Данные: volumes `vikunja-db` (postgres) + `vikunja-files` |
|
| **Vikunja (трекер задач)** | ~~bigbox, compose в `/opt/hermes/email-assistant/vikunja/`~~ | ⛔ **ВЫВЕДЕНА** (change `remove-vikunja-use-radicale-tasks`). Задачи → Radicale VTODO (календарь «Задачи»). |
|
||||||
| **WG (сеть хостов)** | 10.8.0.0/24 | bigbox = 10.8.0.2 (цель reverse-proxy с vps02), vps01 = .1, vps03 = .3, vps02 = .4 |
|
| **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) |
|
| **SSH-ключи** | `/home/estorozhenko/.ssh/` | vps01_key (vps01), cloudruVPS (vps02), hostkeyVPS (vps03/root) |
|
||||||
|
|
||||||
**Домены (публичный DNS):** cal.nixg.ru → 87.242.100.206 (vps02/Caddy → bigbox Radicale 5232), tasks.nixg.ru → 87.242.100.206 (Caddy → bigbox Vikunja 3456).
|
**Домены (публичный DNS):** cal.nixg.ru → 87.242.100.206 (vps02/Caddy → bigbox Radicale 5232). **tasks.nixg.ru — НЕ используется** (Vikunja выведена).
|
||||||
|
|
||||||
- **Himalaya:** `~/.config/himalaya/config.toml`
|
- **Himalaya:** `~/.config/himalaya/config.toml`
|
||||||
- **Аккаунт:** `vinogorod`, IMAP `mail.corpoffice.tech:143` (STARTTLS)
|
- **Аккаунт:** `vinogorod`, IMAP `mail.corpoffice.tech:143` (STARTTLS)
|
||||||
|
|||||||
@@ -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 | Фаза 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 | Анализ 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 | Задача 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: коллекции Radicale (Личный/Рабочий/Задачи) — MKCOL 403, ждёт решения способа | 🔵 заблокировано | |
|
||||||
| 2026-09-11 | Задача 2: Vikunja (docker :3456, postgres) | 🔵 открыта | |
|
| 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) | 🔵 открыта | |
|
| 2026-09-11 | Задача 2: Caddy reverse proxy (cal.nixg.ru, tasks.nixg.ru) — только cal.nixg.ru | 🔵 открыта | |
|
||||||
| 2026-09-11 | Задача 3: Android-синхронизация (DAVx5 → Radicale, Vikunja app) | 🔵 открыта | |
|
| 2026-09-11 | Задача 3: Android-синхронизация (DAVx5 → Radicale, задачи VTODO через jtx board) | 🔵 открыта | |
|
||||||
| 2026-09-11 | Задача 1: веб-интерфейс (FastAPI, список писем, перемещение, тэги) | 🔵 открыта | |
|
| 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-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), события/задачи ещё не проверены в приложении | 🔵 открыта | |
|
||||||
+156
@@ -92,3 +92,159 @@ postgres), Caddy (cal.nixg.ru, tasks.nixg.ru), Android.
|
|||||||
- `PLAN_WEBUI.md` — untracked (не закоммичен).
|
- `PLAN_WEBUI.md` — untracked (не закоммичен).
|
||||||
- Cron mail-index-incremental и digest-weekly — не настроены.
|
- Cron mail-index-incremental и digest-weekly — не настроены.
|
||||||
- Push mirror gitea → gitverse — не настроен (нужен токен [REDACTED]).
|
- 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: новый классификатор/обработчики
|
||||||
+46
@@ -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** вложения не скачиваются повторно (идемпотентность)
|
||||||
+57
@@ -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`, а проход продолжается
|
||||||
+63
@@ -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)
|
# Proposal: Локальные сервисы календаря (Radicale) и задач (Vikunja)
|
||||||
|
|
||||||
|
> ⚠️ **SUPERSEDED (2026-09-13):** Часть про **Vikunja** заменена чейнджем
|
||||||
|
> `remove-vikunja-use-radicale-tasks` — Vikunja выведена из проекта (лишняя
|
||||||
|
> сущность), задачи ведутся через **Radicale VTODO** (календарь «Задачи»).
|
||||||
|
> Radicale-часть актуальна.
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
Для синхронизации календаря и задач с Android-телефоном нужны локальные
|
Для синхронизации календаря и задач с Android-телефоном нужны локальные
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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`, а проход продолжается
|
||||||
@@ -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`, повторная попытка возможна
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
# email-storage-format Specification
|
# email-storage-format Specification
|
||||||
|
|
||||||
## Purpose
|
## 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
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -308,6 +308,8 @@ def get_date_from_path(path):
|
|||||||
def save_progress(contacts_db, contacts_dir, processed_uids, processed_emails):
|
def save_progress(contacts_db, contacts_dir, processed_uids, processed_emails):
|
||||||
"""Инкрементальное сохранение контактов и last_scan."""
|
"""Инкрементальное сохранение контактов и last_scan."""
|
||||||
contacts_db["contacts"].sort(key=lambda c: c.get("email", ""))
|
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 / "contacts.json", contacts_db)
|
||||||
save_json(contacts_dir / "index.json", contacts_db.get("by_email", {}))
|
save_json(contacts_dir / "index.json", contacts_db.get("by_email", {}))
|
||||||
generate_vcard(contacts_dir, contacts_db["contacts"])
|
generate_vcard(contacts_dir, contacts_db["contacts"])
|
||||||
@@ -485,6 +487,7 @@ def scan(limit=0):
|
|||||||
if save_counter >= 5:
|
if save_counter >= 5:
|
||||||
save_counter = 0
|
save_counter = 0
|
||||||
contacts_db["contacts"].sort(key=lambda c: c.get("email", ""))
|
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 / "contacts.json", contacts_db)
|
||||||
save_json(contacts_dir / "index.json", contacts_db.get("by_email", {}))
|
save_json(contacts_dir / "index.json", contacts_db.get("by_email", {}))
|
||||||
generate_vcard(contacts_dir, contacts_db["contacts"])
|
generate_vcard(contacts_dir, contacts_db["contacts"])
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Executable
+12
@@ -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
@@ -398,18 +398,34 @@ def make_email_md(meta, extra_headers, body, folder_name):
|
|||||||
|
|
||||||
|
|
||||||
def get_attachments(uid, folder, dest_dir):
|
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:
|
try:
|
||||||
run_cmd(
|
run_cmd(
|
||||||
HIMALAYA_CMD + [
|
HIMALAYA_CMD + [
|
||||||
"attachment", "download", str(uid),
|
"attachment", "download", str(uid),
|
||||||
"--folder", folder,
|
"--folder", folder,
|
||||||
"--dir", str(dest_dir),
|
"--downloads-dir", str(dest_dir),
|
||||||
],
|
],
|
||||||
timeout=60,
|
timeout=60,
|
||||||
)
|
)
|
||||||
except RuntimeError:
|
except RuntimeError as e:
|
||||||
pass # нет вложений — норм
|
# Нет вложений / письмо не имеет вложений — норм для has_attachment=false.
|
||||||
|
# Но если письмо помечено has_attachment=true, а скачать не вышло —
|
||||||
|
# оставляем пустую папку и пишем warning (письмо не теряется).
|
||||||
|
print(f" [WARN] вложения не скачаны: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def archive_folder(folder, limit=100):
|
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 ""
|
date_str = env.get("date") or env.get("internal_date") or ""
|
||||||
year, month = parse_date(date_str)
|
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)
|
msg_dir = ARCHIVE_ROOT / folder / f"{year:04d}" / f"{month:02d}" / str(uid)
|
||||||
email_path = msg_dir / "email.md"
|
email_path = msg_dir / "email.md"
|
||||||
attachments_dir = msg_dir / "attachments"
|
|
||||||
|
|
||||||
# Проверка — уже сохранено
|
# Проверка — уже сохранено
|
||||||
if email_path.exists():
|
if email_path.exists():
|
||||||
@@ -467,6 +482,12 @@ def archive_folder(folder, limit=100):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
msg_dir.mkdir(parents=True, exist_ok=True)
|
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)
|
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")
|
email_path.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
# Вложения
|
# Вложения
|
||||||
|
if attachments_dir is not None:
|
||||||
get_attachments(uid, folder, attachments_dir)
|
get_attachments(uid, folder, attachments_dir)
|
||||||
|
|
||||||
subj = (env.get("subject") or "")[:60]
|
subj = (env.get("subject") or "")[:60]
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
# Vikunja — трекер задач (WebUI + REST API)
|
|
||||||
# Порт: 3456 (локально), наружу — Caddy: tasks.nixg.ru → 10.8.0.2:3456
|
|
||||||
# Все данные — в локальных папках рядом с compose (НЕ named volumes):
|
|
||||||
# ./db — PostgreSQL (pgdata)
|
|
||||||
# ./files — загрузки Vikunja (аватары, вложения)
|
|
||||||
# Секреты: .env (VIKUNJA_DATABASE_PASSWORD, VIKUNJA_SERVICE_JWTSECRET)
|
|
||||||
|
|
||||||
services:
|
|
||||||
db:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
container_name: vikunja-db
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_PASSWORD: ${VIKUNJA_DATABASE_PASSWORD}
|
|
||||||
POSTGRES_DB: vikunja
|
|
||||||
POSTGRES_USER: vikunja
|
|
||||||
volumes:
|
|
||||||
- ./db:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U vikunja -d vikunja"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
vikunja:
|
|
||||||
image: vikunja/vikunja:latest
|
|
||||||
container_name: vikunja
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
VIKUNJA_DATABASE_TYPE: postgres
|
|
||||||
VIKUNJA_DATABASE_HOST: db
|
|
||||||
VIKUNJA_DATABASE_PORT: 5432
|
|
||||||
VIKUNJA_DATABASE_DATABASE: vikunja
|
|
||||||
VIKUNJA_DATABASE_USERNAME: vikunja
|
|
||||||
VIKUNJA_DATABASE_PASSWORD: ${VIKUNJA_DATABASE_PASSWORD}
|
|
||||||
VIKUNJA_SERVICE_JWTSECRET: ${VIKUNJA_SERVICE_JWTSECRET}
|
|
||||||
VIKUNJA_SERVICE_FRONTENDURL: https://tasks.nixg.ru
|
|
||||||
VIKUNJA_SERVICE_PUBLICURL: https://tasks.nixg.ru
|
|
||||||
ports:
|
|
||||||
- "3456:80"
|
|
||||||
volumes:
|
|
||||||
- ./files:/app/files
|
|
||||||
Reference in New Issue
Block a user