commit b123d2d3b4c5e02b3f510c4fee3eeb1edb46a1c5 Author: estorozhenko Date: Sun Sep 6 13:51:14 2026 +0000 Initial commit: Hermes skill email-local-archive diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..97f83c9 --- /dev/null +++ b/SKILL.md @@ -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 " +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`. Архиватор должен оставаться тупым насосом. \ No newline at end of file diff --git a/references/contacts-extractor-architecture.md b/references/contacts-extractor-architecture.md new file mode 100644 index 0000000..d9592c6 --- /dev/null +++ b/references/contacts-extractor-architecture.md @@ -0,0 +1,237 @@ +# Contacts Extractor — архитектура и промпт + +## Концепция + +**Не внутри архиватора.** Архиватор (`mail_archive.py`) — тупой насос: +забрал письмо → сохранил `email.md`. Без LLM. Иначе архивация встаёт +на каждом письме (1-3 сек на Qwen), и при перезапуске парсит заново. + +## Pipeline + +``` +mail_archive.py ──→ INBOX/UID/email.md (тупой насос, без LLM) + ↓ + contacts_extractor.py (LLM через Qwen3:8b Ollama) + ↓ + /opt/hermes/email/contacts/ + ├── contacts.vcf ← vCard 4.0 для импорта + ├── contacts.json ← машинная база + ├── index.json ← email → contact_id + └── last_scan.json ← трекинг (UID + mtime) +``` + +## Принятые решения + +| Вопрос | Решение | +|--------|---------| +| Body целиком или последние N строк? | **Целиком** — контекст для точной подписи | +| Поле department? | **Нет** — company + position достаточно | +| vCard версия? | **4.0** (RFC 6350) — соцсети, фото | +| Какие папки? | **Только входящие** (INBOX + подпапки) | +| Трекинг обработанных | **Комбинированный** — UID для быстрой фильтрации + mtime для запоздалых | + +## Логика extractor'a + +1. Читает `last_scan.json` — какие UID уже обработаны по каждой папке +2. Сканирует `INBOX/**/email.md`. Для каждого: + - Если UID ≤ last_uid → skip + - Если email отправителя уже есть в `contacts.by_email` → skip (или обновить + если last_seen > 30 дней) + - Если mtime файла новее last_scan → обработать +3. Очищает body (clean_body), передаёт Qwen3:8b через Ollama API +4. Сохраняет результат, обновляет index.json, пересобирает contacts.vcf + +## Реализация call_llm + +```python +def call_llm(body_text, max_retries=2): + """Вызвать Qwen через Ollama API, вернуть JSON.""" + + # Варианты API: http://localhost:11434/api/generate (generate) + # или http://localhost:11434/api/chat (chat) + # Используем generate (не chat) — ответ "response" поле + + # Полный пример: + import json, urllib.request + + body_text = clean_body(body_text) # очистка HTML + truncated = body_text[:MAX_BODY_CHARS] # обрезка + prompt = PROMPT_TEMPLATE.format(body=truncated) + + for attempt in range(max_retries + 1): + payload = json.dumps({ + "model": "qwen3:8b", + "prompt": prompt, + "stream": False, + "options": { + "temperature": 0.1, + "num_predict": 1024, # минимум 1024 — 512 не хватает + } + }).encode("utf-8") + + req = urllib.request.Request( + "http://localhost:11434/api/generate", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + + try: + resp = urllib.request.urlopen(req, timeout=30) + data = json.loads(resp.read().decode("utf-8")) + response_text = data.get("response", "").strip() + except Exception: + if attempt < max_retries: + continue + return None + + # Парсинг JSON: try полный → brace-depth → fail + try: + return json.loads(response_text) + except json.JSONDecodeError: + pass + + # Brace-depth парсер (не re.DOTALL — обрезанный JSON) + 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 + + if attempt < max_retries: + continue + return None +``` + +### Почему не re.DOTALL + +`re.search(r"\{.*\}", text, re.DOTALL)` жадный — захватывает ВСЁ между первой +`{` и последней `}`. Если в ответе два JSON-блока (пояснение + результат), +или JSON обрезан на середине вложенности — re.DOTALL даёт мусор. + +Brace-depth парсер корректно обрабатывает: +- Обрезанный JSON (Qwen не успела закончить `}`) +- Два JSON-блока (берёт первый валидный) +- JSON с пояснениями вокруг + +## Проблема с пустыми ответами Ollama + +Qwen3:8b через `/api/generate` иногда возвращает `response=""`. +Причины (гипотезы): +- Конкуренция за GPU (если другой процесс жрёт VRAM) +- Длинный контекст + маленький `num_predict` +- Сбой внутри Ollama + +**Решение:** retry (2-3 попытки с `time.sleep(1)`). + +**Не путать:** `/api/generate` (generate) vs `/api/chat` (chat) — разные +форматы ответов. + +## Очистка тела письма + +Критично для распознавания подписи. HTML-мусор забивает контекст. + +```python +def clean_body(body): + # Удаляем <#part ...> блоки + body = re.sub(r'<#part[^>]*>|<#/part>', '', body) + # Удаляем HTML-теги + body = re.sub(r'<[^>]+>', '', body) + # Удаляем mailto: ссылки (mailto:estorozhenko@...) + body = re.sub(r'\(mailto:[^)]+\)', '', body) + # Заменяем unicode-пробелы (NBSP \u00a0, zero-width \u2060 и др.) + body = re.sub(r'[\u00a0\u2000-\u200f\u2028-\u202f\u2060]+', ' ', body) + # Удаляем трекинг-пиксели и UTM-ссылки + body = re.sub(r'https?://tn-eoc\.[^\s]+', '', body) + body = re.sub(r'https?://[^\s]+\?utm_[^\s]+', '', body) + # Схлопываем пустые строки + body = re.sub(r'\n{3,}', '\n\n', body) + return body.strip() +``` + +## Промпт для LLM + +```text +Ты — экстрактор контактных данных из писем. Твоя задача — найти подпись +отправителя в конце письма и извлечь структурированные данные. + +Правила поиска подписи: +- Подпись обычно отделена от тела письма разделителями: + "-- \\n", "---\\n", "С уважением,", "С наилучшими пожеланиями,", + "Best regards,", "Kind regards,", "С ув.,", "—————" +- Если разделителя нет — последние 5-15 строк письма это подпись +- Не путай подпись с цитируемым текстом переписки + (обычно начинается с ">" или "On ... wrote:" или "— Пересылаемое сообщение —") +- Ignore boilerplate (disclaimers, confidentiality notices) + +Извлеки из подписи: +1. full_name — полное имя (ФИО) +2. email — email адрес (если есть в подписи, иначе null) +3. phone — основной телефон +4. phone_secondary — дополнительный телефон (если есть) +5. position — должность +6. company — название компании/организации +7. address — почтовый/юридический адрес (если есть) +8. raw_signature — полный текст найденной подписи (для отладки) + +Если никакой подписи не найдено — верни только full_name и email, +остальные поля null. + +Верни ТОЛЬКО JSON, без пояснений: +{"full_name": "...", "email": null, ...} +``` + +### Экранирование JSON в Python str.format() + +При использовании `str.format()` фигурные скобки в JSON-примере внутри промпта +конфликтуют с шаблоном. Решение — двойные скобки `{{...}}` для литеральных +`{...}` в шаблоне: + +```python +PROMPT_TEMPLATE = """...Верни ТОЛЬКО JSON, без пояснений: +{{"full_name": "...", "email": null, ...}} +... +{body}""" +``` + +## Формат vCard 4.0 + +``` +BEGIN:VCARD +VERSION:4.0 +FN:Иванов Иван Иванович +N:Иванов;Иван;Иванович;;; +EMAIL;TYPE=WORK:ivan@example.com +TEL;TYPE=WORK:+7-123-456-78-90 +TITLE:Генеральный директор +ORG:ООО "Ромашка" +ADR;TYPE=WORK:;;ул. Ленина, д.1;Москва;;123456;Россия +NOTE:Извлечено из письма от 2026-07-16 (UID 11559, INBOX) +END:VCARD +``` + +## Дедупликация + +1. **email — primary key.** Если контакт с таким email уже есть → обновить поля + (телефон мог поменяться, должность — повысили). +2. Если email нет, но совпадает full_name (fuzzy) — создать новый, но + зафиксировать в `note` возможный дубль. +3. Если нет ни email, ни full_name — не сохранять. +4. `first_seen` / `last_seen` в контакте для понимания актуальности. + +## Обработка reply/forward писем + +В reply/forward письмах подпись может принадлежать НЕ отправителю в `From:`. +Например: Головлев пересылает письмо Елены Стороженко — её подпись в теле. +Это нормально — контакт Елены всё равно ценный. LLM не пытается сверять +отправителя с извлечёнными данными. diff --git a/references/cron-workflows.md b/references/cron-workflows.md new file mode 100644 index 0000000..bb09997 --- /dev/null +++ b/references/cron-workflows.md @@ -0,0 +1,142 @@ +# Cron Workflows for Email Archive + +## Architecture overview + +Two independent cron pipelines that share the same email storage: + +``` +mail-archive (every 5 min, no_agent=True) + ↓ +INBOX/UID/email.md ←── contacts-extractor (every 30 min, script-based, LLM) + ↓ + contacts.vcf + contacts.json +``` + +Both use **script-based cron** (the `script=` parameter) with the script in +`~/.hermes/scripts/`. The archive job is `no_agent=True` (pure shell — no LLM +tokens consumed). The contacts job uses the agent loop because it calls LLM +internally via Python. + +## Mail archive cron (bulk sync) + +```bash +# Script: ~/.hermes/scripts/mail-archive.sh +#!/usr/bin/env bash +set -euo pipefail +cd /opt/hermes/email-assistant +exec python3 scripts/mail_archive.py --all --limit 10 +``` + +Cron creation: +```bash +hermes cron create \ + --name "mail-archive-every-5min" \ + --schedule "every 5m" \ + --script mail-archive.sh \ + --no-agent \ + --deliver local +``` + +Key details: +- `no_agent=True` → pure script mode, zero LLM cost per tick +- `deliver=local` → output saved, no notification (noisy at 5min intervals) +- Script path MUST be relative in `~/.hermes/scripts/` — absolute paths rejected +- `--all --limit 10` processes all 22 folders with 10 emails per folder per tick + +### Shell wrapper with per-folder timeouts + +When using `--all`, one slow IMAP folder can stall the entire run. The +shell-embedded approach handles this natively — each folder gets its own +`timeout`: + +```bash +FOLDERS=(INBOX "Отправленные" Archive Sent ...) +TIMEOUT=60 +LIMIT=10 + +for folder in "${FOLDERS[@]}"; do + timeout $TIMEOUT python3 scripts/mail_archive.py --folder "$folder" --limit $LIMIT 2>&1 || true +done +``` + +This is the ACTUAL approach used in `mail-archive.sh`. The `--all` flag in +`mail_archive.py` iterates folders internally but without per-folder timeouts, +so the shell wrapper is the recommended pattern when you control the cron script. + +## Contacts extractor cron (LLM-based) + +```bash +# Script: ~/.hermes/scripts/contacts-cron.sh +#!/usr/bin/env bash +set -euo pipefail +cd /opt/hermes/email-assistant +exec python3 scripts/contacts_extractor.py --limit 15 +``` + +Cron creation: +```bash +hermes cron create \ + --name "contacts-extractor-every-30m" \ + --schedule "every 30m" \ + --script contacts-cron.sh \ + --deliver local +``` + +Key differences from archive cron: +- **NO `--no-agent`** — the contacts extractor calls LLM (Qwen3:8b via Ollama) + internally. Without the agent loop, the script runs but output isn't + delivered/visible. +- **`--limit 15`** — Qwen3:8b takes ~20s per email. 15 emails × 20s = ~5 min, + well within the 30-min window. Bump to 25-30 if Qwen is on a GPU. +- **`deliver=local`** — results saved to disk, no notification. The agent + generates a summary message on each tick. + +## Transition from bulk to incremental + +When state files stop advancing (all emails archived): + +1. Update archive cron: smaller limit or longer interval + ```bash + hermes cron update --schedule "every 30m" + ``` +2. Keep contacts cron at `every 30m` — it always processes only new emails + (UID tracking in `last_scan.json`) + +## Testing cron scripts + +Before scheduling, verify the script works by running it once: + +```bash +timeout 120 bash ~/.hermes/scripts/contacts-cron.sh +``` + +Check for: +- Exit code 0 = success; 124 = timeout (reduce `--limit`) +- Stale output = script isn't finding new files (check `last_scan.json` UIDs) +- Python import errors = missing dependencies (run `pip install -r requirements.txt`) + +## Pitfalls + +1. **Script path MUST be relative.** `--script /absolute/path` is silently + rejected. Copy the script to `~/.hermes/scripts/` and pass just the filename. + +2. **Contacts cron needs the agent loop.** Unlike the pure-shell archive cron, + contacts cron must NOT have `--no-agent`. Without the agent, the LLM calls + in `contacts_extractor.py` still execute (it's Python), but the job output + is never delivered — you'd see "last_status=completed, last_output=" + even though contacts.vcf was updated. + +3. **Qwen3:8b speed varies.** On CPU-only Ollama it's ~20s/email. On discrete + GPU (NVIDIA, AMD ROCm) it's ~2-3s/email. Set `--limit` accordingly: + - CPU: 10-15 emails per 5-min cron window + - GPU: 50-100 emails per 5-min window + +4. **Cron jobs run from the session's last state, not a fresh login.** + Environment variables (like `PATH`) may differ. Always use absolute paths or + `cd` to the project directory in the script. + +5. **`deliver=local` vs `deliver=origin`.** `local` saves output to the cron + DB only (viewable via `cronjob action=list`). `origin` sends it back to the + Hermes session that created the cron. For per-5min archive runs, `local` + avoids spam. For contacts (every 30min), consider `origin` if you want + a notification. \ No newline at end of file diff --git a/references/digest-pipeline.md b/references/digest-pipeline.md new file mode 100644 index 0000000..759184b --- /dev/null +++ b/references/digest-pipeline.md @@ -0,0 +1,59 @@ +# Digest Pipeline — еженедельный дайджест почты + +## Назначение + +Автоматическая генерация краткого дайджеста входящей почты за период (7 дней по умолчанию) через локальную LLM. + +## Компонент + +`/opt/hermes/email-assistant/scripts/digest.py` + +## Pipeline + +1. Читает SQLite-индекс (`mail_index.db`), выбирает письма за N дней +2. Группирует по папкам +3. Формирует текстовый блок для LLM: папка → список писем (дата, отправитель, тема) +4. Вызывает Qwen3:8b через Ollama с промптом на русском +5. Сохраняет дайджест в `/opt/hermes/email/digests/digest-YYYY-MM-DD.md` +6. Выводит в stdout (флаг `--output` управляет) + +## Промпт + +LLM получает запрос написать краткий дайджест для руководителя: +- Статистика: сколько писем, сколько папок +- По папкам — 1-3 предложения об основных темах +- Выделить важные письма (руководство, тендеры, финансы) +- На русском, ≤300 слов, без перечисления каждого письма +- Группировать по темам, игнорировать тех.мусор + +## Использование + +```bash +python3 scripts/digest.py # 7 дней +python3 scripts/digest.py --days 14 # 2 недели +python3 scripts/digest.py --folder INBOX # только INBOX +python3 scripts/digest.py --output stdout # только в stdout без файла +``` + +## Cron + +```bash +hermes cron create \ + --name "email-digest-sunday" \ + --schedule "0 9 * * 0" \ + --prompt "Запусти digest.py за 7 дней" \ + --script /opt/hermes/email-assistant/scripts/digest.py \ + --no-agent +``` + +## Зависимости + +- `mail_index.db` — должен быть проиндексирован (mail_index.py) +- Qwen3:8b через Ollama (localhost:11434) +- ~15-20 секунд на генерацию через CPU + +## Важные замечания + +- `body_preview` в индексе — только 500 символов, поэтому FTS5-поиск по телу ограничен +- Для дайджеста используется только subject/from/date — тело не передаётся в LLM +- Группировка по папкам — базовая. Если нужно тематическое группирование — доработать \ No newline at end of file diff --git a/references/dynamic-folder-discovery.md b/references/dynamic-folder-discovery.md new file mode 100644 index 0000000..dc51187 --- /dev/null +++ b/references/dynamic-folder-discovery.md @@ -0,0 +1,171 @@ +# Dynamic Folder Discovery — решение для хардкода INBOX_SUBFOLDERS + +## Контекст + +`mail_archive.py` содержит захардкоженный `INBOX_SUBFOLDERS` (18 папок). +На IMAP-сервере реально 137 подпапок INBOX, включая многоуровневые. + +## Текущее состояние + +### State-файлы (следы прошлых запусков) + +``` +/opt/hermes/email/state/ +├── mail-archive-last-Archive.json +├── mail-archive-last-INBOX.json +├── mail-archive-last-INBOX_!Scan.json +├── mail-archive-last-Sent.json +├── mail-archive-last-folder_41a7755da018.json # хэш от не-ASCII имени +├── mail-archive-last-folder_5dd417336b45.json +├── mail-archive-last-folder_6dfd3661f092.json +├── mail-archive-last-folder_a2d2b831a004.json +├── mail-archive-last-folder_a59f0da18423.json +├── mail-archive-last-folder_b002f4b75367.json +├── mail-archive-last-folder_b8338b886347.json +├── mail-archive-last-folder_ba130b3adfda.json +├── mail-archive-last-folder_e5dd3de630eb.json +``` + +State-файлы уже поддерживают произвольные имена папок (через `get_state_file()` — +MD5-хэш для не-ASCII). Инфраструктура готова. + +### Хардкод в mail_archive.py (строки 54-73) + +```python +INBOX_SUBFOLDERS = [ + "INBOX/!Scan", + "INBOX/!Битрикс", + "INBOX/!ВГ Чек листы", + "INBOX/!Документооборот", + "INBOX/!Завки", + "INBOX/!Материалы", + "INBOX/!Отчеты", + "INBOX/!Персонал", + "INBOX/!Протоколы", + "INBOX/!Реестр оплаты", + "INBOX/!Торик", + "INBOX/Бюджет", + "INBOX/Контрагенты", + "INBOX/ЛНД", + "INBOX/Организация работы", + "INBOX/Приемка и стройка", + "INBOX/Системы", + "INBOX/Эксплуатация", +] +``` + +### Реальные папки на сервере (137 шт.) + +Полный список получен через `himalaya folder list`: + +``` +INBOX/!Scan +INBOX/!Битрикс +INBOX/!ВГ Чек листы +INBOX/!Документооборот +INBOX/!Завки +INBOX/!Материалы +INBOX/!Отчеты +INBOX/!Персонал +INBOX/!Персонал/ОТ и ТБ +INBOX/!Протоколы +INBOX/!Реестр оплаты +INBOX/!Реестр оплаты/Акты +INBOX/!Реестр оплаты/Закупки +INBOX/!Реестр оплаты/Закупки/10 рабочих мест +INBOX/!Реестр оплаты/Закупки/NanoCad +INBOX/!Реестр оплаты/Закупки/Горизонт Ноутбуки +INBOX/!Реестр оплаты/Закупки/Дооснащение ТГ +INBOX/!Реестр оплаты/Закупки/Касперский для ВГ +INBOX/!Реестр оплаты/Закупки/Модернизация Wi-Fi +INBOX/!Реестр оплаты/Закупки/Оборудование горизонт +INBOX/!Реестр оплаты/Закупки/Сервер +INBOX/!Реестр оплаты/Закупки/Цветной МФУ ТГ +INBOX/!Торик +INBOX/Бюджет +INBOX/Бюджет/Винный город +INBOX/Бюджет/Винный город/CAPEX 2025 +INBOX/Бюджет/Винный город/Capex 2026 +INBOX/Бюджет/Винный город/OPEX 2025 +INBOX/Бюджет/Винный город/OPEX 2026 +INBOX/Бюджет/Винный город/OPEX 2027 +INBOX/Бюджет/Горизонт +INBOX/Бюджет/Горизонт/CAPEX 2025 +INBOX/Бюджет/Горизонт/CAPEX 2026 +INBOX/Бюджет/Горизонт/OPEX 2026 +INBOX/Бюджет/Тихая гавань +INBOX/Бюджет/Тихая гавань/CAPEX 2025 +INBOX/Бюджет/Тихая гавань/CAPEX 2026 +INBOX/Бюджет/Тихая гавань/OPEX 2025 +INBOX/Бюджет/Тихая гавань/OPEX 2026 +INBOX/Контрагенты +INBOX/Контрагенты/iiko +INBOX/Контрагенты/iiko/Тихая гавань +INBOX/Контрагенты/АБ-Транзит +INBOX/Контрагенты/Аврора +INBOX/Контрагенты/Ассистент +INBOX/Контрагенты/Билайн +INBOX/Контрагенты/Интеллект - ilocks - замки +INBOX/Контрагенты/Интертех Лицензии Huawei +INBOX/Контрагенты/Квадротек +INBOX/Контрагенты/Кит +INBOX/Контрагенты/Компания АйТи +INBOX/Контрагенты/Кристалл +INBOX/Контрагенты/Крым-Строй-Сервис +INBOX/Контрагенты/Лимон +INBOX/Контрагенты/Медиа-Сервис +INBOX/Контрагенты/МеталлПрофиль +INBOX/Контрагенты/Ново-групп +INBOX/Контрагенты/Орт-Сервис +INBOX/Контрагенты/Партнер +INBOX/Контрагенты/Партнеры +INBOX/Контрагенты/Пиксель +INBOX/Контрагенты/Поставщики +INBOX/Контрагенты/Рубикон-С +INBOX/Контрагенты/СБСС +INBOX/Контрагенты/Самоваръ +INBOX/Контрагенты/Сервисный центр +INBOX/Контрагенты/Смарт +INBOX/Контрагенты/СпецТехМонтаж +INBOX/Контрагенты/Стрим +INBOX/Контрагенты/СтройПартнер +INBOX/Контрагенты/ТД ТрансМет +INBOX/Контрагенты/ТД Лайт +INBOX/Контрагенты/Технологии Доверия +INBOX/Контрагенты/Технополис +INBOX/Контрагенты/Типография +INBOX/Контрагенты/УралТрансПром +INBOX/Контрагенты/Физ лица +INBOX/Контрагенты/Цифровые решения +INBOX/Контрагенты/ЭнергоСпецКомплект +INBOX/Контрагенты/Энергия +INBOX/Контрагенты/Югспецодежда +INBOX/ЛНД +INBOX/Организация работы +INBOX/Приемка и стройка +INBOX/Системы +INBOX/Эксплуатация +``` + +## План исправления + +1. В `mail_archive.py` заменить `INBOX_SUBFOLDERS` на функцию `get_all_folders()`: + - `himalaya folder list --output json` + - Фильтр: папки, начинающиеся с `INBOX/` (исключить Trash, Drafts, RSS, Archives) + - Исключить `INBOX` (корневую — она уже в `FOLDERS`) + +2. `--all` должен использовать `FOLDERS + get_all_inbox_subfolders()` вместо `FOLDERS + INBOX_SUBFOLDERS` + +3. `mail-archive-every-5min` cron автоматически получит новые папки без изменения конфигурации + +4. State-файлы уже готовы — `get_state_file()` работает с любыми именами папок + +## Edge cases + +- Папки могут исчезнуть между запусками — `archive_folder()` уже обрабатывает + `No such folder` через `get_envelopes()` (возвращает `[]`) +- Новая папка без писем — `get_envelopes()` вернёт пустой список, state не создаётся +- Папки с `\HasNoChildren` и `\HasChildren` — `himalaya folder list` показывает все + (независимо от флагов), так что фильтр по `\HasNoChildren` не нужен +- Очень глубокие папки (3-4 уровня) — `get_state_file()` через MD5-хэш поддерживает + любую длину имени \ No newline at end of file diff --git a/references/jino-server-setup.md b/references/jino-server-setup.md new file mode 100644 index 0000000..a39b246 --- /dev/null +++ b/references/jino-server-setup.md @@ -0,0 +1,124 @@ +# Jino (jino.ru) — настройка почтового ящика в Himalaya + +## Серверы + +| Протокол | Сервер | Порт | Шифрование | Аутентификация | +|----------|--------|------|------------|----------------| +| IMAP | mail.jino.ru | 143 | STARTTLS | PLAIN, CRAM-MD5 | +| IMAP SSL | mail.jino.ru | 993 | TLS | PLAIN, CRAM-MD5 | +| POP3 SSL | mail.jino.ru | 995 | TLS | USER/PASS | +| SMTP | smtp.jino.ru | 587 | STARTTLS | PLAIN, LOGIN, CRAM-MD5 | +| SMTP SSL | smtp.jino.ru | 465 | TLS | PLAIN, LOGIN, CRAM-MD5 | + +**Логин:** полный email `hermes@nixg.ru` (не local-part без домена — сервер вернёт "Email not valid"). + +## Himalaya config + +```toml +[accounts.jino-hermes] +email = "hermes@nixg.ru" +display-name = "Hermes Agent" +default = false + +backend.type = "imap" +backend.host = "mail.jino.ru" +backend.port = 993 +backend.encryption.type = "tls" +backend.login = "hermes@nixg.ru" +backend.auth.type = "password" +backend.auth.raw = "PASSWORD_HERE" + +folder.aliases.inbox = "INBOX" + +[accounts.jino-hermes.message.send] +backend.type = "smtp" +backend.host = "smtp.jino.ru" +backend.port = 465 +backend.encryption.type = "tls" +backend.login = "hermes@nixg.ru" +backend.auth.type = "password" +backend.auth.raw = "PASSWORD_HERE" +``` + +## Диагностика + +### Быстрая проверка IMAP (Python) + +```python +import imaplib +M = imaplib.IMAP4_SSL('mail.jino.ru', 993) +try: + M.login('hermes@nixg.ru', 'PASSWORD') + print('OK') + M.logout() +except Exception as e: + print(f'Login failed: {e}') +``` + +### Быстрая проверка SMTP (Python) + +```python +import smtplib +S = smtplib.SMTP('smtp.jino.ru', 587, timeout=15) +S.ehlo() +S.starttls() +S.ehlo() +try: + S.login('hermes@nixg.ru', 'PASSWORD') + print('SMTP OK') +except Exception as e: + print(f'SMTP login error: {e}') +S.quit() +``` + +### Проверка POP3 + +```python +import poplib +P = poplib.POP3_SSL('mail.jino.ru', 995, timeout=15) +try: + P.user('hermes@nixg.ru') + P.pass_('PASSWORD') + msgs, _ = P.stat() + print(f'POP3 OK, {msgs} messages') + P.quit() +except Exception as e: + print(f'POP3 error: {e}') +``` + +### Просмотр IMAP-возможностей сервера + +```python +M = imaplib.IMAP4_SSL('mail.jino.ru', 993) +M.capability() +print(M.capabilities) # выведет: IMAP4 IMAP4REV1 UIDPLUS CHILDREN NAMESPACE QUOTA IDLE AUTH=PLAIN AUTH=CRAM-MD5 +M.shutdown() +``` + +## Типовые проблемы + +### "Login failed" на всех протоколах + +1. **Ящик не активирован** — Jino (и особенно хостинг-аккаунты на nixg.ru) могут требовать первый вход через панель управления или webmail +2. **Неправильный пароль** — сбросить в cp.jino.ru → Почта → нужный ящик → Изменить пароль +3. **Доступ по протоколам отключён** — в панели Jino нужно проверить, что IMAP/POP3/SMTP включены для ящика +4. **Сервер ждёт активации** — на новых доменах почта может не работать до завершения регистрации/верификации + +Если логин не проходит через `himalaya` — **проверить вручную через Python** (см. выше). Python показывает ту же ошибку, но без дополнительных слоёв (Rust-клиент himalaya может добавить свою обёртку). + +### "Email not valid" + +Логин передан без доменной части (только `hermes`, а не `hermes@nixg.ru`). Jino IMAP требует полный email. + +## Множественные аккаунты в Himalaya + +Конфиг может содержать сколько угодно аккаунтов. Для переключения: + +```bash +# Через --account (порядок аргументов важен): +himalaya --account hermes envelope list --limit 10 + +# Или временно сделать default и вернуть обратно: +vim ~/.config/himalaya/config.toml +# выставить default = true у нужного аккаунта +``` \ No newline at end of file diff --git a/references/sqlite-index-architecture.md b/references/sqlite-index-architecture.md new file mode 100644 index 0000000..48cb252 --- /dev/null +++ b/references/sqlite-index-architecture.md @@ -0,0 +1,125 @@ +# SQLite-индекс архива писем + +## Назначение + +Быстрый поиск и трекинг по архиву email.md без grep-а по всем папкам. +Основа для: contacts_extractor (знает какие письма обработаны), sqlite_search (FTS5), digest (выборка по дате). + +## Компоненты + +| Компонент | Путь | Назначение | +|-----------|------|------------| +| Индексатор | `/opt/hermes/email-assistant/scripts/mail_index.py` | Сканирует email.md → SQLite | +| Поиск | `/opt/hermes/email-assistant/scripts/sqlite_search.py` | FTS5-поиск по индексу | +| База | `/opt/hermes/email/mail_index.db` | SQLite (WAL mode) | + +## Схема БД + +### emails — основная таблица + +```sql +CREATE TABLE IF NOT EXISTS emails ( + path TEXT PRIMARY KEY, -- относительный путь от EMAIL_ROOT + uid INTEGER, -- числовой UID из пути + folder TEXT, -- INBOX, INBOX/!Scan, Sent... + date TEXT, -- дата из frontmatter (ISO) + from_addr TEXT, -- отправитель + to_addrs TEXT, -- получатели + subject TEXT, -- тема + body_preview TEXT, -- первые 500 символов тела (без HTML) + contacts_extracted INTEGER DEFAULT 0, -- 0/1 — обработано contacts_extractor + contacts_skipped INTEGER DEFAULT 0, -- 0/1 — нет подписи / LLM error + first_seen TEXT, -- когда проиндексировано + last_scanned TEXT, -- последняя проверка contacts + file_mtime REAL -- mtime файла для инкрементальной проверки +); +``` + +### email_fts — FTS5 virtual table + +```sql +CREATE VIRTUAL TABLE IF NOT EXISTS email_fts USING fts5( + subject, from_addr, to_addrs, body_preview, + content='emails', + content_rowid='rowid', + tokenize='unicode61' +); +``` + +Синхронизация через триггеры INSERT/UPDATE/DELETE + FTS5 rebuild. + +### Индексы + +- `idx_emails_folder` — быстрая фильтрация по папке +- `idx_emails_uid` — lookup по UID +- `idx_emails_contacts` — необработанные письма (contacts_extracted=0) +- `idx_emails_date` — сортировка по дате + +## Контракт между mail_index.py и contacts_extractor.py + +**mail_index.py** владеет схемой и создаёт таблицы. **contacts_extractor.py** — только читает/пишет поля `contacts_extracted`, `contacts_skipped`, `last_scanned`. + +```sql +-- contacts_extractor берёт необработанные письма: +SELECT rowid, path, uid, folder, from_addr, subject +FROM emails +WHERE contacts_extracted = 0 AND contacts_skipped = 0 +ORDER BY folder, uid +LIMIT ? + +-- После обработки: +UPDATE emails SET contacts_extracted=1, last_scanned=? WHERE rowid=? +UPDATE emails SET contacts_skipped=1, last_scanned=? WHERE rowid=? +``` + +## Быстродействие + +- 2073 письма → полная индексация ~9 секунд +- FTS5-поиск — мгновенно (<100ms) +- WAL mode — конкурентные чтения не блокируют запись +- Инкрементальная индексация по mtime — доли секунды + +## Использование sqlite_search.py + +```bash +# Простой поиск (AND по умолчанию) +python3 sqlite_search.py 'Стороженко' +python3 sqlite_search.py 'битрикс OR контрагент' +python3 sqlite_search.py '"точечная фраза"' + +# Фильтры +python3 sqlite_search.py --folder 'INBOX/!Отчеты' +python3 sqlite_search.py --limit 20 + +# Специальные префиксы (точно в поле from_addr/subject через LIKE) +python3 sqlite_search.py 'from:example@mail' +python3 sqlite_search.py 'subject:отчёт' + +# Вкл. тело письма в FTS5 (медленнее, но находит больше) +python3 sqlite_search.py --body +``` + +## Операторы FTS5 + +- `AND` — по умолчанию между словами +- `OR` — `'битрикс OR контрагент'` +- `"точная фраза"` — кавычки для точного совпадения +- `-исключить` — минус перед словом +- `prefix*` — wildcard (звёздочка на конце) + +## mail_index.py — ключевые параметры + +```bash +python3 mail_index.py # полная переиндексация +python3 mail_index.py --incremental # только новые (по mtime) +python3 mail_index.py --search "..." # поиск (встроенный, без FTS5) +python3 mail_index.py --stats # статистика +``` + +## Важные детали + +- **body_preview** — только первые 500 символов. Для full-text search с телом используй `sqlite_search.py --body` (FTS5 на preview). +- **contacts_extracted/contacts_skipped** — взаимоисключающие флаги. Если ни один не 1 — письмо не обработано. +- **file_mtime** — для инкрементальной индексации. Если mtime файла > last_mtime в БД — переиндексировать. +- **FTS5 rebuild** — вызывается после каждой полной индексации для согласованности. +- Исключает папки `contacts/` и `state/` из сканирования. \ No newline at end of file