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

26 KiB
Raw Blame History

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: Рефакторинг формата хранения ✅

  • Перейти с meta.json + body.md на один email.md с YAML-frontmatter
  • Полные заголовки в frontmatter (Message-ID, References, In-Reply-To, CC, Content-Type)
  • State файлы в /opt/hermes/email/state/

Фаза 1: Локальный архив ✅

  • Структура /opt/hermes/email-assistant/
  • Himalaya (IMAP mail.corpoffice.tech:143 STARTTLS)
  • mail_archive.py — инкрементальный архиватор
  • Первый запуск: INBOX 585, Sent 515, Отправленные 510, Archive 373
  • systemd user timer + Hermes cron (every 5m)

Фаза 1.5: Индексация, поиск и дайджесты ✅⬜

  • mail_index.py — SQLite-индекс всех email.md (FTS5 + трекинг контактов)
  • sqlite_search.py — CLI-поиск по FTS5 (поддержка фильтров from:/subject:/folder)
  • digest.py — еженедельный дайджест через LLM (Qwen3:8b)
  • Поставить cron на mail_index.py --incremental (раз в 5-10 мин)
  • Поставить cron на digest.py (раз в неделю)

Фаза 1.7: Динамическое обнаружение всех подпапок INBOX ✅

  • mail_archive.py — список вложенных папок INBOX захардкожен (18 шт.), но на сервере их 137 (включая многоуровневые: INBOX/!Персонал/ОТ и ТБ, INBOX/Бюджет/Винный город/CAPEX 2025, INBOX/Контрагенты/iiko/Тихая гавань и т.д.)
  • --all сейчас использует тот же хардкод — не архивирует ~120 подпапок
  • Требуется: динамическое обнаружение IMAP-папок через himalaya folder list, рекурсивный обход всех подпапок INBOX (любой глубины), автоматическая архивация новых подпапок при их создании
  • mail-archive-every-5min cron должен обновлять список папок динамически, а не из хардкода
  • Реализовано (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) ✅⬜

  • contacts_extractor.py — извлечение контактов из подписей через LLM
  • clean_body — удаление цитируемой переписки (Outlook/forwards/>)
  • SQLite-трекинг обработанных писем (contacts_extracted / contacts_skipped)
  • Инкрементальное сохранение каждые 5 писем
  • --limit N для дозированной обработки
  • vCard 4.0 генерация
  • 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)

  • STORAGE_ANALYSIS.md — сравнение email.md/Maildir/MBOX/notmuch по 7 критериям
  • Рекомендация: остаться на email.md + добавить tags: [] в frontmatter + опц. экспорт в Maildir
  • Ссылка в README; change email-storage-analysis заархивирован (openspec-lab)
  • Факты: 4884 письма, 76 МБ, 81 контакт

Задача 2: Локальный календарь + трекер задач 🔵 (в работе)

  • Решение пользователя: Radicale (CalDAV) + Vikunja (трекер), всё в Docker-контейнерах
  • РЕШЕНИЕ 2026-09-13: Vikunja — ЛИШНЯЯ СУЩНОСТЬ, задачи через Radicale VTODO (change remove-vikunja-use-radicale-tasks). Radicale из коробки умеет VTODO (календарь «Задачи»), jtx board читает их по CalDAV. Vikunja выводится из эксплуатации.
  • Change local-calendar-tasks создан и валиден — proposal/specs/design/tasks (Radicale-часть актуальна, Vikunja-часть — SUPERSEDED)
  • Radicale развёрнут и РАБОТАЕТ (2026-09-13): контейнер на :5232, PROPFIND 207 с паролем / 401 без. Коллекции Личный/Рабочий/Задачи на ФС.
  • CardDAV-синк контактов (2026-09-13, change contacts-caldav-server): scripts/contacts_caldav_sync.py — двусторонний sync. PUSH: 81 контакт → vCard в Radicale. PULL: правки/создание/удаление карточек с телефона → contacts.json. Идемпотентно (162 unchanged, 0 PUT на повторе). Конфликты (412) — приоритет телефону, локальная версия в caldav-sync.log. Подробнее: scripts/contacts_caldav_sync.py --help, лог /opt/hermes/email/contacts/caldav-sync.log.
  • Vikunja ВЫВЕДЕНА ИЗ ЭКСПЛУАТАЦИИ (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). |
  • Caddy reverse proxy (cal.nixg.ru → 5232) — РАБОТАЕТ (2026-09-13): PROPFIND 207 снаружи. tasks.nixg.ru закомментирован.
  • «Обход в Глории» — повторяющееся событие (Рабочий, VTIMEZONE Europe/Moscow, RRULE WEEKLY BYDAY=TU 11:00), подтверждено на телефоне (GMT+3 ✓)

Задача 3: Нативная синхронизация с Android 🔵 (в работе)

  • Контакты синхронизированы (DAVx5 → Radicale «Контакты»; CardDAV-sync двусторонний, change contacts-caldav-server)
  • Caddy reverse proxy (cal.nixg.ru → 5232) — работает, PROPFIND 207 снаружи
  • «Обход в Глории» — VEVENT подтверждён на телефоне (GMT+3 ✓)
  • Проверить появление событий/задач в приложении (тестовый VEVENT obhod-v-glorii-2026.ics в «Рабочий», тестовый VTODO test-vikunja-removal-2026 в «Задачи») — контакты синхронизируются, события/задачи на телефоне пока не проверены
  • Двусторонняя синхронизация: событие/задача с телефона → bigbox → база

Задача 8: Классификация писем и обработчики 🔵 (в работе, change email-classification-handlers)

  • Вложения: фикс бага himalaya --dir → --downloads-dir; вложения в <msg_dir>/attachments/; идемпотентно (2026-09-13 вечер, проверено на живом письме)
  • Классификатор: scripts/email_classifier.py — Qwen3:8b (Ollama localhost:11434) → теги info/urgent/task/meeting + classification/classification_reason в frontmatter; идемпотентно; прогон прошёл (письмо 422 → task,meeting)
  • Обработчики: 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 в корне):

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:

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/