--- 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 " to: "Name " 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 " 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 --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 --limit 10 # или через переменную окружения: HIMALAYA_ACCOUNT=hermes himalaya envelope list --limit 10 ``` При добавлении нового аккаунта: - Скопировать блок `[accounts.]`, заменить данные - Если нужен SMTP — добавить `[accounts..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`. Архиватор должен оставаться тупым насосом.