# Email Assistant — локальный архив и ассистент почты **Дата:** 2026-09-11 **Фаза:** 1.5–1.7 + Портфель веб-UI (планирование) **Стек:** Himalaya CLI → Python → SQLite → Ollama (Qwen3:8b) → Yandex Disk --- ## Архитектура проекта ``` /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] Change `local-calendar-tasks` создан и валиден (опenspec-lab) — proposal/specs/design/tasks - [x] **Radicale развёрнут**: контейнер, порт 5232, HTTP Basic (estorozhenko), PROPFIND → 207, без пароля → 401 - [ ] Коллекции Radicale (Личный/Рабочий/Задачи) — создание через MKCOL вернуло 403 (Radicale 3.x создаёт коллекции иначе: PUT ресурса); **заблокировано ожиданием решения** - [ ] Vikunja — не начат (docker compose + postgres, порт 3456) - [ ] Caddy reverse proxy (cal.nixg.ru → 5232, tasks.nixg.ru → 3456) - [ ] Android-синхронизация (DAVx5) ### Задача 3: Нативная синхронизация с Android ⬜ (после Задачи 2) - [ ] DAVx5 на телефоне → Radicale; Vikunja app/token - [ ] Проверка двусторонней синхронизации (событие с телефона → bigbox) ### Задача 1: Веб-интерфейс ассистента ⬜ - [ ] FastAPI + SQLite FTS5: список писем (дата/адресант/тэги/папка) - [ ] Перемещение в папку; тэги - [ ] Кнопка «Создать задачу» → в Vikunja API (Задача 6) - [ ] Страница авторизации (Задача 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) | ✅ | | — | mail-index-incremental | not set | — | ❌ | | — | digest-weekly | not set | — | ❌ | --- ## Конфигурация - **Репозиторий:** `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/radicale/` (compose.yml, .env — пароль `RADICALE_PASS`). Коллекции на ФС: `/opt/hermes/radicale/data/collections/collection-root/estorozhenko/{Личный,Рабочий,Задачи}` | | **Vikunja (трекер задач)** | bigbox, compose в `/opt/hermes/vikunja/` | Порт 3456 (внутр.), наружу: `https://tasks.nixg.ru` (Caddy → 10.8.0.2:3456). Данные: volumes `vikunja-db` (postgres) + `vikunja-files` | | **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 → 87.242.100.206 (Caddy → bigbox Vikunja 3456). - **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/`