26 KiB
name, description, version, author, platforms, prerequisites, metadata
| name | description | version | author | platforms | prerequisites | metadata | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| email-local-archive | Локальный архив почты: инкрементальное сохранение писем из IMAP в файловую структуру на локальный диск с метаданными, телом, вложениями и извлечением адресной книги через LLM. | 1.6.0 | estorozhenko |
|
|
|
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 (любой глубины), автоматическая архивация
новых подпапок при их создании.
План исправления:
- Заменить хардкод
INBOX_SUBFOLDERSна вызовhimalaya folder listс парсингом JSON - Фильтровать только папки, начинающиеся с
INBOX/(не трогать Trash, Drafts, RSS и т.д.) - Рекурсивно архивировать все уровни вложенности
mail-archive-every-5mincron должен обновлять список папок динамически каждый раз- 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. Архиватор должен оставаться тупым насосом.