Files
email-assistant/STATUS.md
T
hermes 7550aff102 feat: классификация писем Qwen3:8b + обработчики (urgent→TG, task→VTODO, meeting→VEVENT)
- mail_archive.py: фикс вложений himalaya --dir → --downloads-dir; attachments/ только при has_attachment; идемпотентно
- email_classifier.py: Qwen3:8b (Ollama) → теги info/urgent/task/meeting в frontmatter email.md
- email_handlers.py: urgent→Telegram (Bot API+SOCKS5), task→Radicale VTODO, meeting→Radicale VEVENT; handled_* идемпотентность; --dry-run
- .gitignore: игнор *.env.bak*
- openspec: чейндж email-classification-handlers (в работе)
- STATUS/TODO/WALKTHROUGH: прогресс сессии, подводные камни Radicale (http.client, Depth:1, формат дат)
2026-09-13 20:35:58 +00:00

320 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Email Assistant — локальный архив и ассистент почты
**Дата:** 2026-09-13
**Фаза:** 1.5–1.7 + Портфель веб-UI (планирование) + Классификация/обработчики (в работе)
**Стек:** Himalaya CLI → Python → SQLite → Ollama (Qwen3:8b) → Radicale (CalDAV) → Telegram
---
## Архитектура проекта
```
/opt/hermes/email-assistant/
├── STATUS.md # этот файл
├── config/
│ ├── himalaya-config.toml # Himalaya IMAP-конфиг
│ └── contacts-cron.sh # обёртка для cron контактов
├── scripts/
│ ├── mail_archive.py # инкрементальный архиватор писем с IMAP
│ ├── mail-archive.sh # shell-обёртка для systemd/cron
│ ├── migrate_to_email_md.py # конвертер meta.json→email.md (deprecated)
│ ├── mail_index.py # SQLite FTS5-индекс всех писем
│ ├── contacts_extractor.py # извлечение контактов через LLM
│ ├── sqlite_search.py # FTS5-поиск по архиву
│ ├── digest.py # еженедельный дайджест почты
│ └── mail_archive.py # основной архиватор
│
├── context/
│ ├── CONTEXT.md # архитектура и план развития
│ ├── SKILL.md # навык для Hermes (email-local-archive)
│ └── CONTACTS.md # описание contacts extractor
/opt/hermes/email/ # архив писем (локальный диск)
├── INBOX/
│ └── YYYY/MM/UID/email.md # YAML-frontmatter + тело
├── Sent/
├── Отправленные/
├── Archive/
├── state/ # mail-archive-last-*.json (last_uid per folder)
├── contacts/ # адресная книга
│ ├── contacts.json # полная база контактов
│ ├── index.json # email → contact_id
│ ├── contacts.vcf # vCard 4.0 для импорта
│ └── last_scan.json # трекинг обработанных
├── digests/ # еженедельные дайджесты
│ └── digest-YYYY-MM-DD.md
└── mail_index.db # SQLite + FTS5 (~5.4 MB)
Hermes cron:
- mail-archive-every-5min (no-agent, скрипт)
- contacts-extractor-every-30m (скрипт, --limit 15)
- digest: пока не поставлен
- mail_index --incremental: пока не поставлен
```
---
## Статус задач
### Фаза 0.5: Рефакторинг формата хранения ✅
- [x] Перейти с meta.json + body.md на один `email.md` с YAML-frontmatter
- [x] Полные заголовки в frontmatter (Message-ID, References, In-Reply-To, CC, Content-Type)
- [x] State файлы в `/opt/hermes/email/state/`
### Фаза 1: Локальный архив ✅
- [x] Структура `/opt/hermes/email-assistant/`
- [x] Himalaya (IMAP mail.corpoffice.tech:143 STARTTLS)
- [x] `mail_archive.py` — инкрементальный архиватор
- [x] Первый запуск: INBOX 585, Sent 515, Отправленные 510, Archive 373
- [x] systemd user timer + Hermes cron (every 5m)
### Фаза 1.5: Индексация, поиск и дайджесты ✅⬜
- [x] `mail_index.py` — SQLite-индекс всех email.md (FTS5 + трекинг контактов)
- [x] `sqlite_search.py` — CLI-поиск по FTS5 (поддержка фильтров from:/subject:/folder)
- [x] `digest.py` — еженедельный дайджест через LLM (Qwen3:8b)
- [ ] Поставить cron на `mail_index.py --incremental` (раз в 5-10 мин)
- [ ] Поставить cron на `digest.py` (раз в неделю)
### Фаза 1.7: Динамическое обнаружение всех подпапок INBOX ✅
- [x] `mail_archive.py` — список вложенных папок INBOX захардкожен (18 шт.), но на сервере их **137** (включая многоуровневые: INBOX/!Персонал/ОТ и ТБ, INBOX/Бюджет/Винный город/CAPEX 2025, INBOX/Контрагенты/iiko/Тихая гавань и т.д.)
- [x] `--all` сейчас использует тот же хардкод — не архивирует ~120 подпапок
- [x] Требуется: динамическое обнаружение IMAP-папок через `himalaya folder list`, рекурсивный обход всех подпапок INBOX (любой глубины), автоматическая архивация новых подпапок при их создании
- [x] `mail-archive-every-5min` cron должен обновлять список папок динамически, а не из хардкода
- [x] **Реализовано (2026-09-11):** `get_inbox_subfolders()` через `himalaya folder list` — динамически находит **136** подпапок INBOX (глубина до 3), fallback на хардкод при ошибке. `--all` использует `FOLDERS + get_inbox_subfolders()`. Проверено: 140 папок в списке, smoke-тест на реальном запуске. Таймаут envelope list поднят до 180с (INBOX 14k писем >60с).
### Фаза 1.6: Адресная книга (Contacts Extractor) ✅⬜
- [x] `contacts_extractor.py` — извлечение контактов из подписей через LLM
- [x] clean_body — удаление цитируемой переписки (Outlook/forwards/>)
- [x] SQLite-трекинг обработанных писем (contacts_extracted / contacts_skipped)
- [x] Инкрементальное сохранение каждые 5 писем
- [x] `--limit N` для дозированной обработки
- [x] vCard 4.0 генерация
- [x] Cron already set: `contacts-extractor-every-30m` (--limit 15)
- [ ] Проверить качество извлечения: сейчас 5 контактов найдено, 5 skipped
- [ ] Доделать парсинг темы письма (некоторые темы содержат вшитые заголовки)
### Фаза 2: Векторизация и поиск ⬜
- [ ] Выбор векторизатора (bge-m3 через Ollama — уже есть в Memory OS)
- [ ] Индексация body в Qdrant
- [ ] Поиск по письмам через агента
### Фаза 3: Граф знаний ⬜
- [ ] Извлечение связанных сущностей (отправители, темы, проекты)
---
## Портфель: Веб-интерфейс + Календарь/Задачи (2026-09-11)
План: `PLAN_WEBUI.md`. Порядок: Задача 4 → 2 → 3 → 1 → 6 → 7.
### Задача 4: Анализ ФС vs Maildir ✅ (закрыта 2026-09-11)
- [x] `STORAGE_ANALYSIS.md` — сравнение email.md/Maildir/MBOX/notmuch по 7 критериям
- [x] Рекомендация: **остаться на email.md** + добавить `tags: []` в frontmatter + опц. экспорт в Maildir
- [x] Ссылка в README; change `email-storage-analysis` заархивирован (openspec-lab)
- [x] Факты: 4884 письма, 76 МБ, 81 контакт
### Задача 2: Локальный календарь + трекер задач 🔵 (в работе)
- [x] Решение пользователя: **Radicale (CalDAV) + Vikunja (трекер)**, всё в Docker-контейнерах
- [x] **РЕШЕНИЕ 2026-09-13: Vikunja — ЛИШНЯЯ СУЩНОСТЬ, задачи через Radicale VTODO** (change `remove-vikunja-use-radicale-tasks`). Radicale из коробки умеет VTODO (календарь «Задачи»), jtx board читает их по CalDAV. Vikunja выводится из эксплуатации.
- [x] Change `local-calendar-tasks` создан и валиден — proposal/specs/design/tasks (Radicale-часть актуальна, Vikunja-часть — SUPERSEDED)<br>
- [x] **Radicale развёрнут и РАБОТАЕТ (2026-09-13)**: контейнер на :5232, PROPFIND 207 с паролем / 401 без. Коллекции Личный/Рабочий/Задачи на ФС.
- [x] **CardDAV-синк контактов (2026-09-13, change `contacts-caldav-server`)**: `scripts/contacts_caldav_sync.py` — двусторонний sync. PUSH: 81 контакт → vCard в Radicale. PULL: правки/создание/удаление карточек с телефона → contacts.json. Идемпотентно (162 unchanged, 0 PUT на повторе). Конфликты (412) — приоритет телефону, локальная версия в `caldav-sync.log`. Подробнее: `scripts/contacts_caldav_sync.py --help`, лог `/opt/hermes/email/contacts/caldav-sync.log`.
- [x] **Vikunja ВЫВЕДЕНА ИЗ ЭКСПЛУАТАЦИИ (2026-09-13)**: контейнеры vikunja + vikunja-db удалены (`docker compose down -v`), каталог `/opt/hermes/email-assistant/vikunja/` удалён, порт 3456 свободен. Tasks.nixg.ru закомментирован в Caddy (строки 114-120), Caddy перезагружен (бэкап Caddyfile.bak-vikunja-removed). |
- [x] **Caddy reverse proxy (cal.nixg.ru → 5232)** — РАБОТАЕТ (2026-09-13): PROPFIND 207 снаружи. tasks.nixg.ru закомментирован.
- [x] **«Обход в Глории»** — повторяющееся событие (Рабочий, VTIMEZONE Europe/Moscow, RRULE WEEKLY BYDAY=TU 11:00), подтверждено на телефоне (GMT+3 ✓)
### Задача 3: Нативная синхронизация с Android 🔵 (в работе)
- [x] **Контакты синхронизированы** (DAVx5 → Radicale «Контакты»; CardDAV-sync двусторонний, change `contacts-caldav-server`)
- [x] **Caddy reverse proxy (cal.nixg.ru → 5232)** — работает, PROPFIND 207 снаружи
- [x] **«Обход в Глории»** — VEVENT подтверждён на телефоне (GMT+3 ✓)
- [ ] **Проверить появление событий/задач в приложении** (тестовый VEVENT obhod-v-glorii-2026.ics в «Рабочий», тестовый VTODO test-vikunja-removal-2026 в «Задачи») — контакты синхронизируются, события/задачи на телефоне пока не проверены
- [ ] Двусторонняя синхронизация: событие/задача с телефона → bigbox → база
### Задача 8: Классификация писем и обработчики 🔵 (в работе, change `email-classification-handlers`)
- [x] **Вложения**: фикс бага `himalaya --dir` → `--downloads-dir`; вложения в `<msg_dir>/attachments/`; идемпотентно (2026-09-13 вечер, проверено на живом письме)
- [x] **Классификатор**: `scripts/email_classifier.py` — Qwen3:8b (Ollama localhost:11434) → теги info/urgent/task/meeting + `classification`/`classification_reason` в frontmatter; идемпотентно; прогон прошёл (письмо 422 → task,meeting)
- [x] **Обработчики**: `scripts/email_handlers.py` — urgent→Telegram (Bot API+SOCKS5), task→Radicale VTODO («Задачи»), meeting→Radicale VEVENT («Рабочий»), info→ничего; идемпотентно через `handled_*`; dry-run чист (VTODO 204/VEVENT 201)
- [ ] Живой прогон обработчиков без --dry-run + проверка доставки urgent→Telegram
- [ ] Cron: классификация/обработчики после `mail-archive`
- [ ] Telegram-секреты (chat_id) в `.env` (токен — `VESTI_BOT_TOKEN` из /opt/vesti/.env, канал-дефолт `@dedinit_vesti`)
### Задача 1: Веб-интерфейс ассистента ⬜
- [ ] FastAPI + SQLite FTS5: список писем (дата/адресант/тэги/папка)
- [ ] Перемещение в папку; тэги
- [ ] Кнопка «Создать задачу» → Radicale VTODO (Задача 6, вместо Vikunja API)
- [ ] Страница авторизации (Задача 7)
---
## Решения и проблемы скриптов
### `mail_archive.py` — Инкрементальный архиватор
**Задача:** Качать письма с IMAP, сохранять в `email.md` с YAML-frontmatter.
**Решение:**
- Для каждой папки хранится `last_uid` в `/opt/hermes/email/state/mail-archive-last-<folder>.json`
- `--limit N` — максимум писем за проход (по умолчанию 200)
- `--drain` — скачивать ВСЮ почту до конца: повторять проходы по каждой папке, пока за проход не обработано 0 писем. Нужен, когда новых писем накопилось больше батча (`--limit`) — скрипт сам себя повторяет до полного осущения папки, а не оставляет хвост до следующего запуска. Предохранитель от бесконечного цикла (10 000 проходов).
- `himalaya envelope --page-size 500` для быстрой загрузки списка писем
- Каждое письмо: `himalaya get <uid> | email-to-md.py` → `email.md`
- Инкрементально: добавляет все uid > last_uid, обновляет last_uid
- Проблема: Himalaya v1.2.0 не поддерживает `danger_accept_invalid_certs` — используем `mail.corpoffice.tech` (валидный сертификат)
### `mail_index.py` — SQLite-индекс
**Задача:** Быстрый полнотекстовый поиск по архиву, трекинг обработки контактов.
**Решение:**
- SQLite с FTS5 (unicode61 tokenizer) — 4 таблицы: `emails`, `email_fts`, триггеры синхронизации
- Индексирует: path, uid, folder, date, from, to, subject, body_preview (первые 500 символов)
- Поля `contacts_extracted` / `contacts_skipped` для совместной работы с contacts_extractor
- Режимы: полная переиндексация (`--incremental` игнорирует mtime), поиск (`--search`), статистика (`--stats`)
- Инкрементальный режим: проверяет `file_mtime` — пропускает неизменённые файлы
### `contacts_extractor.py` — Извлечение контактов
**Задача:** Найти в подписи письма имя, должность, телефон, компанию отправителя.
**Решение:**
- Берёт необработанные письма из SQLite (WHERE contacts_extracted=0 AND contacts_skipped=0)
- `clean_body()`: удаляет HTML-теги, трекинг-ссылки, цитируемую переписку (Outlook-заголовки `От:`, `From:`, `Sent:`; forwarded; `>` quotes)
- Отдаёт очищенный текст Qwen3:8b (Ollama, temperature=0.1)
- LLM возвращает JSON: full_name, email, phone, position, company, address, raw_signature
- Дедупликация по email: при повторной встрече обновляет поля
- Инкрементальное сохранение: каждые 5 писем пишет contacts.json + contacts.vcf
- **Текущая проблема:** clean_body может вырезать подпись вместе с цитатами (см. ниже)
### `sqlite_search.py` — FTS5-поиск
**Задача:** Быстрый поиск по архиву писем из консоли.
**Решение:**
- FTS5-запрос к mail_index.db через SQL MATCH
- Поддержка синтаксиса: `"точная фраза"`, `OR`, `-исключение`, `префикс*`
- Пользовательские префиксы: `from:user@mail`, `subject:отчёт` → LIKE-фильтр в WHERE
- `--folder INBOX/!Отчеты` — фильтр по папке
- `--body` — включает тело письма в поиск (медленнее, но полнее)
- Вывод: дата, папка, отправитель, тема, полный путь к файлу
### `digest.py` — Еженедельный дайджест
**Задача:** Сгенерировать краткое резюме всех писем за N дней для руководителя.
**Решение:**
- SQLite-запрос: письма за последние N дней (по дате из frontmatter)
- Группировка по папкам (INBOX/!ВГ Чек листы → "ВГ Чек листы")
- Вызов Qwen3:8b с промптом: "напиши краткий дайджест на русском для руководителя"
- LLM выделяет: общую статистику, темы по папкам, важные отправители
- Сохраняет в `/opt/hermes/email/digests/digest-YYYY-MM-DD.md`
- Режимы: `stdout`, `file`, `both`
---
## Проблема: clean_body вырезает подпись вместе с цитатой
**Корень:** В письмах с цепочкой ответов (Outlook forwarding) подпись отправителя часто находится **после** маркера `От: Стороженко... Отправлено:...`, но до конца цитаты. clean_body отрезает всё от первого найденного маркера, теряя подпись.
**Текущее решение (итерация):**
1. Ищем самый ранний маркер цитирования среди всех паттернов (не первый совпавший)
2. Fallback: если ни один маркер не сработал — ищем `От: / From: / Subject:` в последних 500 символах
**Что ещё можно сделать:**
- Двухпроходная очистка: сначала отрезать цепочки forward-заголовков, потом отделять подпись от тела
- Определять границу подписи по паттернам `С уважением,` / `Best regards,` / `—` — она ближе к концу
- Использовать LLM не только для извлечения, но и для нахождения подписи
---
## Текущие метрики
| Папка | Писем (2026-09-11) |
|-------|---------------------|
| INBOX (вкл. подпапки) | 2674 |
| Archive | 876 |
| Отправленные | 790 |
| Sent | 544 |
| **Всего** | **4884** (76 МБ, mail_index.db 5.4 MB) |
Контакты: **81** в contacts.json (не «5» — устарело; проверено 2026-09-11).
---
## Cron-задачи (Hermes)
| ID | Имя | Расписание | Тип | Статус |
|----|-----|-----------|-----|--------|
| 5f2305b2bbf8 | mail-archive-every-5min | every 5m | no-agent (скрипт) | ✅ (Фаза 1.7: использует `--all --drain` с динамическим списком) |
| ea0fd1ab4f93 | contacts-extractor-every-30m | every 30m | скрипт (--limit 15) | ✅ |
| — | mail-index-incremental | not set | — | ❌ |
| — | digest-weekly | not set | — | ❌ |
---
## DAVx⁵ (Android: CalDAV/CardDAV-мост)
**Назначение:** DAVx⁵ — приложение-синхронизатор для Android, **не имеет собственного UI** для просмотра событий/контактов, а встраивается в стандартные системные приложения Android (Календарь, Контакты). Это стандарт де-факто для синхронизации с Radicale на Android.
- **Где взять:** F-Droid (бесплатно) или Google Play (платно, поддержка разработчиков).
- **Как работает:** добавляете аккаунт (URL сервера Radicale, логин, пароль) → DAVx⁵ сам находит доступные календари и адресные книги → выбираете, что синхронизировать с системой.
- **Для задач** пользователь поставил **jtx board** (не DAVx⁵).
### Настройка Caddy для Radicale
Проксирование Radicale через Caddy требует внимания к путям и заголовкам, иначе CalDAV/CardDAV-клиенты не найдут ресурсы.
**Рабочий пример для домена `dav.example.com` (Radicale в корне):**
```caddyfile
dav.example.com {
# Важно: сохраняем заголовок Authorization для Radicale
header_up Authorization {header.Authorization}
# Если Radicale в подпапке — используйте handle_path (см. ниже)
reverse_proxy localhost:5232
}
```
**Ключевые моменты:**
1. **Сохранение Authorization:** Caddy по умолчанию может удалять заголовки. `header_up Authorization {header.Authorization}` гарантирует, что Radicale получит логин/пароль.
2. **X-Script-Name (если в подпапке):** для размещения по `/radicale` нужен `handle_path` для удаления префикса пути + заголовок `X-Script-Name`, чтобы Radicale знал о своём расположении.
3. **Обязательный `handle_path` для подпапки:** простой `reverse_proxy` внутри `handle` может не сработать — Radicale ожидает запросы без префикса (получает его через `X-Script-Name`).
**Пример для подпапки `/radicale`:**
```caddyfile
dav.example.com {
handle_path /radicale/* {
header_up Authorization {header.Authorization}
header_up X-Script-Name /radicale
reverse_proxy localhost:5232
}
}
```
**В DAVx⁵ указывается базовый URL** (например, `https://dav.example.com` или `https://dav.example.com/radicale`); пути к календарям/контактам приложение определяет автоматически.
**Наш случай (следующая сессия):** поддомен `cal.nixg.ru` (Caddy на vps02, `reverse_proxy 10.8.0.2:5232` к bigbox) — Radicale слушает 127.0.0.1:5232, user `estorozhenko`, пароль `RADICALE_PASS` в `radicale/.env`. Адресная книга: `Контакты` (кириллица в URL — DAVx5 умеет). **Задачи: Radicale VTODO (календарь «Задачи») → jtx board** (Vikunja выведена, change `remove-vikunja-use-radicale-tasks`).
---
## Конфигурация
- **Репозиторий:** `https://gitea.nixg.ru/hermes/email-assistant`
- **Push mirror на gitverse.ru:** ❌ не настроен
- [ ] Настроить push mirror из gitea.nixg.ru в gitverse.ru
- Требуется: создать репозиторий на gitverse.ru, получить токен, настроить mirror в настройках gitea (Settings → Git Hooks/Mirrors → Add Push Mirror)
### Ресурсы проекта (расположение и доступ)
| Ресурс | Где живёт | Доступ |
|--------|-----------|--------|
| **Caddy (reverse proxy)** | vps02 = 87.242.100.206, контейнер `caddy` | SSH: `ssh vps02` (alias в `~/.ssh/config`, User estorozhenko, ключ cloudruVPS). Caddyfile: `/opt/caddy/Caddyfile` (root; правка через `sudo`). Reload: `sudo docker exec caddy caddy reload --config /etc/caddy/Caddyfile` |
| **Radicale (CalDAV)** | bigbox, контейнер `radicale`, порт 5232 | WGET: `http://127.0.0.1:5232` (с bigbox), наружу: `https://cal.nixg.ru`. Конфиг: `/opt/hermes/email-assistant/radicale/` (compose.yml, .env — пароль `RADICALE_PASS`). Коллекции на ФС: `/opt/hermes/email-assistant/radicale/data/collections/collection-root/estorozhenko/{Личный,Рабочий,Задачи}` |
| **Vikunja (трекер задач)** | ~~bigbox, compose в `/opt/hermes/email-assistant/vikunja/`~~ | ⛔ **ВЫВЕДЕНА** (change `remove-vikunja-use-radicale-tasks`). Задачи → Radicale VTODO (календарь «Задачи»). |
| **WG (сеть хостов)** | 10.8.0.0/24 | bigbox = 10.8.0.2 (цель reverse-proxy с vps02), vps01 = .1, vps03 = .3, vps02 = .4 |
| **SSH-ключи** | `/home/estorozhenko/.ssh/` | vps01_key (vps01), cloudruVPS (vps02), hostkeyVPS (vps03/root) |
**Домены (публичный DNS):** cal.nixg.ru → 87.242.100.206 (vps02/Caddy → bigbox Radicale 5232). **tasks.nixg.ru — НЕ используется** (Vikunja выведена).
- **Himalaya:** `~/.config/himalaya/config.toml`
- **Аккаунт:** `vinogorod`, IMAP `mail.corpoffice.tech:143` (STARTTLS)
- **Почта:** `e.storozhenko@vinogorod.ru`
- **LLM:** Qwen3:8b (Ollama localhost:11434)
- **SMTP:** не настроен
- **Файлы state:** `/opt/hermes/email/state/`