Initial commit: Hermes skill email-local-archive

This commit is contained in:
estorozhenko
2026-09-06 13:51:14 +00:00
commit b123d2d3b4
7 changed files with 1386 additions and 0 deletions
@@ -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 не пытается сверять
отправителя с извлечёнными данными.
+142
View File
@@ -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.
+59
View File
@@ -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
- Группировка по папкам — базовая. Если нужно тематическое группирование — доработать
+171
View File
@@ -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-хэш поддерживает
любую длину имени
+124
View File
@@ -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 у нужного аккаунта
```
+125
View File
@@ -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/` из сканирования.