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

26 KiB
Raw Permalink Blame History

name, description, version, author, platforms, prerequisites, metadata
name description version author platforms prerequisites metadata
email-local-archive Локальный архив почты: инкрементальное сохранение писем из IMAP в файловую структуру на локальный диск с метаданными, телом, вложениями и извлечением адресной книги через LLM. 1.6.0 estorozhenko
linux
commands
himalaya
python3
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:

---
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 пишет флаги.

Поиск:

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.

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  // Была ли подпись вообще
}

Основные команды

# Архивация 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:

# Создать скрипт в ~/.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:

#!/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() вызывается после каждой обработанной папки:

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:

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:

# ❌ Падает с 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 тело письма проходит очистку. Ключевые этапы:

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) — парсинг по глубине скобок:

# Пробуем распарсить весь ответ как 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-примере внутри промпта конфликтуют с шаблоном. Решение — двойные скобки {{...}} для литеральных {...} в шаблоне:

PROMPT_TEMPLATE = """...Верни ТОЛЬКО JSON, без пояснений:
{{"full_name": "...", "email": null, ...}}
{body}"""

Обработка reply/forward писем

В reply/forward письмах подпись может принадлежать НЕ отправителю в From:. Например: Головлев пересылает письмо Елены Стороженко — её подпись в теле. Это нормально — контакт Елены всё равно ценный. LLM не пытается сверять отправителя с извлечёнными данными.

Оптимизация pagination

Проблема

get_envelopes() изначально тащил ВСЕ страницы INBOX (58 страниц × 200 писем = ~11559) через SOCKS5 — это висло на 60+ секунд и никогда не доходило до архивации.

Решение

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: несколько аккаунтов

Конфиг может содержать несколько почтовых аккаунтов. Для переключения:

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