Initial commit: Hermes skill email-local-archive

This commit is contained in:
estorozhenko
2026-09-06 13:51:14 +00:00
commit b123d2d3b4
7 changed files with 1386 additions and 0 deletions
+528
View File
@@ -0,0 +1,528 @@
---
name: email-local-archive
description: "Локальный архив почты: инкрементальное сохранение писем из IMAP в файловую структуру на локальный диск с метаданными, телом, вложениями и извлечением адресной книги через LLM."
version: 1.6.0
author: estorozhenko
platforms: [linux]
prerequisites:
commands: [himalaya, python3]
metadata:
hermes:
tags: [email, archive, imap, backup, contacts]
---
# Email Local Archive
Этот навык позволяет агенту архивировать письма из почтового ящика
в локальную файловую систему (`/opt/hermes/email/`) и извлекать адресную
книгу из подписей входящих писем через локальную LLM.
**История:** архив был перенесён с Yandex Disk (WebDAV davfs2) на локальный
диск `/opt/hermes/email/` из-за катастрофической медлительности WebDAV при
rm/find/ls на больших деревьях.
## Структура хранилища
```
/opt/hermes/email/
├── INBOX/
│ ├── YYYY/MM/UID/
│ │ ├── email.md # YAML-frontmatter + тело письма
│ │ └── attachments/ # вложения
│ └── ...
├── Отправленные/...
├── Archive/...
├── Sent/...
├── state/
│ └── mail-archive-last-*.json # last_uid per folder
└── contacts/ # адресная книга (извлечение через LLM)
├── contacts.vcf # vCard 4.0 для импорта
├── contacts.json # машинный формат
├── index.json # email → contact_id
└── last_scan.json # трекинг обработанных писем (UID + mtime)
```
Формат email.md — YAML-frontmatter:
```yaml
---
id: 255
folder: INBOX
subject: "Re: ..."
from: "Name <addr>"
to: "Name <addr>"
date: "2025-07-10 11:51+03:00"
flags: ["Seen"]
has_attachment: false
message_id: <...@domain.ru>
in_reply_to: <...@domain.ru>
references: <...> <...>
cc: "Name <addr>"
content_type: multipart/mixed; boundary=...
---
Body text here...
```
## Компоненты
| Компонент | Путь | Назначение |
|-----------|------|------------|
| Архиватор | `/opt/hermes/email-assistant/scripts/mail_archive.py` | Тупой насос: himalaya → email.md. Без LLM. |
| Индексатор | `/opt/hermes/email-assistant/scripts/mail_index.py` | SQLite-индекс всех email.md для быстрого поиска |
| Поиск (FTS5) | `/opt/hermes/email-assistant/scripts/sqlite_search.py` | Полнотекстовый поиск по SQLite-индексу |
| Contacts Extractor | `/opt/hermes/email-assistant/scripts/contacts_extractor.py` | LLM-парсинг подписей через Qwen3:8b |
| Дайджест | `/opt/hermes/email-assistant/scripts/digest.py` | Еженедельный дайджест почты через Qwen3:8b |
| Shell-обёртка | `/opt/hermes/email-assistant/scripts/mail-archive.sh` | Для systemd/cron |
| Документация | `/opt/hermes/email-assistant/context/CONTACTS.md` | Архитектура, промпт, формат vCard |
## SQLite-индекс (mail_index.py + sqlite_search.py)
Быстрый поиск по архиву без grep. База: `/opt/hermes/email/mail_index.db`.
**Схема:** таблица `emails` (2073 записи) + FTS5 virtual table `email_fts` для полнотекстового поиска. Триггеры синхронизируют FTS5 при INSERT/UPDATE/DELETE. Индексы: folder, uid, contacts_extracted, date.
**Контракт с contacts_extractor:** поля `contacts_extracted` (0/1) и `contacts_skipped` (0/1) — общие. mail_index.py создаёт схему, contacts_extractor.py пишет флаги.
**Поиск:**
```bash
python3 /opt/hermes/email-assistant/scripts/sqlite_search.py 'Стороженко'
python3 /opt/hermes/email-assistant/scripts/sqlite_search.py 'битрикс OR контрагент'
python3 /opt/hermes/email-assistant/scripts/sqlite_search.py 'from:example@mail'
python3 /opt/hermes/email-assistant/scripts/sqlite_search.py --folder 'INBOX/!Отчеты'
```
Операторы FTS5: AND (по умолчанию), OR, `"точная фраза"`, `-исключить`, `prefix*`.
Подробнее: `references/sqlite-index-architecture.md`
## Дайджест почты (digest.py)
Еженедельный дайджест через Qwen3:8b. Выборка из SQLite за N дней, группировка по папкам, LLM-генерация краткого обзора на русском. Сохраняется в `/opt/hermes/email/digests/digest-YYYY-MM-DD.md`.
```bash
python3 /opt/hermes/email-assistant/scripts/digest.py # 7 дней
python3 /opt/hermes/email-assistant/scripts/digest.py --days 14
```
Подробнее: `references/digest-pipeline.md`
## Пайплайн (полный)
```
mail_archive.py ──→ INBOX/UID/email.md (тупой насос, без LLM)
↓
contacts_extractor.py (LLM через delegate_task)
↓
/opt/hermes/email/contacts/
├── contacts.vcf ← vCard 4.0, импорт в любой клиент
├── contacts.json ← машинная база (дедупликация)
├── index.json ← email → contact_id
└── last_scan.json ← трекинг обработанных
```
### Contacts Extractor: ключевые решения
| Вопрос | Решение |
|--------|---------|
| Body целиком или последние N строк? | **Целиком** — контекст для точного распознавания подписи |
| Поле department? | **Нет** — достаточно company + position |
| vCard версия? | **4.0** — соцсети, фото, расширенные поля |
| Какие папки обрабатывать? | **Только входящие** (INBOX + подпапки) |
| Как трекать обработанные? | **UID + mtime** — UID для быстрого skip, mtime для отлова запоздалых писем |
### Промпт для LLM (Qwen3:8b через Ollama)
```
Ты — экстрактор контактов. Извлеки данные отправителя из подписи письма.
Отправитель (для сверки): {from_name} <{from_email}>
Найди подпись. Она обычно:
- После "-- \n", "---\n", "Best regards,", "Kind regards,"
- После "С уважением,", "С наилучшими пожеланиями,"
- После "С ув.,", "Всего доброго,"
- Если разделителя нет — последние 5-15 строк
НЕ путай подпись с:
- Цитируемой предыдущей перепиской (строки с ">" или "On ... wrote:")
- Пересланным сообщением ("— Пересылаемое сообщение —")
- Дисклеймером/конфиденциальностью внизу письма
Верни JSON:
{
"full_name": null, // Полное имя (ФИО)
"phone": null, // Основной телефон
"phone_secondary": null, // Дополнительный телефон
"position": null, // Должность
"company": null, // Компания/организация
"address": null, // Адрес (почтовый/юридический)
"signature_found": false // Была ли подпись вообще
}
```
## Основные команды
```bash
# Архивация INBOX (10 писем за раз для bulk sync)
python3 /opt/hermes/email-assistant/scripts/mail_archive.py --folder INBOX --limit 10
# Архивация всех папок
python3 /opt/hermes/email-assistant/scripts/mail_archive.py --all --limit 10
# Поиск по архиву (быстрый — локальный диск)
grep -ril 'тема' /opt/hermes/email/**/email.md 2>/dev/null
# Статус архива
cat /opt/hermes/email/state/mail-archive-last-*.json
```
## Проблема: хардкод подпапок INBOX (18 из 137)
**Статус:** ❌ Не решено — задача зафиксирована в STATUS.md как Фаза 1.7.
**Проблема:** `mail_archive.py` содержит захардкоженный список `INBOX_SUBFOLDERS` (18 папок),
но на IMAP-сервере реально **137 подпапок INBOX**, включая многоуровневые глубиной 2-3:
```
INBOX/!Персонал/ОТ и ТБ
INBOX/Бюджет/Винный город/CAPEX 2025
INBOX/Контрагенты/iiko/Тихая гавань
INBOX/!Реестр оплаты/Закупки/Сервер
...
```
**Требуется:** динамическое обнаружение IMAP-папок через `himalaya folder list`,
рекурсивный обход всех подпапок INBOX (любой глубины), автоматическая архивация
новых подпапок при их создании.
**План исправления:**
1. Заменить хардкод `INBOX_SUBFOLDERS` на вызов `himalaya folder list` с парсингом JSON
2. Фильтровать только папки, начинающиеся с `INBOX/` (не трогать Trash, Drafts, RSS и т.д.)
3. Рекурсивно архивировать все уровни вложенности
4. `mail-archive-every-5min` cron должен обновлять список папок динамически каждый раз
5. State-файлы уже поддерживают произвольные имена папок (через хэш MD5 для не-ASCII)
Подробнее: `references/dynamic-folder-discovery.md`
## Bulk-sync cron strategy
Двухфазный подход для переноса архива:
| Фаза | Период | Лимит | Параметры cron |
|------|--------|-------|----------------|
| **Bulk sync** (слив существующего архива) | Каждые 5 мин | 10 писем на папку | `no_agent=True`, `script=` |
| **Incremental** (только новые письма) | Каждые 30 мин | 50-100 | `no_agent=True`, `script=` |
В bulk-фазе лимит маленький (10), чтобы:
- Не превышать таймаут при медленных папках
- Не перегружать IMAP-сервер
- Равномерно распределять нагрузку на все 22 папки
Когда state-файлы перестают обновляться (все письма заархивированы) — переключить на incremental.
### no_agent cron (рекомендуемый паттерн)
Для скриптовых recurring-задач без LLM:
```bash
# Создать скрипт в ~/.hermes/scripts/
cat > ~/.hermes/scripts/mail-archive.sh << 'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
cd /opt/hermes/email-assistant
exec python3 scripts/mail_archive.py --all --limit 10
SCRIPT
# Создать cron
hermes cron create \
--name "mail-archive-every-5min" \
--schedule "every 5m" \
--script mail-archive.sh \
--no-agent
# Обновить для incremental
hermes cron update <id> --schedule "every 30m" --script mail-archive.sh --no-agent
```
**Важно:** `script` должен быть относительным путём в `~/.hermes/scripts/`.
Абсолютные пути не принимаются.
### Shell wrapper с per-folder timeouts
При обработке 22+ папок одна медленная папка не должна валить весь запуск.
Скрипт-обёртка обрабатывает каждую папку с отдельным `timeout`:
```bash
#!/usr/bin/env bash
set -euo pipefail
cd /opt/hermes/email-assistant
TIMEOUT=60
LIMIT=10
FOLDERS=(
"INBOX" "Отправленные" "Archive" "Sent"
"INBOX/!Scan" "INBOX/!Битрикс" "INBOX/!ВГ Чек листы"
"INBOX/!Документооборот" "INBOX/!Завки" "INBOX/!Материалы"
"INBOX/!Отчеты" "INBOX/!Персонал" "INBOX/!Протоколы"
"INBOX/!Реестр оплаты" "INBOX/!Торик" "INBOX/Бюджет"
"INBOX/Контрагенты" "INBOX/ЛНД" "INBOX/Организация работы"
"INBOX/Приемка и стройка" "INBOX/Системы" "INBOX/Эксплуатация"
)
for folder in "${FOLDERS[@]}"; do
echo "[$folder] start..."
out=$(timeout $TIMEOUT python3 scripts/mail_archive.py --folder "$folder" --limit $LIMIT 2>&1) || true
echo "$out"
done
```
## Contacts Extractor: реализация
### Инкрементальное сохранение (save_progress)
Раньше скрипт писал все файлы только в конце — при таймауте (600с на 354 письма при Qwen3:8b) весь прогресс терялся.
**Решение:** `save_progress()` вызывается после каждой обработанной папки:
```python
def save_progress(contacts_db, contacts_dir, processed_uids, processed_emails):
contacts_db["contacts"].sort(key=lambda c: c.get("email", ""))
save_json(contacts_dir / "contacts.json", contacts_db)
save_json(contacts_dir / "index.json", contacts_db.get("by_email", {}))
generate_vcard(contacts_dir, contacts_db["contacts"])
last_scan = {
"last_processed": datetime.now().isoformat(timespec="seconds"),
"processed_uids": processed_uids,
"processed_emails": sorted(processed_emails),
}
save_json(contacts_dir / "last_scan.json", last_scan)
```
### --limit для batch-запусков
Для локальной Qwen3:8b на CPU (~15-20 сек на письмо) полный прогон 354 писем
занимает >1.5 часа. Добавлен `--limit`:
```bash
python3 scripts/contacts_extractor.py --limit 15 # ~5 мин на CPU
python3 scripts/contacts_extractor.py --limit 50 # ~2 мин на GPU
```
Лимит считается на все папки суммарно. После каждой папки — `save_progress()`.
### None-safety при работе с JSON
Qwen через Ollama может вернуть JSON с `null` значениями. Python `.get(key, "")` возвращает `None` (не `""`), если ключ существует со значением `null`:
```python
# ❌ Падает с AttributeError: 'NoneType' object has no attribute 'strip'
full_name = llm_result.get("full_name", "").strip()
# ✅ Безопасно
full_name = (llm_result.get("full_name") or "").strip()
```
### Отсутствующий import hashlib
Скрипт использовал `hashlib.md5()` в `contact_id()` без импорта — падение при первом вызове. Исправлено добавлением `import hashlib`.
### Очистка тела письма перед LLM (clean_body)
Перед отправкой в LLM тело письма проходит очистку. Ключевые этапы:
```python
def clean_body(body):
# Удаляем <#part ...> блоки
body = re.sub(r'<#part[^>]*>', '', body)
body = re.sub(r'<#/part>', '', body)
# Удаляем HTML-теги
body = re.sub(r'<[^>]+>', '', body)
# Удаляем mailto: ссылки
body = re.sub(r'\\(mailto:[^)]+\\)', '', body)
# Заменяем unicode-пробелы (NBSP, zero-width) на обычные
body = re.sub(r'[\\u00a0\\u2000-\\u200f\\u2028-\\u202f\\u2060]+', ' ', body)
# Удаляем трекинг-ссылки
body = re.sub(r'https?://tn-eoc\\.[^\\s]+', '', body)
body = re.sub(r'https?://[^\\s]+\\?utm_[^\\s]+', '', body)
# Отрезаем цитируемую переписку — ищем САМЫЙ РАННИЙ маркер цитирования
quote_patterns = [
r'^[\\s]*_{4,}\\s*$', # _____
r'От:.*\\n[\\s]*Отправлено:', # Russian Outlook (в любом месте строки)
r'^[\\s]*From:.*\\n[\\s]*Sent:', # English Outlook headers
r'——-.*Forwarded.*——-',
r'——-.*Пересылаемое.*——-',
r'——-.*Original Message.*——-',
r'>.*\\bwrote:',
]
earliest = None
earliest_pos = len(body)
for qp in quote_patterns:
for m in re.finditer(qp, body, re.MULTILINE):
if m.start() < earliest_pos:
earliest_pos = m.start()
earliest = m
if earliest:
body = body[:earliest_pos].strip()
```
**Ключевое изменение:** вместо `re.search()` по списку (первый сработавший) — `re.finditer()` по ВСЕМ паттернам, выбор самого раннего матча. Это критично для писем с двумя фрагментами цитирования (например, ответ на forwarded message), где второй матч (`From: ... Sent:`) срабатывает раньше первого (`От: Стороженко`), потому что `От:` не в начале строки.
**Fallback:** если ни один паттерн не совпал — ищем любой Outlook-заголовок (`От:`, `Отправлено:`, `From:`, `Sent:`, `Кому:`, `Subject:`) в последних 500 символах. Это ловит подписи, где `От:` вшит в строку с телефоном: `+7 962 854 22 01 От: Стороженко`
### Парсинг JSON из ответа LLM (brace-depth)
Qwen3:8b через Ollama может обрезать JSON на лимите `num_predict` или добавить
пояснения. Вместо `re.search(r"\{.*\}", ..., re.DOTALL)` — парсинг по глубине
скобок:
```python
# Пробуем распарсить весь ответ как JSON
try:
return json.loads(response_text)
except json.JSONDecodeError:
pass
# Если не получилось — ищем { ... } внутри
brace_depth = 0
json_start = None
for i, ch in enumerate(response_text):
if ch == '{':
if brace_depth == 0:
json_start = i
brace_depth += 1
elif ch == '}':
brace_depth -= 1
if brace_depth == 0 and json_start is not None:
try:
return json.loads(response_text[json_start:i+1])
except json.JSONDecodeError:
pass
json_start = None
```
### Retry при пустых ответах Ollama
Qwen3:8b иногда возвращает `response=""` (пустая строка) на длинных промптах.
Стандартный retry (2-3 попытки с `time.sleep(1)`) решает проблему.
### Проблема: num_predict=512 недостаточно
Телефон + должность + компания не помещаются в 512 токенов для русского текста.
Увеличено до 1024.
### Экранирование JSON в промпте
При использовании `str.format()` фигурные скобки в JSON-примере внутри промпта
конфликтуют с шаблоном. Решение — двойные скобки `{{...}}` для литеральных
`{...}` в шаблоне:
```python
PROMPT_TEMPLATE = """...Верни ТОЛЬКО JSON, без пояснений:
{{"full_name": "...", "email": null, ...}}
{body}"""
```
### Обработка reply/forward писем
В reply/forward письмах подпись может принадлежать НЕ отправителю в `From:`.
Например: Головлев пересылает письмо Елены Стороженко — её подпись в теле.
Это нормально — контакт Елены всё равно ценный. LLM не пытается сверять
отправителя с извлечёнными данными.
## Оптимизация pagination
### Проблема
`get_envelopes()` изначально тащил ВСЕ страницы INBOX (58 страниц × 200 писем = ~11559)
через SOCKS5 — это висло на 60+ секунд и никогда не доходило до архивации.
### Решение
```python
def get_envelopes(folder, limit=100, last_uid=0):
"""Ранняя остановка pagination при достижении лимита ИЛИ если все
письма на странице уже обработаны (UID <= last_uid)."""
page_size = 500 # 1 страница покрывает лимит
# ...
# Хватит — не тащим остальные страницы
if total_new >= limit:
break
# Если все письма на этой странице старые — дальше только старее
last_batch_min = min(int(e.get("uid")...) for e in batch)
if last_batch_min <= last_uid:
break
```
**Ключевое:** одна страница `page_size=500` покрывает `limit=10` с запасом.
Искать на последующих страницах нужно только если на этой меньше лимита новых писем.
## Cron orchestration
Два независимых cron-пайплайна поверх общего хранилища — см.
`references/cron-workflows.md` для полной документации:
| Пайплайн | Расписание | Скрипт | `no_agent` | LLM |
|-----------|-----------|--------|------------|-----|
| Архивация | `every 5m` | `mail-archive.sh` | ✅ Да | ❌ Нет |
| Контакты | `every 30m` | `contacts-cron.sh` | ❌ Нет | ✅ Qwen3:8b |
**Ключевые правила для cron с LLM:**
- Контакты НЕ используют `--no-agent` — LLM-вызовы внутри Python-скрипта
работают и без агента, но без агента результат не доставляется
- `--limit` рассчитан на скорость Qwen3:8b: CPU = 15-20s/email → limit 15
- Контакты всегда остаются инкрементальными (UID-трекинг в `last_scan.json`)
## Скорость Qwen3:8b как planning-ограничение
Локальная Qwen3:8b через Ollama — бутылочное горлышко для contacts extractor:
| Окружение | Время на письмо | limit за 5 мин | limit за 30 мин |
|-----------|----------------|----------------|-----------------|
| CPU (только процессор) | 15-20 с | 15 | 90 |
| GPU (NVIDIA/AMD) | 2-3 с | 100 | 600 |
**Никогда не пытаться прогнать все 354 письма INBOX за один запуск при CPU.**
Использовать `--limit` + cron (каждые 30 мин) для инкрементальной обработки.
## Поведение агента
### Когда архив был на Yandex Disk (WebDAV davfs2)
Сейчас архив на `/opt/hermes/email/` — локальный диск, быстрый. Но код
исторически работал с `/mnt/yandex-disk/` — медленным WebDAV. Если архив
снова окажется на WebDAV:
- **Не делать** `rm -rf` на вложенных папках — шлёт HTTP DELETE на каждый файл
- **Не делать** `find` на всём архиве — таймаутится
- **Партии по 5-10 писем** за один запуск
- **Делегировать batch-операции** локальной Qwen3:8b через `delegate_task`
### Текущий архив (локальный диск)
- Батчи по 50-500 писем — диск быстрый
- `grep` работает мгновенно
- `find` / `rm` — без ограничений
## Himalaya: несколько аккаунтов
Конфиг может содержать несколько почтовых аккаунтов. Для переключения:
```bash
himalaya envelope list --account <name> --limit 10
# или через переменную окружения:
HIMALAYA_ACCOUNT=hermes himalaya envelope list --limit 10
```
При добавлении нового аккаунта:
- Скопировать блок `[accounts.<name>]`, заменить данные
- Если нужен SMTP — добавить `[accounts.<name>.message.send]`
- Порт и шифрование подбираются под провайдера: STARTTLS (порт 143 для IMAP, 587 для SMTP) vs TLS (993/465)
- Только один аккаунт может быть `default = true`
Документированные провайдеры:
- **Jino (jino.ru):** `references/jino-server-setup.md` — порты, auth, диагностика
## Правила
- НЕ изменять файлы архива без явной команды
- НЕ переписывать логику архивации сырыми командами Himalaya
- Пароль в `backend.auth.raw` в конфиге — не показывать в логах
- **Contacts Extractor — отдельный pipeline, НЕ внутри архиватора.** Не вызывать
LLM в `mail_archive.py`. Архиватор должен оставаться тупым насосом.
@@ -0,0 +1,237 @@
# Contacts Extractor — архитектура и промпт
## Концепция
**Не внутри архиватора.** Архиватор (`mail_archive.py`) — тупой насос:
забрал письмо → сохранил `email.md`. Без LLM. Иначе архивация встаёт
на каждом письме (1-3 сек на Qwen), и при перезапуске парсит заново.
## Pipeline
```
mail_archive.py ──→ INBOX/UID/email.md (тупой насос, без LLM)
↓
contacts_extractor.py (LLM через Qwen3:8b Ollama)
↓
/opt/hermes/email/contacts/
├── contacts.vcf ← vCard 4.0 для импорта
├── contacts.json ← машинная база
├── index.json ← email → contact_id
└── last_scan.json ← трекинг (UID + mtime)
```
## Принятые решения
| Вопрос | Решение |
|--------|---------|
| Body целиком или последние N строк? | **Целиком** — контекст для точной подписи |
| Поле department? | **Нет** — company + position достаточно |
| vCard версия? | **4.0** (RFC 6350) — соцсети, фото |
| Какие папки? | **Только входящие** (INBOX + подпапки) |
| Трекинг обработанных | **Комбинированный** — UID для быстрой фильтрации + mtime для запоздалых |
## Логика extractor'a
1. Читает `last_scan.json` — какие UID уже обработаны по каждой папке
2. Сканирует `INBOX/**/email.md`. Для каждого:
- Если UID ≤ last_uid → skip
- Если email отправителя уже есть в `contacts.by_email` → skip (или обновить
если last_seen > 30 дней)
- Если mtime файла новее last_scan → обработать
3. Очищает body (clean_body), передаёт Qwen3:8b через Ollama API
4. Сохраняет результат, обновляет index.json, пересобирает contacts.vcf
## Реализация call_llm
```python
def call_llm(body_text, max_retries=2):
"""Вызвать Qwen через Ollama API, вернуть JSON."""
# Варианты API: http://localhost:11434/api/generate (generate)
# или http://localhost:11434/api/chat (chat)
# Используем generate (не chat) — ответ "response" поле
# Полный пример:
import json, urllib.request
body_text = clean_body(body_text) # очистка HTML
truncated = body_text[:MAX_BODY_CHARS] # обрезка
prompt = PROMPT_TEMPLATE.format(body=truncated)
for attempt in range(max_retries + 1):
payload = json.dumps({
"model": "qwen3:8b",
"prompt": prompt,
"stream": False,
"options": {
"temperature": 0.1,
"num_predict": 1024, # минимум 1024 — 512 не хватает
}
}).encode("utf-8")
req = urllib.request.Request(
"http://localhost:11434/api/generate",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
resp = urllib.request.urlopen(req, timeout=30)
data = json.loads(resp.read().decode("utf-8"))
response_text = data.get("response", "").strip()
except Exception:
if attempt < max_retries:
continue
return None
# Парсинг JSON: try полный → brace-depth → fail
try:
return json.loads(response_text)
except json.JSONDecodeError:
pass
# Brace-depth парсер (не re.DOTALL — обрезанный JSON)
brace_depth = 0
json_start = None
for i, ch in enumerate(response_text):
if ch == '{':
if brace_depth == 0:
json_start = i
brace_depth += 1
elif ch == '}':
brace_depth -= 1
if brace_depth == 0 and json_start is not None:
try:
return json.loads(response_text[json_start:i+1])
except json.JSONDecodeError:
pass
json_start = None
if attempt < max_retries:
continue
return None
```
### Почему не re.DOTALL
`re.search(r"\{.*\}", text, re.DOTALL)` жадный — захватывает ВСЁ между первой
`{` и последней `}`. Если в ответе два JSON-блока (пояснение + результат),
или JSON обрезан на середине вложенности — re.DOTALL даёт мусор.
Brace-depth парсер корректно обрабатывает:
- Обрезанный JSON (Qwen не успела закончить `}`)
- Два JSON-блока (берёт первый валидный)
- JSON с пояснениями вокруг
## Проблема с пустыми ответами Ollama
Qwen3:8b через `/api/generate` иногда возвращает `response=""`.
Причины (гипотезы):
- Конкуренция за GPU (если другой процесс жрёт VRAM)
- Длинный контекст + маленький `num_predict`
- Сбой внутри Ollama
**Решение:** retry (2-3 попытки с `time.sleep(1)`).
**Не путать:** `/api/generate` (generate) vs `/api/chat` (chat) — разные
форматы ответов.
## Очистка тела письма
Критично для распознавания подписи. HTML-мусор забивает контекст.
```python
def clean_body(body):
# Удаляем <#part ...> блоки
body = re.sub(r'<#part[^>]*>|<#/part>', '', body)
# Удаляем HTML-теги
body = re.sub(r'<[^>]+>', '', body)
# Удаляем mailto: ссылки (mailto:estorozhenko@...)
body = re.sub(r'\(mailto:[^)]+\)', '', body)
# Заменяем unicode-пробелы (NBSP \u00a0, zero-width \u2060 и др.)
body = re.sub(r'[\u00a0\u2000-\u200f\u2028-\u202f\u2060]+', ' ', body)
# Удаляем трекинг-пиксели и UTM-ссылки
body = re.sub(r'https?://tn-eoc\.[^\s]+', '', body)
body = re.sub(r'https?://[^\s]+\?utm_[^\s]+', '', body)
# Схлопываем пустые строки
body = re.sub(r'\n{3,}', '\n\n', body)
return body.strip()
```
## Промпт для LLM
```text
Ты — экстрактор контактных данных из писем. Твоя задача — найти подпись
отправителя в конце письма и извлечь структурированные данные.
Правила поиска подписи:
- Подпись обычно отделена от тела письма разделителями:
"-- \\n", "---\\n", "С уважением,", "С наилучшими пожеланиями,",
"Best regards,", "Kind regards,", "С ув.,", "—————"
- Если разделителя нет — последние 5-15 строк письма это подпись
- Не путай подпись с цитируемым текстом переписки
(обычно начинается с ">" или "On ... wrote:" или "— Пересылаемое сообщение —")
- Ignore boilerplate (disclaimers, confidentiality notices)
Извлеки из подписи:
1. full_name — полное имя (ФИО)
2. email — email адрес (если есть в подписи, иначе null)
3. phone — основной телефон
4. phone_secondary — дополнительный телефон (если есть)
5. position — должность
6. company — название компании/организации
7. address — почтовый/юридический адрес (если есть)
8. raw_signature — полный текст найденной подписи (для отладки)
Если никакой подписи не найдено — верни только full_name и email,
остальные поля null.
Верни ТОЛЬКО JSON, без пояснений:
{"full_name": "...", "email": null, ...}
```
### Экранирование JSON в Python str.format()
При использовании `str.format()` фигурные скобки в JSON-примере внутри промпта
конфликтуют с шаблоном. Решение — двойные скобки `{{...}}` для литеральных
`{...}` в шаблоне:
```python
PROMPT_TEMPLATE = """...Верни ТОЛЬКО JSON, без пояснений:
{{"full_name": "...", "email": null, ...}}
...
{body}"""
```
## Формат vCard 4.0
```
BEGIN:VCARD
VERSION:4.0
FN:Иванов Иван Иванович
N:Иванов;Иван;Иванович;;;
EMAIL;TYPE=WORK:ivan@example.com
TEL;TYPE=WORK:+7-123-456-78-90
TITLE:Генеральный директор
ORG:ООО "Ромашка"
ADR;TYPE=WORK:;;ул. Ленина, д.1;Москва;;123456;Россия
NOTE:Извлечено из письма от 2026-07-16 (UID 11559, INBOX)
END:VCARD
```
## Дедупликация
1. **email — primary key.** Если контакт с таким email уже есть → обновить поля
(телефон мог поменяться, должность — повысили).
2. Если email нет, но совпадает full_name (fuzzy) — создать новый, но
зафиксировать в `note` возможный дубль.
3. Если нет ни email, ни full_name — не сохранять.
4. `first_seen` / `last_seen` в контакте для понимания актуальности.
## Обработка reply/forward писем
В reply/forward письмах подпись может принадлежать НЕ отправителю в `From:`.
Например: Головлев пересылает письмо Елены Стороженко — её подпись в теле.
Это нормально — контакт Елены всё равно ценный. LLM не пытается сверять
отправителя с извлечёнными данными.
+142
View File
@@ -0,0 +1,142 @@
# Cron Workflows for Email Archive
## Architecture overview
Two independent cron pipelines that share the same email storage:
```
mail-archive (every 5 min, no_agent=True)
↓
INBOX/UID/email.md ←── contacts-extractor (every 30 min, script-based, LLM)
↓
contacts.vcf + contacts.json
```
Both use **script-based cron** (the `script=` parameter) with the script in
`~/.hermes/scripts/`. The archive job is `no_agent=True` (pure shell — no LLM
tokens consumed). The contacts job uses the agent loop because it calls LLM
internally via Python.
## Mail archive cron (bulk sync)
```bash
# Script: ~/.hermes/scripts/mail-archive.sh
#!/usr/bin/env bash
set -euo pipefail
cd /opt/hermes/email-assistant
exec python3 scripts/mail_archive.py --all --limit 10
```
Cron creation:
```bash
hermes cron create \
--name "mail-archive-every-5min" \
--schedule "every 5m" \
--script mail-archive.sh \
--no-agent \
--deliver local
```
Key details:
- `no_agent=True` → pure script mode, zero LLM cost per tick
- `deliver=local` → output saved, no notification (noisy at 5min intervals)
- Script path MUST be relative in `~/.hermes/scripts/` — absolute paths rejected
- `--all --limit 10` processes all 22 folders with 10 emails per folder per tick
### Shell wrapper with per-folder timeouts
When using `--all`, one slow IMAP folder can stall the entire run. The
shell-embedded approach handles this natively — each folder gets its own
`timeout`:
```bash
FOLDERS=(INBOX "Отправленные" Archive Sent ...)
TIMEOUT=60
LIMIT=10
for folder in "${FOLDERS[@]}"; do
timeout $TIMEOUT python3 scripts/mail_archive.py --folder "$folder" --limit $LIMIT 2>&1 || true
done
```
This is the ACTUAL approach used in `mail-archive.sh`. The `--all` flag in
`mail_archive.py` iterates folders internally but without per-folder timeouts,
so the shell wrapper is the recommended pattern when you control the cron script.
## Contacts extractor cron (LLM-based)
```bash
# Script: ~/.hermes/scripts/contacts-cron.sh
#!/usr/bin/env bash
set -euo pipefail
cd /opt/hermes/email-assistant
exec python3 scripts/contacts_extractor.py --limit 15
```
Cron creation:
```bash
hermes cron create \
--name "contacts-extractor-every-30m" \
--schedule "every 30m" \
--script contacts-cron.sh \
--deliver local
```
Key differences from archive cron:
- **NO `--no-agent`** — the contacts extractor calls LLM (Qwen3:8b via Ollama)
internally. Without the agent loop, the script runs but output isn't
delivered/visible.
- **`--limit 15`** — Qwen3:8b takes ~20s per email. 15 emails × 20s = ~5 min,
well within the 30-min window. Bump to 25-30 if Qwen is on a GPU.
- **`deliver=local`** — results saved to disk, no notification. The agent
generates a summary message on each tick.
## Transition from bulk to incremental
When state files stop advancing (all emails archived):
1. Update archive cron: smaller limit or longer interval
```bash
hermes cron update <archive-id> --schedule "every 30m"
```
2. Keep contacts cron at `every 30m` — it always processes only new emails
(UID tracking in `last_scan.json`)
## Testing cron scripts
Before scheduling, verify the script works by running it once:
```bash
timeout 120 bash ~/.hermes/scripts/contacts-cron.sh
```
Check for:
- Exit code 0 = success; 124 = timeout (reduce `--limit`)
- Stale output = script isn't finding new files (check `last_scan.json` UIDs)
- Python import errors = missing dependencies (run `pip install -r requirements.txt`)
## Pitfalls
1. **Script path MUST be relative.** `--script /absolute/path` is silently
rejected. Copy the script to `~/.hermes/scripts/` and pass just the filename.
2. **Contacts cron needs the agent loop.** Unlike the pure-shell archive cron,
contacts cron must NOT have `--no-agent`. Without the agent, the LLM calls
in `contacts_extractor.py` still execute (it's Python), but the job output
is never delivered — you'd see "last_status=completed, last_output=<empty>"
even though contacts.vcf was updated.
3. **Qwen3:8b speed varies.** On CPU-only Ollama it's ~20s/email. On discrete
GPU (NVIDIA, AMD ROCm) it's ~2-3s/email. Set `--limit` accordingly:
- CPU: 10-15 emails per 5-min cron window
- GPU: 50-100 emails per 5-min window
4. **Cron jobs run from the session's last state, not a fresh login.**
Environment variables (like `PATH`) may differ. Always use absolute paths or
`cd` to the project directory in the script.
5. **`deliver=local` vs `deliver=origin`.** `local` saves output to the cron
DB only (viewable via `cronjob action=list`). `origin` sends it back to the
Hermes session that created the cron. For per-5min archive runs, `local`
avoids spam. For contacts (every 30min), consider `origin` if you want
a notification.
+59
View File
@@ -0,0 +1,59 @@
# Digest Pipeline — еженедельный дайджест почты
## Назначение
Автоматическая генерация краткого дайджеста входящей почты за период (7 дней по умолчанию) через локальную LLM.
## Компонент
`/opt/hermes/email-assistant/scripts/digest.py`
## Pipeline
1. Читает SQLite-индекс (`mail_index.db`), выбирает письма за N дней
2. Группирует по папкам
3. Формирует текстовый блок для LLM: папка → список писем (дата, отправитель, тема)
4. Вызывает Qwen3:8b через Ollama с промптом на русском
5. Сохраняет дайджест в `/opt/hermes/email/digests/digest-YYYY-MM-DD.md`
6. Выводит в stdout (флаг `--output` управляет)
## Промпт
LLM получает запрос написать краткий дайджест для руководителя:
- Статистика: сколько писем, сколько папок
- По папкам — 1-3 предложения об основных темах
- Выделить важные письма (руководство, тендеры, финансы)
- На русском, ≤300 слов, без перечисления каждого письма
- Группировать по темам, игнорировать тех.мусор
## Использование
```bash
python3 scripts/digest.py # 7 дней
python3 scripts/digest.py --days 14 # 2 недели
python3 scripts/digest.py --folder INBOX # только INBOX
python3 scripts/digest.py --output stdout # только в stdout без файла
```
## Cron
```bash
hermes cron create \
--name "email-digest-sunday" \
--schedule "0 9 * * 0" \
--prompt "Запусти digest.py за 7 дней" \
--script /opt/hermes/email-assistant/scripts/digest.py \
--no-agent
```
## Зависимости
- `mail_index.db` — должен быть проиндексирован (mail_index.py)
- Qwen3:8b через Ollama (localhost:11434)
- ~15-20 секунд на генерацию через CPU
## Важные замечания
- `body_preview` в индексе — только 500 символов, поэтому FTS5-поиск по телу ограничен
- Для дайджеста используется только subject/from/date — тело не передаётся в LLM
- Группировка по папкам — базовая. Если нужно тематическое группирование — доработать
+171
View File
@@ -0,0 +1,171 @@
# Dynamic Folder Discovery — решение для хардкода INBOX_SUBFOLDERS
## Контекст
`mail_archive.py` содержит захардкоженный `INBOX_SUBFOLDERS` (18 папок).
На IMAP-сервере реально 137 подпапок INBOX, включая многоуровневые.
## Текущее состояние
### State-файлы (следы прошлых запусков)
```
/opt/hermes/email/state/
├── mail-archive-last-Archive.json
├── mail-archive-last-INBOX.json
├── mail-archive-last-INBOX_!Scan.json
├── mail-archive-last-Sent.json
├── mail-archive-last-folder_41a7755da018.json # хэш от не-ASCII имени
├── mail-archive-last-folder_5dd417336b45.json
├── mail-archive-last-folder_6dfd3661f092.json
├── mail-archive-last-folder_a2d2b831a004.json
├── mail-archive-last-folder_a59f0da18423.json
├── mail-archive-last-folder_b002f4b75367.json
├── mail-archive-last-folder_b8338b886347.json
├── mail-archive-last-folder_ba130b3adfda.json
├── mail-archive-last-folder_e5dd3de630eb.json
```
State-файлы уже поддерживают произвольные имена папок (через `get_state_file()` —
MD5-хэш для не-ASCII). Инфраструктура готова.
### Хардкод в mail_archive.py (строки 54-73)
```python
INBOX_SUBFOLDERS = [
"INBOX/!Scan",
"INBOX/!Битрикс",
"INBOX/!ВГ Чек листы",
"INBOX/!Документооборот",
"INBOX/!Завки",
"INBOX/!Материалы",
"INBOX/!Отчеты",
"INBOX/!Персонал",
"INBOX/!Протоколы",
"INBOX/!Реестр оплаты",
"INBOX/!Торик",
"INBOX/Бюджет",
"INBOX/Контрагенты",
"INBOX/ЛНД",
"INBOX/Организация работы",
"INBOX/Приемка и стройка",
"INBOX/Системы",
"INBOX/Эксплуатация",
]
```
### Реальные папки на сервере (137 шт.)
Полный список получен через `himalaya folder list`:
```
INBOX/!Scan
INBOX/!Битрикс
INBOX/!ВГ Чек листы
INBOX/!Документооборот
INBOX/!Завки
INBOX/!Материалы
INBOX/!Отчеты
INBOX/!Персонал
INBOX/!Персонал/ОТ и ТБ
INBOX/!Протоколы
INBOX/!Реестр оплаты
INBOX/!Реестр оплаты/Акты
INBOX/!Реестр оплаты/Закупки
INBOX/!Реестр оплаты/Закупки/10 рабочих мест
INBOX/!Реестр оплаты/Закупки/NanoCad
INBOX/!Реестр оплаты/Закупки/Горизонт Ноутбуки
INBOX/!Реестр оплаты/Закупки/Дооснащение ТГ
INBOX/!Реестр оплаты/Закупки/Касперский для ВГ
INBOX/!Реестр оплаты/Закупки/Модернизация Wi-Fi
INBOX/!Реестр оплаты/Закупки/Оборудование горизонт
INBOX/!Реестр оплаты/Закупки/Сервер
INBOX/!Реестр оплаты/Закупки/Цветной МФУ ТГ
INBOX/!Торик
INBOX/Бюджет
INBOX/Бюджет/Винный город
INBOX/Бюджет/Винный город/CAPEX 2025
INBOX/Бюджет/Винный город/Capex 2026
INBOX/Бюджет/Винный город/OPEX 2025
INBOX/Бюджет/Винный город/OPEX 2026
INBOX/Бюджет/Винный город/OPEX 2027
INBOX/Бюджет/Горизонт
INBOX/Бюджет/Горизонт/CAPEX 2025
INBOX/Бюджет/Горизонт/CAPEX 2026
INBOX/Бюджет/Горизонт/OPEX 2026
INBOX/Бюджет/Тихая гавань
INBOX/Бюджет/Тихая гавань/CAPEX 2025
INBOX/Бюджет/Тихая гавань/CAPEX 2026
INBOX/Бюджет/Тихая гавань/OPEX 2025
INBOX/Бюджет/Тихая гавань/OPEX 2026
INBOX/Контрагенты
INBOX/Контрагенты/iiko
INBOX/Контрагенты/iiko/Тихая гавань
INBOX/Контрагенты/АБ-Транзит
INBOX/Контрагенты/Аврора
INBOX/Контрагенты/Ассистент
INBOX/Контрагенты/Билайн
INBOX/Контрагенты/Интеллект - ilocks - замки
INBOX/Контрагенты/Интертех Лицензии Huawei
INBOX/Контрагенты/Квадротек
INBOX/Контрагенты/Кит
INBOX/Контрагенты/Компания АйТи
INBOX/Контрагенты/Кристалл
INBOX/Контрагенты/Крым-Строй-Сервис
INBOX/Контрагенты/Лимон
INBOX/Контрагенты/Медиа-Сервис
INBOX/Контрагенты/МеталлПрофиль
INBOX/Контрагенты/Ново-групп
INBOX/Контрагенты/Орт-Сервис
INBOX/Контрагенты/Партнер
INBOX/Контрагенты/Партнеры
INBOX/Контрагенты/Пиксель
INBOX/Контрагенты/Поставщики
INBOX/Контрагенты/Рубикон-С
INBOX/Контрагенты/СБСС
INBOX/Контрагенты/Самоваръ
INBOX/Контрагенты/Сервисный центр
INBOX/Контрагенты/Смарт
INBOX/Контрагенты/СпецТехМонтаж
INBOX/Контрагенты/Стрим
INBOX/Контрагенты/СтройПартнер
INBOX/Контрагенты/ТД ТрансМет
INBOX/Контрагенты/ТД Лайт
INBOX/Контрагенты/Технологии Доверия
INBOX/Контрагенты/Технополис
INBOX/Контрагенты/Типография
INBOX/Контрагенты/УралТрансПром
INBOX/Контрагенты/Физ лица
INBOX/Контрагенты/Цифровые решения
INBOX/Контрагенты/ЭнергоСпецКомплект
INBOX/Контрагенты/Энергия
INBOX/Контрагенты/Югспецодежда
INBOX/ЛНД
INBOX/Организация работы
INBOX/Приемка и стройка
INBOX/Системы
INBOX/Эксплуатация
```
## План исправления
1. В `mail_archive.py` заменить `INBOX_SUBFOLDERS` на функцию `get_all_folders()`:
- `himalaya folder list --output json`
- Фильтр: папки, начинающиеся с `INBOX/` (исключить Trash, Drafts, RSS, Archives)
- Исключить `INBOX` (корневую — она уже в `FOLDERS`)
2. `--all` должен использовать `FOLDERS + get_all_inbox_subfolders()` вместо `FOLDERS + INBOX_SUBFOLDERS`
3. `mail-archive-every-5min` cron автоматически получит новые папки без изменения конфигурации
4. State-файлы уже готовы — `get_state_file()` работает с любыми именами папок
## Edge cases
- Папки могут исчезнуть между запусками — `archive_folder()` уже обрабатывает
`No such folder` через `get_envelopes()` (возвращает `[]`)
- Новая папка без писем — `get_envelopes()` вернёт пустой список, state не создаётся
- Папки с `\HasNoChildren` и `\HasChildren` — `himalaya folder list` показывает все
(независимо от флагов), так что фильтр по `\HasNoChildren` не нужен
- Очень глубокие папки (3-4 уровня) — `get_state_file()` через MD5-хэш поддерживает
любую длину имени
+124
View File
@@ -0,0 +1,124 @@
# Jino (jino.ru) — настройка почтового ящика в Himalaya
## Серверы
| Протокол | Сервер | Порт | Шифрование | Аутентификация |
|----------|--------|------|------------|----------------|
| IMAP | mail.jino.ru | 143 | STARTTLS | PLAIN, CRAM-MD5 |
| IMAP SSL | mail.jino.ru | 993 | TLS | PLAIN, CRAM-MD5 |
| POP3 SSL | mail.jino.ru | 995 | TLS | USER/PASS |
| SMTP | smtp.jino.ru | 587 | STARTTLS | PLAIN, LOGIN, CRAM-MD5 |
| SMTP SSL | smtp.jino.ru | 465 | TLS | PLAIN, LOGIN, CRAM-MD5 |
**Логин:** полный email `hermes@nixg.ru` (не local-part без домена — сервер вернёт "Email not valid").
## Himalaya config
```toml
[accounts.jino-hermes]
email = "hermes@nixg.ru"
display-name = "Hermes Agent"
default = false
backend.type = "imap"
backend.host = "mail.jino.ru"
backend.port = 993
backend.encryption.type = "tls"
backend.login = "hermes@nixg.ru"
backend.auth.type = "password"
backend.auth.raw = "PASSWORD_HERE"
folder.aliases.inbox = "INBOX"
[accounts.jino-hermes.message.send]
backend.type = "smtp"
backend.host = "smtp.jino.ru"
backend.port = 465
backend.encryption.type = "tls"
backend.login = "hermes@nixg.ru"
backend.auth.type = "password"
backend.auth.raw = "PASSWORD_HERE"
```
## Диагностика
### Быстрая проверка IMAP (Python)
```python
import imaplib
M = imaplib.IMAP4_SSL('mail.jino.ru', 993)
try:
M.login('hermes@nixg.ru', 'PASSWORD')
print('OK')
M.logout()
except Exception as e:
print(f'Login failed: {e}')
```
### Быстрая проверка SMTP (Python)
```python
import smtplib
S = smtplib.SMTP('smtp.jino.ru', 587, timeout=15)
S.ehlo()
S.starttls()
S.ehlo()
try:
S.login('hermes@nixg.ru', 'PASSWORD')
print('SMTP OK')
except Exception as e:
print(f'SMTP login error: {e}')
S.quit()
```
### Проверка POP3
```python
import poplib
P = poplib.POP3_SSL('mail.jino.ru', 995, timeout=15)
try:
P.user('hermes@nixg.ru')
P.pass_('PASSWORD')
msgs, _ = P.stat()
print(f'POP3 OK, {msgs} messages')
P.quit()
except Exception as e:
print(f'POP3 error: {e}')
```
### Просмотр IMAP-возможностей сервера
```python
M = imaplib.IMAP4_SSL('mail.jino.ru', 993)
M.capability()
print(M.capabilities) # выведет: IMAP4 IMAP4REV1 UIDPLUS CHILDREN NAMESPACE QUOTA IDLE AUTH=PLAIN AUTH=CRAM-MD5
M.shutdown()
```
## Типовые проблемы
### "Login failed" на всех протоколах
1. **Ящик не активирован** — Jino (и особенно хостинг-аккаунты на nixg.ru) могут требовать первый вход через панель управления или webmail
2. **Неправильный пароль** — сбросить в cp.jino.ru → Почта → нужный ящик → Изменить пароль
3. **Доступ по протоколам отключён** — в панели Jino нужно проверить, что IMAP/POP3/SMTP включены для ящика
4. **Сервер ждёт активации** — на новых доменах почта может не работать до завершения регистрации/верификации
Если логин не проходит через `himalaya` — **проверить вручную через Python** (см. выше). Python показывает ту же ошибку, но без дополнительных слоёв (Rust-клиент himalaya может добавить свою обёртку).
### "Email not valid"
Логин передан без доменной части (только `hermes`, а не `hermes@nixg.ru`). Jino IMAP требует полный email.
## Множественные аккаунты в Himalaya
Конфиг может содержать сколько угодно аккаунтов. Для переключения:
```bash
# Через --account (порядок аргументов важен):
himalaya --account hermes envelope list --limit 10
# Или временно сделать default и вернуть обратно:
vim ~/.config/himalaya/config.toml
# выставить default = true у нужного аккаунта
```
+125
View File
@@ -0,0 +1,125 @@
# SQLite-индекс архива писем
## Назначение
Быстрый поиск и трекинг по архиву email.md без grep-а по всем папкам.
Основа для: contacts_extractor (знает какие письма обработаны), sqlite_search (FTS5), digest (выборка по дате).
## Компоненты
| Компонент | Путь | Назначение |
|-----------|------|------------|
| Индексатор | `/opt/hermes/email-assistant/scripts/mail_index.py` | Сканирует email.md → SQLite |
| Поиск | `/opt/hermes/email-assistant/scripts/sqlite_search.py` | FTS5-поиск по индексу |
| База | `/opt/hermes/email/mail_index.db` | SQLite (WAL mode) |
## Схема БД
### emails — основная таблица
```sql
CREATE TABLE IF NOT EXISTS emails (
path TEXT PRIMARY KEY, -- относительный путь от EMAIL_ROOT
uid INTEGER, -- числовой UID из пути
folder TEXT, -- INBOX, INBOX/!Scan, Sent...
date TEXT, -- дата из frontmatter (ISO)
from_addr TEXT, -- отправитель
to_addrs TEXT, -- получатели
subject TEXT, -- тема
body_preview TEXT, -- первые 500 символов тела (без HTML)
contacts_extracted INTEGER DEFAULT 0, -- 0/1 — обработано contacts_extractor
contacts_skipped INTEGER DEFAULT 0, -- 0/1 — нет подписи / LLM error
first_seen TEXT, -- когда проиндексировано
last_scanned TEXT, -- последняя проверка contacts
file_mtime REAL -- mtime файла для инкрементальной проверки
);
```
### email_fts — FTS5 virtual table
```sql
CREATE VIRTUAL TABLE IF NOT EXISTS email_fts USING fts5(
subject, from_addr, to_addrs, body_preview,
content='emails',
content_rowid='rowid',
tokenize='unicode61'
);
```
Синхронизация через триггеры INSERT/UPDATE/DELETE + FTS5 rebuild.
### Индексы
- `idx_emails_folder` — быстрая фильтрация по папке
- `idx_emails_uid` — lookup по UID
- `idx_emails_contacts` — необработанные письма (contacts_extracted=0)
- `idx_emails_date` — сортировка по дате
## Контракт между mail_index.py и contacts_extractor.py
**mail_index.py** владеет схемой и создаёт таблицы. **contacts_extractor.py** — только читает/пишет поля `contacts_extracted`, `contacts_skipped`, `last_scanned`.
```sql
-- contacts_extractor берёт необработанные письма:
SELECT rowid, path, uid, folder, from_addr, subject
FROM emails
WHERE contacts_extracted = 0 AND contacts_skipped = 0
ORDER BY folder, uid
LIMIT ?
-- После обработки:
UPDATE emails SET contacts_extracted=1, last_scanned=? WHERE rowid=?
UPDATE emails SET contacts_skipped=1, last_scanned=? WHERE rowid=?
```
## Быстродействие
- 2073 письма → полная индексация ~9 секунд
- FTS5-поиск — мгновенно (<100ms)
- WAL mode — конкурентные чтения не блокируют запись
- Инкрементальная индексация по mtime — доли секунды
## Использование sqlite_search.py
```bash
# Простой поиск (AND по умолчанию)
python3 sqlite_search.py 'Стороженко'
python3 sqlite_search.py 'битрикс OR контрагент'
python3 sqlite_search.py '"точечная фраза"'
# Фильтры
python3 sqlite_search.py --folder 'INBOX/!Отчеты'
python3 sqlite_search.py --limit 20
# Специальные префиксы (точно в поле from_addr/subject через LIKE)
python3 sqlite_search.py 'from:example@mail'
python3 sqlite_search.py 'subject:отчёт'
# Вкл. тело письма в FTS5 (медленнее, но находит больше)
python3 sqlite_search.py --body
```
## Операторы FTS5
- `AND` — по умолчанию между словами
- `OR` — `'битрикс OR контрагент'`
- `"точная фраза"` — кавычки для точного совпадения
- `-исключить` — минус перед словом
- `prefix*` — wildcard (звёздочка на конце)
## mail_index.py — ключевые параметры
```bash
python3 mail_index.py # полная переиндексация
python3 mail_index.py --incremental # только новые (по mtime)
python3 mail_index.py --search "..." # поиск (встроенный, без FTS5)
python3 mail_index.py --stats # статистика
```
## Важные детали
- **body_preview** — только первые 500 символов. Для full-text search с телом используй `sqlite_search.py --body` (FTS5 на preview).
- **contacts_extracted/contacts_skipped** — взаимоисключающие флаги. Если ни один не 1 — письмо не обработано.
- **file_mtime** — для инкрементальной индексации. Если mtime файла > last_mtime в БД — переиндексировать.
- **FTS5 rebuild** — вызывается после каждой полной индексации для согласованности.
- Исключает папки `contacts/` и `state/` из сканирования.