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,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