Files
2026-09-06 13:51:14 +00:00

528 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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`. Архиватор должен оставаться тупым насосом.