mirror of
https://gitverse.ru/kpa39l/email-local-archive.git
synced 2026-09-29 09:15:11 +00:00
Initial commit: Hermes skill email-local-archive
This commit is contained in:
@@ -0,0 +1,528 @@
|
||||
---
|
||||
name: email-local-archive
|
||||
description: "Локальный архив почты: инкрементальное сохранение писем из IMAP в файловую структуру на локальный диск с метаданными, телом, вложениями и извлечением адресной книги через LLM."
|
||||
version: 1.6.0
|
||||
author: estorozhenko
|
||||
platforms: [linux]
|
||||
prerequisites:
|
||||
commands: [himalaya, python3]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [email, archive, imap, backup, contacts]
|
||||
---
|
||||
|
||||
# Email Local Archive
|
||||
|
||||
Этот навык позволяет агенту архивировать письма из почтового ящика
|
||||
в локальную файловую систему (`/opt/hermes/email/`) и извлекать адресную
|
||||
книгу из подписей входящих писем через локальную LLM.
|
||||
|
||||
**История:** архив был перенесён с Yandex Disk (WebDAV davfs2) на локальный
|
||||
диск `/opt/hermes/email/` из-за катастрофической медлительности WebDAV при
|
||||
rm/find/ls на больших деревьях.
|
||||
|
||||
## Структура хранилища
|
||||
|
||||
```
|
||||
/opt/hermes/email/
|
||||
├── INBOX/
|
||||
│ ├── YYYY/MM/UID/
|
||||
│ │ ├── email.md # YAML-frontmatter + тело письма
|
||||
│ │ └── attachments/ # вложения
|
||||
│ └── ...
|
||||
├── Отправленные/...
|
||||
├── Archive/...
|
||||
├── Sent/...
|
||||
├── state/
|
||||
│ └── mail-archive-last-*.json # last_uid per folder
|
||||
└── contacts/ # адресная книга (извлечение через LLM)
|
||||
├── contacts.vcf # vCard 4.0 для импорта
|
||||
├── contacts.json # машинный формат
|
||||
├── index.json # email → contact_id
|
||||
└── last_scan.json # трекинг обработанных писем (UID + mtime)
|
||||
```
|
||||
|
||||
Формат email.md — YAML-frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
id: 255
|
||||
folder: INBOX
|
||||
subject: "Re: ..."
|
||||
from: "Name <addr>"
|
||||
to: "Name <addr>"
|
||||
date: "2025-07-10 11:51+03:00"
|
||||
flags: ["Seen"]
|
||||
has_attachment: false
|
||||
message_id: <...@domain.ru>
|
||||
in_reply_to: <...@domain.ru>
|
||||
references: <...> <...>
|
||||
cc: "Name <addr>"
|
||||
content_type: multipart/mixed; boundary=...
|
||||
---
|
||||
Body text here...
|
||||
```
|
||||
|
||||
## Компоненты
|
||||
|
||||
| Компонент | Путь | Назначение |
|
||||
|-----------|------|------------|
|
||||
| Архиватор | `/opt/hermes/email-assistant/scripts/mail_archive.py` | Тупой насос: himalaya → email.md. Без LLM. |
|
||||
| Индексатор | `/opt/hermes/email-assistant/scripts/mail_index.py` | SQLite-индекс всех email.md для быстрого поиска |
|
||||
| Поиск (FTS5) | `/opt/hermes/email-assistant/scripts/sqlite_search.py` | Полнотекстовый поиск по SQLite-индексу |
|
||||
| Contacts Extractor | `/opt/hermes/email-assistant/scripts/contacts_extractor.py` | LLM-парсинг подписей через Qwen3:8b |
|
||||
| Дайджест | `/opt/hermes/email-assistant/scripts/digest.py` | Еженедельный дайджест почты через Qwen3:8b |
|
||||
| Shell-обёртка | `/opt/hermes/email-assistant/scripts/mail-archive.sh` | Для systemd/cron |
|
||||
| Документация | `/opt/hermes/email-assistant/context/CONTACTS.md` | Архитектура, промпт, формат vCard |
|
||||
|
||||
## SQLite-индекс (mail_index.py + sqlite_search.py)
|
||||
|
||||
Быстрый поиск по архиву без grep. База: `/opt/hermes/email/mail_index.db`.
|
||||
|
||||
**Схема:** таблица `emails` (2073 записи) + FTS5 virtual table `email_fts` для полнотекстового поиска. Триггеры синхронизируют FTS5 при INSERT/UPDATE/DELETE. Индексы: folder, uid, contacts_extracted, date.
|
||||
|
||||
**Контракт с contacts_extractor:** поля `contacts_extracted` (0/1) и `contacts_skipped` (0/1) — общие. mail_index.py создаёт схему, contacts_extractor.py пишет флаги.
|
||||
|
||||
**Поиск:**
|
||||
```bash
|
||||
python3 /opt/hermes/email-assistant/scripts/sqlite_search.py 'Стороженко'
|
||||
python3 /opt/hermes/email-assistant/scripts/sqlite_search.py 'битрикс OR контрагент'
|
||||
python3 /opt/hermes/email-assistant/scripts/sqlite_search.py 'from:example@mail'
|
||||
python3 /opt/hermes/email-assistant/scripts/sqlite_search.py --folder 'INBOX/!Отчеты'
|
||||
```
|
||||
|
||||
Операторы FTS5: AND (по умолчанию), OR, `"точная фраза"`, `-исключить`, `prefix*`.
|
||||
|
||||
Подробнее: `references/sqlite-index-architecture.md`
|
||||
|
||||
## Дайджест почты (digest.py)
|
||||
|
||||
Еженедельный дайджест через Qwen3:8b. Выборка из SQLite за N дней, группировка по папкам, LLM-генерация краткого обзора на русском. Сохраняется в `/opt/hermes/email/digests/digest-YYYY-MM-DD.md`.
|
||||
|
||||
```bash
|
||||
python3 /opt/hermes/email-assistant/scripts/digest.py # 7 дней
|
||||
python3 /opt/hermes/email-assistant/scripts/digest.py --days 14
|
||||
```
|
||||
|
||||
Подробнее: `references/digest-pipeline.md`
|
||||
|
||||
## Пайплайн (полный)
|
||||
|
||||
```
|
||||
mail_archive.py ──→ INBOX/UID/email.md (тупой насос, без LLM)
|
||||
↓
|
||||
contacts_extractor.py (LLM через delegate_task)
|
||||
↓
|
||||
/opt/hermes/email/contacts/
|
||||
├── contacts.vcf ← vCard 4.0, импорт в любой клиент
|
||||
├── contacts.json ← машинная база (дедупликация)
|
||||
├── index.json ← email → contact_id
|
||||
└── last_scan.json ← трекинг обработанных
|
||||
```
|
||||
|
||||
### Contacts Extractor: ключевые решения
|
||||
|
||||
| Вопрос | Решение |
|
||||
|--------|---------|
|
||||
| Body целиком или последние N строк? | **Целиком** — контекст для точного распознавания подписи |
|
||||
| Поле department? | **Нет** — достаточно company + position |
|
||||
| vCard версия? | **4.0** — соцсети, фото, расширенные поля |
|
||||
| Какие папки обрабатывать? | **Только входящие** (INBOX + подпапки) |
|
||||
| Как трекать обработанные? | **UID + mtime** — UID для быстрого skip, mtime для отлова запоздалых писем |
|
||||
|
||||
### Промпт для LLM (Qwen3:8b через Ollama)
|
||||
|
||||
```
|
||||
Ты — экстрактор контактов. Извлеки данные отправителя из подписи письма.
|
||||
|
||||
Отправитель (для сверки): {from_name} <{from_email}>
|
||||
|
||||
Найди подпись. Она обычно:
|
||||
- После "-- \n", "---\n", "Best regards,", "Kind regards,"
|
||||
- После "С уважением,", "С наилучшими пожеланиями,"
|
||||
- После "С ув.,", "Всего доброго,"
|
||||
- Если разделителя нет — последние 5-15 строк
|
||||
|
||||
НЕ путай подпись с:
|
||||
- Цитируемой предыдущей перепиской (строки с ">" или "On ... wrote:")
|
||||
- Пересланным сообщением ("— Пересылаемое сообщение —")
|
||||
- Дисклеймером/конфиденциальностью внизу письма
|
||||
|
||||
Верни JSON:
|
||||
{
|
||||
"full_name": null, // Полное имя (ФИО)
|
||||
"phone": null, // Основной телефон
|
||||
"phone_secondary": null, // Дополнительный телефон
|
||||
"position": null, // Должность
|
||||
"company": null, // Компания/организация
|
||||
"address": null, // Адрес (почтовый/юридический)
|
||||
"signature_found": false // Была ли подпись вообще
|
||||
}
|
||||
```
|
||||
|
||||
## Основные команды
|
||||
|
||||
```bash
|
||||
# Архивация INBOX (10 писем за раз для bulk sync)
|
||||
python3 /opt/hermes/email-assistant/scripts/mail_archive.py --folder INBOX --limit 10
|
||||
|
||||
# Архивация всех папок
|
||||
python3 /opt/hermes/email-assistant/scripts/mail_archive.py --all --limit 10
|
||||
|
||||
# Поиск по архиву (быстрый — локальный диск)
|
||||
grep -ril 'тема' /opt/hermes/email/**/email.md 2>/dev/null
|
||||
|
||||
# Статус архива
|
||||
cat /opt/hermes/email/state/mail-archive-last-*.json
|
||||
```
|
||||
|
||||
## Проблема: хардкод подпапок INBOX (18 из 137)
|
||||
|
||||
**Статус:** ❌ Не решено — задача зафиксирована в STATUS.md как Фаза 1.7.
|
||||
|
||||
**Проблема:** `mail_archive.py` содержит захардкоженный список `INBOX_SUBFOLDERS` (18 папок),
|
||||
но на IMAP-сервере реально **137 подпапок INBOX**, включая многоуровневые глубиной 2-3:
|
||||
|
||||
```
|
||||
INBOX/!Персонал/ОТ и ТБ
|
||||
INBOX/Бюджет/Винный город/CAPEX 2025
|
||||
INBOX/Контрагенты/iiko/Тихая гавань
|
||||
INBOX/!Реестр оплаты/Закупки/Сервер
|
||||
...
|
||||
```
|
||||
|
||||
**Требуется:** динамическое обнаружение IMAP-папок через `himalaya folder list`,
|
||||
рекурсивный обход всех подпапок INBOX (любой глубины), автоматическая архивация
|
||||
новых подпапок при их создании.
|
||||
|
||||
**План исправления:**
|
||||
1. Заменить хардкод `INBOX_SUBFOLDERS` на вызов `himalaya folder list` с парсингом JSON
|
||||
2. Фильтровать только папки, начинающиеся с `INBOX/` (не трогать Trash, Drafts, RSS и т.д.)
|
||||
3. Рекурсивно архивировать все уровни вложенности
|
||||
4. `mail-archive-every-5min` cron должен обновлять список папок динамически каждый раз
|
||||
5. State-файлы уже поддерживают произвольные имена папок (через хэш MD5 для не-ASCII)
|
||||
|
||||
Подробнее: `references/dynamic-folder-discovery.md`
|
||||
|
||||
## Bulk-sync cron strategy
|
||||
|
||||
Двухфазный подход для переноса архива:
|
||||
|
||||
| Фаза | Период | Лимит | Параметры cron |
|
||||
|------|--------|-------|----------------|
|
||||
| **Bulk sync** (слив существующего архива) | Каждые 5 мин | 10 писем на папку | `no_agent=True`, `script=` |
|
||||
| **Incremental** (только новые письма) | Каждые 30 мин | 50-100 | `no_agent=True`, `script=` |
|
||||
|
||||
В bulk-фазе лимит маленький (10), чтобы:
|
||||
- Не превышать таймаут при медленных папках
|
||||
- Не перегружать IMAP-сервер
|
||||
- Равномерно распределять нагрузку на все 22 папки
|
||||
|
||||
Когда state-файлы перестают обновляться (все письма заархивированы) — переключить на incremental.
|
||||
|
||||
### no_agent cron (рекомендуемый паттерн)
|
||||
|
||||
Для скриптовых recurring-задач без LLM:
|
||||
|
||||
```bash
|
||||
# Создать скрипт в ~/.hermes/scripts/
|
||||
cat > ~/.hermes/scripts/mail-archive.sh << 'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd /opt/hermes/email-assistant
|
||||
exec python3 scripts/mail_archive.py --all --limit 10
|
||||
SCRIPT
|
||||
|
||||
# Создать cron
|
||||
hermes cron create \
|
||||
--name "mail-archive-every-5min" \
|
||||
--schedule "every 5m" \
|
||||
--script mail-archive.sh \
|
||||
--no-agent
|
||||
|
||||
# Обновить для incremental
|
||||
hermes cron update <id> --schedule "every 30m" --script mail-archive.sh --no-agent
|
||||
```
|
||||
|
||||
**Важно:** `script` должен быть относительным путём в `~/.hermes/scripts/`.
|
||||
Абсолютные пути не принимаются.
|
||||
|
||||
### Shell wrapper с per-folder timeouts
|
||||
|
||||
При обработке 22+ папок одна медленная папка не должна валить весь запуск.
|
||||
Скрипт-обёртка обрабатывает каждую папку с отдельным `timeout`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd /opt/hermes/email-assistant
|
||||
|
||||
TIMEOUT=60
|
||||
LIMIT=10
|
||||
|
||||
FOLDERS=(
|
||||
"INBOX" "Отправленные" "Archive" "Sent"
|
||||
"INBOX/!Scan" "INBOX/!Битрикс" "INBOX/!ВГ Чек листы"
|
||||
"INBOX/!Документооборот" "INBOX/!Завки" "INBOX/!Материалы"
|
||||
"INBOX/!Отчеты" "INBOX/!Персонал" "INBOX/!Протоколы"
|
||||
"INBOX/!Реестр оплаты" "INBOX/!Торик" "INBOX/Бюджет"
|
||||
"INBOX/Контрагенты" "INBOX/ЛНД" "INBOX/Организация работы"
|
||||
"INBOX/Приемка и стройка" "INBOX/Системы" "INBOX/Эксплуатация"
|
||||
)
|
||||
|
||||
for folder in "${FOLDERS[@]}"; do
|
||||
echo "[$folder] start..."
|
||||
out=$(timeout $TIMEOUT python3 scripts/mail_archive.py --folder "$folder" --limit $LIMIT 2>&1) || true
|
||||
echo "$out"
|
||||
done
|
||||
```
|
||||
|
||||
## Contacts Extractor: реализация
|
||||
|
||||
### Инкрементальное сохранение (save_progress)
|
||||
|
||||
Раньше скрипт писал все файлы только в конце — при таймауте (600с на 354 письма при Qwen3:8b) весь прогресс терялся.
|
||||
|
||||
**Решение:** `save_progress()` вызывается после каждой обработанной папки:
|
||||
|
||||
```python
|
||||
def save_progress(contacts_db, contacts_dir, processed_uids, processed_emails):
|
||||
contacts_db["contacts"].sort(key=lambda c: c.get("email", ""))
|
||||
save_json(contacts_dir / "contacts.json", contacts_db)
|
||||
save_json(contacts_dir / "index.json", contacts_db.get("by_email", {}))
|
||||
generate_vcard(contacts_dir, contacts_db["contacts"])
|
||||
last_scan = {
|
||||
"last_processed": datetime.now().isoformat(timespec="seconds"),
|
||||
"processed_uids": processed_uids,
|
||||
"processed_emails": sorted(processed_emails),
|
||||
}
|
||||
save_json(contacts_dir / "last_scan.json", last_scan)
|
||||
```
|
||||
|
||||
### --limit для batch-запусков
|
||||
|
||||
Для локальной Qwen3:8b на CPU (~15-20 сек на письмо) полный прогон 354 писем
|
||||
занимает >1.5 часа. Добавлен `--limit`:
|
||||
|
||||
```bash
|
||||
python3 scripts/contacts_extractor.py --limit 15 # ~5 мин на CPU
|
||||
python3 scripts/contacts_extractor.py --limit 50 # ~2 мин на GPU
|
||||
```
|
||||
|
||||
Лимит считается на все папки суммарно. После каждой папки — `save_progress()`.
|
||||
|
||||
### None-safety при работе с JSON
|
||||
|
||||
Qwen через Ollama может вернуть JSON с `null` значениями. Python `.get(key, "")` возвращает `None` (не `""`), если ключ существует со значением `null`:
|
||||
|
||||
```python
|
||||
# ❌ Падает с AttributeError: 'NoneType' object has no attribute 'strip'
|
||||
full_name = llm_result.get("full_name", "").strip()
|
||||
|
||||
# ✅ Безопасно
|
||||
full_name = (llm_result.get("full_name") or "").strip()
|
||||
```
|
||||
|
||||
### Отсутствующий import hashlib
|
||||
|
||||
Скрипт использовал `hashlib.md5()` в `contact_id()` без импорта — падение при первом вызове. Исправлено добавлением `import hashlib`.
|
||||
|
||||
### Очистка тела письма перед LLM (clean_body)
|
||||
|
||||
Перед отправкой в LLM тело письма проходит очистку. Ключевые этапы:
|
||||
|
||||
```python
|
||||
def clean_body(body):
|
||||
# Удаляем <#part ...> блоки
|
||||
body = re.sub(r'<#part[^>]*>', '', body)
|
||||
body = re.sub(r'<#/part>', '', body)
|
||||
# Удаляем HTML-теги
|
||||
body = re.sub(r'<[^>]+>', '', body)
|
||||
# Удаляем mailto: ссылки
|
||||
body = re.sub(r'\\(mailto:[^)]+\\)', '', body)
|
||||
# Заменяем unicode-пробелы (NBSP, zero-width) на обычные
|
||||
body = re.sub(r'[\\u00a0\\u2000-\\u200f\\u2028-\\u202f\\u2060]+', ' ', body)
|
||||
# Удаляем трекинг-ссылки
|
||||
body = re.sub(r'https?://tn-eoc\\.[^\\s]+', '', body)
|
||||
body = re.sub(r'https?://[^\\s]+\\?utm_[^\\s]+', '', body)
|
||||
# Отрезаем цитируемую переписку — ищем САМЫЙ РАННИЙ маркер цитирования
|
||||
quote_patterns = [
|
||||
r'^[\\s]*_{4,}\\s*$', # _____
|
||||
r'От:.*\\n[\\s]*Отправлено:', # Russian Outlook (в любом месте строки)
|
||||
r'^[\\s]*From:.*\\n[\\s]*Sent:', # English Outlook headers
|
||||
r'——-.*Forwarded.*——-',
|
||||
r'——-.*Пересылаемое.*——-',
|
||||
r'——-.*Original Message.*——-',
|
||||
r'>.*\\bwrote:',
|
||||
]
|
||||
earliest = None
|
||||
earliest_pos = len(body)
|
||||
for qp in quote_patterns:
|
||||
for m in re.finditer(qp, body, re.MULTILINE):
|
||||
if m.start() < earliest_pos:
|
||||
earliest_pos = m.start()
|
||||
earliest = m
|
||||
if earliest:
|
||||
body = body[:earliest_pos].strip()
|
||||
```
|
||||
|
||||
**Ключевое изменение:** вместо `re.search()` по списку (первый сработавший) — `re.finditer()` по ВСЕМ паттернам, выбор самого раннего матча. Это критично для писем с двумя фрагментами цитирования (например, ответ на forwarded message), где второй матч (`From: ... Sent:`) срабатывает раньше первого (`От: Стороженко`), потому что `От:` не в начале строки.
|
||||
|
||||
**Fallback:** если ни один паттерн не совпал — ищем любой Outlook-заголовок (`От:`, `Отправлено:`, `From:`, `Sent:`, `Кому:`, `Subject:`) в последних 500 символах. Это ловит подписи, где `От:` вшит в строку с телефоном: `+7 962 854 22 01 От: Стороженко`
|
||||
|
||||
### Парсинг JSON из ответа LLM (brace-depth)
|
||||
|
||||
Qwen3:8b через Ollama может обрезать JSON на лимите `num_predict` или добавить
|
||||
пояснения. Вместо `re.search(r"\{.*\}", ..., re.DOTALL)` — парсинг по глубине
|
||||
скобок:
|
||||
|
||||
```python
|
||||
# Пробуем распарсить весь ответ как JSON
|
||||
try:
|
||||
return json.loads(response_text)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Если не получилось — ищем { ... } внутри
|
||||
brace_depth = 0
|
||||
json_start = None
|
||||
for i, ch in enumerate(response_text):
|
||||
if ch == '{':
|
||||
if brace_depth == 0:
|
||||
json_start = i
|
||||
brace_depth += 1
|
||||
elif ch == '}':
|
||||
brace_depth -= 1
|
||||
if brace_depth == 0 and json_start is not None:
|
||||
try:
|
||||
return json.loads(response_text[json_start:i+1])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
json_start = None
|
||||
```
|
||||
|
||||
### Retry при пустых ответах Ollama
|
||||
|
||||
Qwen3:8b иногда возвращает `response=""` (пустая строка) на длинных промптах.
|
||||
Стандартный retry (2-3 попытки с `time.sleep(1)`) решает проблему.
|
||||
|
||||
### Проблема: num_predict=512 недостаточно
|
||||
|
||||
Телефон + должность + компания не помещаются в 512 токенов для русского текста.
|
||||
Увеличено до 1024.
|
||||
|
||||
### Экранирование JSON в промпте
|
||||
|
||||
При использовании `str.format()` фигурные скобки в JSON-примере внутри промпта
|
||||
конфликтуют с шаблоном. Решение — двойные скобки `{{...}}` для литеральных
|
||||
`{...}` в шаблоне:
|
||||
|
||||
```python
|
||||
PROMPT_TEMPLATE = """...Верни ТОЛЬКО JSON, без пояснений:
|
||||
{{"full_name": "...", "email": null, ...}}
|
||||
{body}"""
|
||||
```
|
||||
|
||||
### Обработка reply/forward писем
|
||||
|
||||
В reply/forward письмах подпись может принадлежать НЕ отправителю в `From:`.
|
||||
Например: Головлев пересылает письмо Елены Стороженко — её подпись в теле.
|
||||
Это нормально — контакт Елены всё равно ценный. LLM не пытается сверять
|
||||
отправителя с извлечёнными данными.
|
||||
|
||||
## Оптимизация pagination
|
||||
|
||||
### Проблема
|
||||
`get_envelopes()` изначально тащил ВСЕ страницы INBOX (58 страниц × 200 писем = ~11559)
|
||||
через SOCKS5 — это висло на 60+ секунд и никогда не доходило до архивации.
|
||||
|
||||
### Решение
|
||||
```python
|
||||
def get_envelopes(folder, limit=100, last_uid=0):
|
||||
"""Ранняя остановка pagination при достижении лимита ИЛИ если все
|
||||
письма на странице уже обработаны (UID <= last_uid)."""
|
||||
page_size = 500 # 1 страница покрывает лимит
|
||||
# ...
|
||||
# Хватит — не тащим остальные страницы
|
||||
if total_new >= limit:
|
||||
break
|
||||
# Если все письма на этой странице старые — дальше только старее
|
||||
last_batch_min = min(int(e.get("uid")...) for e in batch)
|
||||
if last_batch_min <= last_uid:
|
||||
break
|
||||
```
|
||||
|
||||
**Ключевое:** одна страница `page_size=500` покрывает `limit=10` с запасом.
|
||||
Искать на последующих страницах нужно только если на этой меньше лимита новых писем.
|
||||
|
||||
## Cron orchestration
|
||||
|
||||
Два независимых cron-пайплайна поверх общего хранилища — см.
|
||||
`references/cron-workflows.md` для полной документации:
|
||||
|
||||
| Пайплайн | Расписание | Скрипт | `no_agent` | LLM |
|
||||
|-----------|-----------|--------|------------|-----|
|
||||
| Архивация | `every 5m` | `mail-archive.sh` | ✅ Да | ❌ Нет |
|
||||
| Контакты | `every 30m` | `contacts-cron.sh` | ❌ Нет | ✅ Qwen3:8b |
|
||||
|
||||
**Ключевые правила для cron с LLM:**
|
||||
- Контакты НЕ используют `--no-agent` — LLM-вызовы внутри Python-скрипта
|
||||
работают и без агента, но без агента результат не доставляется
|
||||
- `--limit` рассчитан на скорость Qwen3:8b: CPU = 15-20s/email → limit 15
|
||||
- Контакты всегда остаются инкрементальными (UID-трекинг в `last_scan.json`)
|
||||
|
||||
## Скорость Qwen3:8b как planning-ограничение
|
||||
|
||||
Локальная Qwen3:8b через Ollama — бутылочное горлышко для contacts extractor:
|
||||
|
||||
| Окружение | Время на письмо | limit за 5 мин | limit за 30 мин |
|
||||
|-----------|----------------|----------------|-----------------|
|
||||
| CPU (только процессор) | 15-20 с | 15 | 90 |
|
||||
| GPU (NVIDIA/AMD) | 2-3 с | 100 | 600 |
|
||||
|
||||
**Никогда не пытаться прогнать все 354 письма INBOX за один запуск при CPU.**
|
||||
Использовать `--limit` + cron (каждые 30 мин) для инкрементальной обработки.
|
||||
|
||||
## Поведение агента
|
||||
|
||||
### Когда архив был на Yandex Disk (WebDAV davfs2)
|
||||
Сейчас архив на `/opt/hermes/email/` — локальный диск, быстрый. Но код
|
||||
исторически работал с `/mnt/yandex-disk/` — медленным WebDAV. Если архив
|
||||
снова окажется на WebDAV:
|
||||
|
||||
- **Не делать** `rm -rf` на вложенных папках — шлёт HTTP DELETE на каждый файл
|
||||
- **Не делать** `find` на всём архиве — таймаутится
|
||||
- **Партии по 5-10 писем** за один запуск
|
||||
- **Делегировать batch-операции** локальной Qwen3:8b через `delegate_task`
|
||||
|
||||
### Текущий архив (локальный диск)
|
||||
- Батчи по 50-500 писем — диск быстрый
|
||||
- `grep` работает мгновенно
|
||||
- `find` / `rm` — без ограничений
|
||||
|
||||
## Himalaya: несколько аккаунтов
|
||||
|
||||
Конфиг может содержать несколько почтовых аккаунтов. Для переключения:
|
||||
|
||||
```bash
|
||||
himalaya envelope list --account <name> --limit 10
|
||||
# или через переменную окружения:
|
||||
HIMALAYA_ACCOUNT=hermes himalaya envelope list --limit 10
|
||||
```
|
||||
|
||||
При добавлении нового аккаунта:
|
||||
- Скопировать блок `[accounts.<name>]`, заменить данные
|
||||
- Если нужен SMTP — добавить `[accounts.<name>.message.send]`
|
||||
- Порт и шифрование подбираются под провайдера: STARTTLS (порт 143 для IMAP, 587 для SMTP) vs TLS (993/465)
|
||||
- Только один аккаунт может быть `default = true`
|
||||
|
||||
Документированные провайдеры:
|
||||
- **Jino (jino.ru):** `references/jino-server-setup.md` — порты, auth, диагностика
|
||||
|
||||
## Правила
|
||||
|
||||
- НЕ изменять файлы архива без явной команды
|
||||
- НЕ переписывать логику архивации сырыми командами Himalaya
|
||||
- Пароль в `backend.auth.raw` в конфиге — не показывать в логах
|
||||
- **Contacts Extractor — отдельный pipeline, НЕ внутри архиватора.** Не вызывать
|
||||
LLM в `mail_archive.py`. Архиватор должен оставаться тупым насосом.
|
||||
@@ -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 не пытается сверять
|
||||
отправителя с извлечёнными данными.
|
||||
@@ -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 <archive-id> --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=<empty>"
|
||||
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.
|
||||
@@ -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
|
||||
- Группировка по папкам — базовая. Если нужно тематическое группирование — доработать
|
||||
@@ -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-хэш поддерживает
|
||||
любую длину имени
|
||||
@@ -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 у нужного аккаунта
|
||||
```
|
||||
@@ -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/` из сканирования.
|
||||
Reference in New Issue
Block a user