c7430d1b8a
- get_inbox_subfolders(): парсит 'himalaya folder list', находит 136
подпапок INBOX (вместо хардкода 18), fallback на INBOX_SUBFOLDERS
- --all использует FOLDERS + get_inbox_subfolders()
- EXCLUDED_FOLDERS: Trash/Drafts/RSS/Junk/Spam/Archive не архивируются
- ENVELOPE_TIMEOUT=180: INBOX (14k писем) перечисляется >60с
- run_cmd() ловит FileNotFoundError (himalaya вне PATH)
- mail-archive.sh: HOME=/home/estorozhenko, цикл переписан на --all --drain
- STATUS.md: Фаза 1.7 ✅
223 lines
15 KiB
Markdown
223 lines
15 KiB
Markdown
# 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-<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 не только для извлечения, но и для нахождения подписи
|
||
|
||
---
|
||
|
||
## Текущие метрики
|
||
|
||
| Папка | Писем | Контакты извл. |
|
||
|-------|-------|-----------------|
|
||
| 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/`
|