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`. Архиватор должен оставаться тупым насосом.