# Email Assistant — локальный архив и ассистент почты **Дата:** 2026-07-19 **Фаза:** 1.5 — Индексация, поиск, дайджесты + Адресная книга (в работе) **Стек:** 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: Граф знаний ⬜ - [ ] Извлечение связанных сущностей (отправители, темы, проекты) --- ## Решения и проблемы скриптов ### `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 не только для извлечения, но и для нахождения подписи --- ## Текущие метрики | Папка | Писем | Контакты извл. | |-------|-------|-----------------| | INBOX | 585 | — | | INBOX подпапки (18 хардкодных) | ~180 | — | | **Неархивируемые подпапки INBOX** | **~120 папок не синхронизируются** | **—** | | Sent | 515 | — | | Отправленные | 510 | — | | Archive | 373 | — | | **Всего** | **2073** | **5** | Индекс: 2073 письма, 5.4 MB SQLite. Контакты: 5 найдено (clean_body отрезает подпись в большинстве forwarded-писем). --- ## Cron-задачи (Hermes) | ID | Имя | Расписание | Тип | Статус | |----|-----|-----------|-----|--------| | 22c5beb891cc | mail-archive-every-5min | every 5m | no-agent (скрипт) | ✅ | | 8e181a988392 | 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) - **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/`