# 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)
- [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`; вложения в `/attachments/`; идемпотентно (2026-09-13 вечер, проверено на живом письме) - [x] **Классификатор**: `scripts/email_classifier.py` — Qwen3:8b (Ollama localhost:11434) → теги info/urgent/task/meeting + `classification`/`classification_reason` в frontmatter; идемпотентно; прогон прошёл (письмо 422 → task,meeting) - [x] **Обработчики**: `scripts/email_handlers.py` — urgent→Telegram (Bot API+SOCKS5), task→Radicale VTODO («Задачи»), meeting→Radicale VEVENT («Рабочий»), info→ничего; идемпотентно через `handled_*` - [x] **Фикс секретов (2026-09-14)**: скрипт теперь сам читает `radicale/.env` (RADICALE_PASS) и `/opt/vesti/.env` (VESTI_BOT_TOKEN) — раньше без ручного export был 401; добавлен stdlib-парсер .env (python-dotenv в системе нет) - [x] **Живой прогон (2026-09-14)**: письмо 2026/422 → VTODO «Задачи» (204) + VEVENT «Рабочий» (204); повтор — идемпотентно (0 дублей) - [x] **Cron (2026-09-14)**: `mail-classify-handlers` (6e1e78ceedfd, every 5m) — классификатор (--limit 10) → обработчики; end-to-end проверено: 5 новых «meeting» → 5 VEVENT (201) - [ ] Живое urgent-письмо → доставка в Telegram (механика готова, токен подхватывается; пока не было urgent-писем) - [x] **Telegram-секреты**: токен `VESTI_BOT_TOKEN` читается из /opt/vesti/.env; канал-дефолт `@dedinit_vesti` (TELEGRAM_CHAT_ID можно переопределить в .env проекта) ### Задача 1: Веб-интерфейс ассистента ⬜ - [ ] FastAPI + SQLite FTS5: список писем (дата/адресант/тэги/папка) - [ ] Перемещение в папку; тэги - [ ] Кнопка «Создать задачу» → Radicale VTODO (Задача 6, вместо Vikunja API) - [ ] Страница авторизации (Задача 7) --- ## Решения и проблемы скриптов ### `mail_archive.py` — Инкрементальный архиватор **Задача:** Качать письма с IMAP, сохранять в `email.md` с YAML-frontmatter. **Решение:** - Для каждой папки хранится `last_uid` в `/opt/hermes/email/state/mail-archive-last-.json` - `--limit N` — максимум писем за проход (по умолчанию 200) - `--drain` — скачивать ВСЮ почту до конца: повторять проходы по каждой папке, пока за проход не обработано 0 писем. Нужен, когда новых писем накопилось больше батча (`--limit`) — скрипт сам себя повторяет до полного осущения папки, а не оставляет хвост до следующего запуска. Предохранитель от бесконечного цикла (10 000 проходов). - `himalaya envelope --page-size 500` для быстрой загрузки списка писем - Каждое письмо: `himalaya get | 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) | ✅ | | 6e1e78ceedfd | mail-classify-handlers | every 5m | скрипт (classifier --limit 10 → handlers) | ✅ (2026-09-14) | | — | mail-index-incremental | not set | — | ❌ | | — | digest-weekly | not set | — | ❌ | --- ## DAVx⁵ (Android: CalDAV/CardDAV-мост) **Назначение:** DAVx⁵ — приложение-синхронизатор для Android, **не имеет собственного UI** для просмотра событий/контактов, а встраивается в стандартные системные приложения Android (Календарь, Контакты). Это стандарт де-факто для синхронизации с Radicale на Android. - **Где взять:** F-Droid (бесплатно) или Google Play (платно, поддержка разработчиков). - **Как работает:** добавляете аккаунт (URL сервера Radicale, логин, пароль) → DAVx⁵ сам находит доступные календари и адресные книги → выбираете, что синхронизировать с системой. - **Для задач** пользователь поставил **jtx board** (не DAVx⁵). ### Настройка Caddy для Radicale Проксирование Radicale через Caddy требует внимания к путям и заголовкам, иначе CalDAV/CardDAV-клиенты не найдут ресурсы. **Рабочий пример для домена `dav.example.com` (Radicale в корне):** ```caddyfile dav.example.com { # Важно: сохраняем заголовок Authorization для Radicale header_up Authorization {header.Authorization} # Если Radicale в подпапке — используйте handle_path (см. ниже) reverse_proxy localhost:5232 } ``` **Ключевые моменты:** 1. **Сохранение Authorization:** Caddy по умолчанию может удалять заголовки. `header_up Authorization {header.Authorization}` гарантирует, что Radicale получит логин/пароль. 2. **X-Script-Name (если в подпапке):** для размещения по `/radicale` нужен `handle_path` для удаления префикса пути + заголовок `X-Script-Name`, чтобы Radicale знал о своём расположении. 3. **Обязательный `handle_path` для подпапки:** простой `reverse_proxy` внутри `handle` может не сработать — Radicale ожидает запросы без префикса (получает его через `X-Script-Name`). **Пример для подпапки `/radicale`:** ```caddyfile dav.example.com { handle_path /radicale/* { header_up Authorization {header.Authorization} header_up X-Script-Name /radicale reverse_proxy localhost:5232 } } ``` **В DAVx⁵ указывается базовый URL** (например, `https://dav.example.com` или `https://dav.example.com/radicale`); пути к календарям/контактам приложение определяет автоматически. **Наш случай (следующая сессия):** поддомен `cal.nixg.ru` (Caddy на vps02, `reverse_proxy 10.8.0.2:5232` к bigbox) — Radicale слушает 127.0.0.1:5232, user `estorozhenko`, пароль `RADICALE_PASS` в `radicale/.env`. Адресная книга: `Контакты` (кириллица в URL — DAVx5 умеет). **Задачи: Radicale VTODO (календарь «Задачи») → jtx board** (Vikunja выведена, change `remove-vikunja-use-radicale-tasks`). --- ## Конфигурация - **Репозиторий:** `https://gitea.nixg.ru/hermes/email-assistant` - **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/`