mirror of
https://gitverse.ru/kpa39l/email-assistant.git
synced 2026-09-28 21:05:07 +00:00
Initial commit: Email Assistant project
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
*.env.local
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Git
|
||||
*.orig
|
||||
@@ -0,0 +1,9 @@
|
||||
# Email Assistant
|
||||
|
||||
Локальный архив и ассистент почты. Инкрементальный архиватор писем с IMAP, SQLite FTS5-поиск, извлечение контактов из подписей через LLM, еженедельные дайджесты.
|
||||
|
||||
**Стек:** Himalaya CLI → Python → SQLite → Ollama (Qwen3:8b) → Yandex Disk
|
||||
|
||||
**Статус:** Фаза 1.5 — Индексация, поиск, дайджесты + Адресная книга (в работе)
|
||||
|
||||
Подробнее: [STATUS.md](STATUS.md)
|
||||
@@ -0,0 +1,215 @@
|
||||
# Email Assistant — локальный архив и ассистент почты
|
||||
|
||||
**Дата:** 2026-07-19
|
||||
**Фаза:** 1.5 — Индексация, поиск, дайджесты + Адресная книга (в работе)
|
||||
|
||||
**Стек:** Himalaya CLI → Python → SQLite → Ollama (Qwen3:8b) → Yandex Disk
|
||||
|
||||
---
|
||||
|
||||
## Архитектура проекта
|
||||
|
||||
```
|
||||
/opt/hermes/email-assistant/
|
||||
├── STATUS.md # этот файл
|
||||
├── config/
|
||||
│ ├── himalaya-config.toml # Himalaya IMAP-конфиг
|
||||
│ └── contacts-cron.sh # обёртка для cron контактов
|
||||
├── scripts/
|
||||
│ ├── mail_archive.py # инкрементальный архиватор писем с IMAP
|
||||
│ ├── mail-archive.sh # shell-обёртка для systemd/cron
|
||||
│ ├── migrate_to_email_md.py # конвертер meta.json→email.md (deprecated)
|
||||
│ ├── mail_index.py # SQLite FTS5-индекс всех писем
|
||||
│ ├── contacts_extractor.py # извлечение контактов через LLM
|
||||
│ ├── sqlite_search.py # FTS5-поиск по архиву
|
||||
│ ├── digest.py # еженедельный дайджест почты
|
||||
│ └── mail_archive.py # основной архиватор
|
||||
│
|
||||
├── context/
|
||||
│ ├── CONTEXT.md # архитектура и план развития
|
||||
│ ├── SKILL.md # навык для Hermes (email-local-archive)
|
||||
│ └── CONTACTS.md # описание contacts extractor
|
||||
|
||||
/opt/hermes/email/ # архив писем (локальный диск)
|
||||
├── INBOX/
|
||||
│ └── YYYY/MM/UID/email.md # YAML-frontmatter + тело
|
||||
├── Sent/
|
||||
├── Отправленные/
|
||||
├── Archive/
|
||||
├── state/ # mail-archive-last-*.json (last_uid per folder)
|
||||
├── contacts/ # адресная книга
|
||||
│ ├── contacts.json # полная база контактов
|
||||
│ ├── index.json # email → contact_id
|
||||
│ ├── contacts.vcf # vCard 4.0 для импорта
|
||||
│ └── last_scan.json # трекинг обработанных
|
||||
├── digests/ # еженедельные дайджесты
|
||||
│ └── digest-YYYY-MM-DD.md
|
||||
└── mail_index.db # SQLite + FTS5 (~5.4 MB)
|
||||
|
||||
Hermes cron:
|
||||
- mail-archive-every-5min (no-agent, скрипт)
|
||||
- contacts-extractor-every-30m (скрипт, --limit 15)
|
||||
- digest: пока не поставлен
|
||||
- mail_index --incremental: пока не поставлен
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Статус задач
|
||||
|
||||
### Фаза 0.5: Рефакторинг формата хранения ✅
|
||||
- [x] Перейти с meta.json + body.md на один `email.md` с YAML-frontmatter
|
||||
- [x] Полные заголовки в frontmatter (Message-ID, References, In-Reply-To, CC, Content-Type)
|
||||
- [x] State файлы в `/opt/hermes/email/state/`
|
||||
|
||||
### Фаза 1: Локальный архив ✅
|
||||
- [x] Структура `/opt/hermes/email-assistant/`
|
||||
- [x] Himalaya (IMAP mail.corpoffice.tech:143 STARTTLS)
|
||||
- [x] `mail_archive.py` — инкрементальный архиватор
|
||||
- [x] Первый запуск: INBOX 585, Sent 515, Отправленные 510, Archive 373
|
||||
- [x] systemd user timer + Hermes cron (every 5m)
|
||||
|
||||
### Фаза 1.5: Индексация, поиск и дайджесты ✅⬜
|
||||
- [x] `mail_index.py` — SQLite-индекс всех email.md (FTS5 + трекинг контактов)
|
||||
- [x] `sqlite_search.py` — CLI-поиск по FTS5 (поддержка фильтров from:/subject:/folder)
|
||||
- [x] `digest.py` — еженедельный дайджест через LLM (Qwen3:8b)
|
||||
- [ ] Поставить cron на `mail_index.py --incremental` (раз в 5-10 мин)
|
||||
- [ ] Поставить cron на `digest.py` (раз в неделю)
|
||||
|
||||
### Фаза 1.7: Динамическое обнаружение всех подпапок INBOX ❌
|
||||
- [ ] `mail_archive.py` — список вложенных папок INBOX захардкожен (18 шт.), но на сервере их **137** (включая многоуровневые: INBOX/!Персонал/ОТ и ТБ, INBOX/Бюджет/Винный город/CAPEX 2025, INBOX/Контрагенты/iiko/Тихая гавань и т.д.)
|
||||
- [ ] `--all` сейчас использует тот же хардкод — не архивирует ~120 подпапок
|
||||
- [ ] Требуется: динамическое обнаружение IMAP-папок через `himalaya folder list`, рекурсивный обход всех подпапок INBOX (любой глубины), автоматическая архивация новых подпапок при их создании
|
||||
- [ ] `mail-archive-every-5min` cron должен обновлять список папок динамически, а не из хардкода
|
||||
|
||||
### Фаза 1.6: Адресная книга (Contacts Extractor) ✅⬜
|
||||
- [x] `contacts_extractor.py` — извлечение контактов из подписей через LLM
|
||||
- [x] clean_body — удаление цитируемой переписки (Outlook/forwards/>)
|
||||
- [x] SQLite-трекинг обработанных писем (contacts_extracted / contacts_skipped)
|
||||
- [x] Инкрементальное сохранение каждые 5 писем
|
||||
- [x] `--limit N` для дозированной обработки
|
||||
- [x] vCard 4.0 генерация
|
||||
- [x] Cron already set: `contacts-extractor-every-30m` (--limit 15)
|
||||
- [ ] Проверить качество извлечения: сейчас 5 контактов найдено, 5 skipped
|
||||
- [ ] Доделать парсинг темы письма (некоторые темы содержат вшитые заголовки)
|
||||
|
||||
### Фаза 2: Векторизация и поиск ⬜
|
||||
- [ ] Выбор векторизатора (bge-m3 через Ollama — уже есть в Memory OS)
|
||||
- [ ] Индексация body в Qdrant
|
||||
- [ ] Поиск по письмам через агента
|
||||
|
||||
### Фаза 3: Граф знаний ⬜
|
||||
- [ ] Извлечение связанных сущностей (отправители, темы, проекты)
|
||||
|
||||
---
|
||||
|
||||
## Решения и проблемы скриптов
|
||||
|
||||
### `mail_archive.py` — Инкрементальный архиватор
|
||||
**Задача:** Качать письма с IMAP, сохранять в `email.md` с YAML-frontmatter.
|
||||
|
||||
**Решение:**
|
||||
- Для каждой папки хранится `last_uid` в `/opt/hermes/email/state/mail-archive-last-<folder>.json`
|
||||
- Himalaya читает envelope (from, to, subject, date, message-id) → YAML frontmatter
|
||||
- `himalaya envelope --page-size 500` для быстрой загрузки списка писем
|
||||
- Каждое письмо: `himalaya get <uid> | email-to-md.py` → `email.md`
|
||||
- Инкрементально: добавляет все uid > last_uid, обновляет last_uid
|
||||
- Проблема: Himalaya v1.2.0 не поддерживает `danger_accept_invalid_certs` — используем `mail.corpoffice.tech` (валидный сертификат)
|
||||
|
||||
### `mail_index.py` — SQLite-индекс
|
||||
**Задача:** Быстрый полнотекстовый поиск по архиву, трекинг обработки контактов.
|
||||
|
||||
**Решение:**
|
||||
- SQLite с FTS5 (unicode61 tokenizer) — 4 таблицы: `emails`, `email_fts`, триггеры синхронизации
|
||||
- Индексирует: path, uid, folder, date, from, to, subject, body_preview (первые 500 символов)
|
||||
- Поля `contacts_extracted` / `contacts_skipped` для совместной работы с contacts_extractor
|
||||
- Режимы: полная переиндексация (`--incremental` игнорирует mtime), поиск (`--search`), статистика (`--stats`)
|
||||
- Инкрементальный режим: проверяет `file_mtime` — пропускает неизменённые файлы
|
||||
|
||||
### `contacts_extractor.py` — Извлечение контактов
|
||||
**Задача:** Найти в подписи письма имя, должность, телефон, компанию отправителя.
|
||||
|
||||
**Решение:**
|
||||
- Берёт необработанные письма из SQLite (WHERE contacts_extracted=0 AND contacts_skipped=0)
|
||||
- `clean_body()`: удаляет HTML-теги, трекинг-ссылки, цитируемую переписку (Outlook-заголовки `От:`, `From:`, `Sent:`; forwarded; `>` quotes)
|
||||
- Отдаёт очищенный текст Qwen3:8b (Ollama, temperature=0.1)
|
||||
- LLM возвращает JSON: full_name, email, phone, position, company, address, raw_signature
|
||||
- Дедупликация по email: при повторной встрече обновляет поля
|
||||
- Инкрементальное сохранение: каждые 5 писем пишет contacts.json + contacts.vcf
|
||||
- **Текущая проблема:** clean_body может вырезать подпись вместе с цитатами (см. ниже)
|
||||
|
||||
### `sqlite_search.py` — FTS5-поиск
|
||||
**Задача:** Быстрый поиск по архиву писем из консоли.
|
||||
|
||||
**Решение:**
|
||||
- FTS5-запрос к mail_index.db через SQL MATCH
|
||||
- Поддержка синтаксиса: `"точная фраза"`, `OR`, `-исключение`, `префикс*`
|
||||
- Пользовательские префиксы: `from:user@mail`, `subject:отчёт` → LIKE-фильтр в WHERE
|
||||
- `--folder INBOX/!Отчеты` — фильтр по папке
|
||||
- `--body` — включает тело письма в поиск (медленнее, но полнее)
|
||||
- Вывод: дата, папка, отправитель, тема, полный путь к файлу
|
||||
|
||||
### `digest.py` — Еженедельный дайджест
|
||||
**Задача:** Сгенерировать краткое резюме всех писем за N дней для руководителя.
|
||||
|
||||
**Решение:**
|
||||
- SQLite-запрос: письма за последние N дней (по дате из frontmatter)
|
||||
- Группировка по папкам (INBOX/!ВГ Чек листы → "ВГ Чек листы")
|
||||
- Вызов Qwen3:8b с промптом: "напиши краткий дайджест на русском для руководителя"
|
||||
- LLM выделяет: общую статистику, темы по папкам, важные отправители
|
||||
- Сохраняет в `/opt/hermes/email/digests/digest-YYYY-MM-DD.md`
|
||||
- Режимы: `stdout`, `file`, `both`
|
||||
|
||||
---
|
||||
|
||||
## Проблема: clean_body вырезает подпись вместе с цитатой
|
||||
|
||||
**Корень:** В письмах с цепочкой ответов (Outlook forwarding) подпись отправителя часто находится **после** маркера `От: Стороженко... Отправлено:...`, но до конца цитаты. clean_body отрезает всё от первого найденного маркера, теряя подпись.
|
||||
|
||||
**Текущее решение (итерация):**
|
||||
1. Ищем самый ранний маркер цитирования среди всех паттернов (не первый совпавший)
|
||||
2. Fallback: если ни один маркер не сработал — ищем `От: / From: / Subject:` в последних 500 символах
|
||||
|
||||
**Что ещё можно сделать:**
|
||||
- Двухпроходная очистка: сначала отрезать цепочки forward-заголовков, потом отделять подпись от тела
|
||||
- Определять границу подписи по паттернам `С уважением,` / `Best regards,` / `—` — она ближе к концу
|
||||
- Использовать LLM не только для извлечения, но и для нахождения подписи
|
||||
|
||||
---
|
||||
|
||||
## Текущие метрики
|
||||
|
||||
| Папка | Писем | Контакты извл. |
|
||||
|-------|-------|-----------------|
|
||||
| INBOX | 585 | — |
|
||||
| INBOX подпапки (18 хардкодных) | ~180 | — |
|
||||
| **Неархивируемые подпапки INBOX** | **~120 папок не синхронизируются** | **—** |
|
||||
| Sent | 515 | — |
|
||||
| Отправленные | 510 | — |
|
||||
| Archive | 373 | — |
|
||||
| **Всего** | **2073** | **5** |
|
||||
|
||||
Индекс: 2073 письма, 5.4 MB SQLite.
|
||||
Контакты: 5 найдено (clean_body отрезает подпись в большинстве forwarded-писем).
|
||||
|
||||
---
|
||||
|
||||
## Cron-задачи (Hermes)
|
||||
|
||||
| ID | Имя | Расписание | Тип | Статус |
|
||||
|----|-----|-----------|-----|--------|
|
||||
| 22c5beb891cc | mail-archive-every-5min | every 5m | no-agent (скрипт) | ✅ |
|
||||
| 8e181a988392 | contacts-extractor-every-30m | every 30m | скрипт (--limit 15) | ✅ |
|
||||
| — | mail-index-incremental | not set | — | ❌ |
|
||||
| — | digest-weekly | not set | — | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## Конфигурация
|
||||
|
||||
- **Himalaya:** `~/.config/himalaya/config.toml`
|
||||
- **Аккаунт:** `vinogorod`, IMAP `mail.corpoffice.tech:143` (STARTTLS)
|
||||
- **Почта:** `e.storozhenko@vinogorod.ru`
|
||||
- **LLM:** Qwen3:8b (Ollama localhost:11434)
|
||||
- **SMTP:** не настроен
|
||||
- **Файлы state:** `/opt/hermes/email/state/`
|
||||
@@ -0,0 +1,17 @@
|
||||
[accounts.vinogorod]
|
||||
email = "e.storozhenko@vinogorod.ru"
|
||||
display-name = "Evgeny Storozhenko"
|
||||
default = true
|
||||
|
||||
backend.type = "imap"
|
||||
backend.host = "mail.corpoffice.tech"
|
||||
backend.port = 143
|
||||
backend.encryption.type = "start-tls"
|
||||
backend.login = "e.storozhenko"
|
||||
backend.auth.type = "password"
|
||||
backend.auth.raw = "fd9OCAEx04"
|
||||
|
||||
# SMTP — пока не настраиваем
|
||||
# message.send.backend.type = "smtp"
|
||||
|
||||
folder.aliases.inbox = "INBOX"
|
||||
@@ -0,0 +1,192 @@
|
||||
# Contacts Extractor — извлечение адресной книги из писем
|
||||
|
||||
## Описание
|
||||
Автоматическое извлечение контактных данных отправителей из входящих писем. Использует локальную LLM (Qwen3:8b через Ollama) для парсинга подписей — данные, которые регуляркой не вытянуть: должности, отделы, компании, несколько телефонов.
|
||||
|
||||
## Пайплайн
|
||||
|
||||
```
|
||||
mail_archive.py ──→ INBOX/UID/email.md (тупой насос, без LLM)
|
||||
↓
|
||||
contacts_extractor.py (LLM через delegate_task)
|
||||
↓
|
||||
/mnt/yandex-disk/hermes/email/contacts/
|
||||
├── contacts.vcf ← импорт в Thunderbird/Outlook/Apple Mail
|
||||
├── contacts.json ← машинная база (дедупликация)
|
||||
├── index.json ← uid → contact_id (для быстрых ответов)
|
||||
└── last_scan.json ← трекинг: какие письма уже обработаны
|
||||
```
|
||||
|
||||
## Трекинг обработанных писем
|
||||
|
||||
Хранится в `contacts/last_scan.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"last_processed": "2026-07-16T18:00:00",
|
||||
"processed_uids": {
|
||||
"INBOX": 5787,
|
||||
"INBOX/!Протоколы": 0,
|
||||
"Archive": 22
|
||||
},
|
||||
"processed_emails": [
|
||||
"ivanov@example.com",
|
||||
"petrov@example.com"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Логика работы:**
|
||||
1. При запуске читает `last_scan.json`
|
||||
2. Сканирует файловую структуру `INBOX/YYYY/MM/UID/email.md` (только входящие — не `Sent`)
|
||||
3. Для каждого `email.md` проверяет:
|
||||
- Если UID ≤ last_uid по папке → уже обработано этим запуском
|
||||
- Если email отправителя уже есть в `contacts.json` → пропустить (или обновить, если прошло >30 дней)
|
||||
- Если файл `email.md` новее даты `last_processed` → обработать
|
||||
4. После обработки обновляет `last_scan.json`
|
||||
|
||||
**Почему не по UID только:** письма могут добавиться задним числом (например, после переархивации). Поэтому лучше комбинировать: last_uid + дата модификации `email.md`.
|
||||
|
||||
## Промпт для LLM
|
||||
|
||||
### Цель промпта
|
||||
Передаётся full text письма (YAML frontmatter отбрасывается, остаётся body). LLM должна:
|
||||
1. Найти подпись в конце письма
|
||||
2. Извлечь из неё контактные данные
|
||||
3. Вернуть строгий JSON
|
||||
|
||||
### Вариант промпта (черновик)
|
||||
|
||||
```
|
||||
Ты — экстрактор контактных данных из писем. Твоя задача — найти подпись
|
||||
отправителя в конце письма и извлечь структурированные данные.
|
||||
|
||||
Правила поиска подписи:
|
||||
- Подпись обычно отделена от тела письма разделителями: "-- \n", "---\n",
|
||||
"С уважением,", "С наилучшими пожеланиями,", "Best regards,",
|
||||
"Kind regards,", "С ув.,", "————"—"
|
||||
- Если разделителя нет — последние 5-15 строк письма это подпись
|
||||
- Не путай подпись с цитируемым текстом переписки (обычно начинается с ">"
|
||||
или "On ... wrote:" или "————— Forwarded message —————")
|
||||
- Ignore boilerplate (disclaimers, confidentiality notices)
|
||||
|
||||
Извлеки из подписи:
|
||||
1. full_name — полное имя (ФИО)
|
||||
2. email — email адрес (если есть в подписи, иначе null)
|
||||
3. phone — основной телефон (в международном или местном формате)
|
||||
4. phone_secondary — дополнительный телефон (если есть)
|
||||
5. position — должность
|
||||
6. company — название компании/организации
|
||||
7. department — отдел (если указан)
|
||||
8. address — почтовый/юридический адрес (если есть)
|
||||
9. raw_signature — полный текст найденной подписи (для отладки)
|
||||
|
||||
Если никакой подписи не найдено — верни только full_name (из from) и
|
||||
email, остальные поля null.
|
||||
|
||||
Верни ТОЛЬКО JSON, без пояснений:
|
||||
{"full_name": "...", "email": "...", "phone": null, ...}
|
||||
|
||||
Вот текст письма:
|
||||
|
||||
[body]
|
||||
```
|
||||
|
||||
### Обсуждение промпта (решения)
|
||||
|
||||
| Вопрос | Решение |
|
||||
|--------|---------|
|
||||
| Передаём body целиком или последние 50 строк? | **Целиком** — чтобы Qwen видела контекст и не путала подпись с цитатой |
|
||||
| Поле department нужно? | **Нет** — достаточно company + position |
|
||||
| vCard версия? | **4.0** — поддержка соцсетей, фото, расширенных полей |
|
||||
| Отправленные/Sent обрабатываем? | **Только входящие** — INBOX + вложенные папки |
|
||||
|
||||
### Архитектура хранилища
|
||||
|
||||
```
|
||||
/opt/hermes/email/ # ← локально (быстрый диск)
|
||||
├── INBOX/
|
||||
│ └── ...email.md
|
||||
├── state/
|
||||
│ └── mail-archive-last-*.json # last_uid per folder
|
||||
└── contacts/ # адресная книга
|
||||
├── contacts.vcf # vCard 4.0 для импорта
|
||||
├── contacts.json # машинный формат
|
||||
├── index.json # email → contact_id
|
||||
└── last_scan.json # трекинг обработанных писем
|
||||
```
|
||||
|
||||
## Трекинг обработанных писем
|
||||
|
||||
### Формат vCard
|
||||
|
||||
Для импорта в почтовые клиенты (vCard 4.0, RFC 6350):
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
### Структура contacts.json
|
||||
|
||||
Для дедупликации и машинной обработки:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"contacts": [
|
||||
{
|
||||
"id": "md5_of_email",
|
||||
"email": "ivan@example.com",
|
||||
"full_name": "Иванов Иван Иванович",
|
||||
"phone": "+7-123-456-78-90",
|
||||
"phone_secondary": null,
|
||||
"position": "Генеральный директор",
|
||||
"company": "ООО \"Ромашка\"",
|
||||
"department": null,
|
||||
"address": null,
|
||||
"first_seen": "2026-07-16",
|
||||
"last_seen": "2026-07-16",
|
||||
"source_uids": ["INBOX/2026/07/11559"],
|
||||
"source_folders": ["INBOX"]
|
||||
}
|
||||
],
|
||||
"by_email": {
|
||||
"ivan@example.com": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `by_email` — индекс для O(1) дедупликации
|
||||
- `source_uids` — массив, чтобы можно было посмотреть, из каких писем извлечён контакт
|
||||
- `first_seen`/`last_seen` — для понимания актуальности
|
||||
|
||||
### Дедупликация
|
||||
|
||||
1. **email — primary key.** Если контакт с таким email уже есть → обновить поля (телефон мог поменяться, должность — повысили)
|
||||
2. Если email нет, но совпадает full_name (fuzzy) — всё равно создать новый, но зафиксировать в `note`
|
||||
3. Если нет ни email, ни full_name (редко) — не сохранять
|
||||
|
||||
### Интеграция с будущим профилем Hermes
|
||||
|
||||
Когда появится отдельный профиль с SOUL.md:
|
||||
- SOUL.md будет задавать personality и язык
|
||||
- Contacts extractor переедет туда как skill
|
||||
- Промпт может расшириться: «вытащи не только подпись, но и все упомянутые в письме контакты»
|
||||
|
||||
## План реализации
|
||||
|
||||
1. Создать `scripts/contacts_extractor.py`
|
||||
2. Логика: сканирование `email.md` → дедупликация по `contacts.json` → вызов Qwen через субпроцесс (curl Ollama API) → запись
|
||||
3. Промпт — hardcoded в скрипте (потом можно будет вынести в config/prompts/)
|
||||
4. Systemd timer: mail-contacts.service + mail-contacts.timer (через час после архивации)
|
||||
5. Создать Hermes-скилл `email-contacts`
|
||||
@@ -0,0 +1,68 @@
|
||||
# Email Assistant — Context & Architecture
|
||||
|
||||
## Vision
|
||||
Полноценный email-ассистент на локальных данных:
|
||||
1. **Архив** — вся почта в файлах (Фаза 1)
|
||||
2. **Векторизация** — поиск по смыслу через bge-m3 + Qdrant (Фаза 2)
|
||||
3. **Граф знаний** — связи между письмами, проектами, людьми (Фаза 3)
|
||||
4. **Интеллект** — авто-извлечение фактов, задач, рекомендации (Фаза 4)
|
||||
|
||||
## Почему файлы, а не БД?
|
||||
- Версионность и бэкапы (Yandex Disk)
|
||||
- Прозрачность — можно grep, jq, find без SQL
|
||||
- Совместимость с Obsidian и другими инструментами
|
||||
- Поэтапная миграция в Qdrant без потери данных
|
||||
|
||||
## Связь с существующей инфраструктурой
|
||||
|
||||
### Memory OS
|
||||
- Уже есть: Qdrant (1024d COSINE + sparse), bge-m3 (Ollama)
|
||||
- Можно повторно использовать для индексации писем
|
||||
- Icarus threshold = 0.40
|
||||
|
||||
### Yandex Disk
|
||||
- Путь: `/mnt/yandex-disk/`
|
||||
- Монтирован как WebDAV/FUSE — работает как локальная ФС
|
||||
- Файлы доступны из Obsidian и других приложений
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
/opt/hermes/email-assistant/
|
||||
├── STATUS.md # Статус и план работ
|
||||
├── context/
|
||||
│ ├── CONTEXT.md # Этот файл — архитектура
|
||||
│ └── SKILL.md # Навык для Hermes
|
||||
├── scripts/
|
||||
│ ├── mail_archive.py # Python-архиватор
|
||||
│ └── mail-archive.sh # Shell-обёртка
|
||||
└── data/ # Временные данные/эксперименты
|
||||
```
|
||||
|
||||
## Pipeline
|
||||
|
||||
```
|
||||
IMAP (mail.vinogorod.ru:143)
|
||||
↓ Himalaya CLI
|
||||
mail_archive.py
|
||||
↓ инкрементальная запись
|
||||
/mnt/yandex-disk/hermes/email/<folder>/YYYY/MM/UID/
|
||||
├── meta.json
|
||||
├── body.md
|
||||
└── attachments/
|
||||
↓ (Фаза 2)
|
||||
bge-m3 (Ollama) → эмбеддинги
|
||||
↓ (Фаза 2)
|
||||
Qdrant (1024d, COSINE)
|
||||
↓ (Фаза 3)
|
||||
Граф: люди, проекты, организации, ключевые даты
|
||||
↓ (Фаза 4)
|
||||
Извлечение фактов, формирование задач, рекомендации
|
||||
```
|
||||
|
||||
## Известные ограничения
|
||||
|
||||
1. Himalaya v1.2.0 — page-size 2000, нет пагинации по дате (только page/page-size)
|
||||
2. Нет `danger_accept_invalid_certs` в конфиге — используем `mail.corpoffice.tech` (валидный сертификат)
|
||||
3. SMTP не настроен — только чтение
|
||||
4. Пароль в raw-виде в конфиге (потом в keyring)
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: email-local-archive
|
||||
description: "Локальный архив почты: инкрементальное сохранение писем из IMAP в файловую структуру на Yandex Disk с метаданными, телом и вложениями."
|
||||
version: 1.0.0
|
||||
author: estorozhenko
|
||||
platforms: [linux]
|
||||
prerequisites:
|
||||
commands: [himalaya, python3]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [email, archive, imap, backup, knowledge]
|
||||
---
|
||||
|
||||
# Email Local Archive
|
||||
|
||||
Этот навык позволяет агенту архивировать письма из почтового ящика
|
||||
в локальную файловую систему по пути:
|
||||
|
||||
```
|
||||
/mnt/yandex-disk/hermes/email/<folder>/YYYY/MM/UID/
|
||||
├── meta.json # envelope (from, to, subject, date, flags)
|
||||
├── body.md # тело письма (plain text)
|
||||
└── attachments/ # вложения
|
||||
```
|
||||
|
||||
Используется клиент Himalaya CLI и Python-скрипт
|
||||
`/opt/hermes/email-assistant/scripts/mail_archive.py`.
|
||||
|
||||
## Возможности
|
||||
|
||||
- Инкрементальная архивация новых писем по UID
|
||||
- Поддержка любых папок (INBOX, Отправленные, Archive, Sent, подпапки)
|
||||
- Ограничение количества писем за один запуск (`--limit`)
|
||||
- Режим `--all` для архивации всех папок (основные + вложенные INBOX)
|
||||
- Состояние отслеживается в `~/.local/state/mail-archive-last-*.json`
|
||||
- Обёртка `mail-archive.sh` для systemd/cron/Hermes
|
||||
|
||||
## Инструменты
|
||||
|
||||
Используется `terminal` для вызова скрипта, `read_file` для чтения
|
||||
сохранённых писем, `search_files` для поиска по архиву.
|
||||
|
||||
### Основные команды
|
||||
|
||||
```bash
|
||||
# Архивация INBOX (первые 200 писем)
|
||||
mail-archive.sh --folder INBOX --limit 200
|
||||
|
||||
# Архивация Отправленные (первые 50)
|
||||
mail-archive.sh --folder "Отправленные" --limit 50
|
||||
|
||||
# Архивация всех папок (основные)
|
||||
mail-archive.sh --limit 100
|
||||
|
||||
# Архивация всех папок включая вложенные INBOX
|
||||
mail-archive.sh --all --limit 50
|
||||
|
||||
# Справка
|
||||
mail-archive.sh --help
|
||||
```
|
||||
|
||||
## Поведение агента
|
||||
|
||||
1. **"Обнови архив почты"** — выполнить `mail-archive.sh --limit 100`
|
||||
2. **"Архивируй INBOX"** — `mail-archive.sh --folder INBOX --limit 200`
|
||||
3. **"Архивируй всё"** — `mail-archive.sh --all --limit 100`
|
||||
4. **"Найди письмо про <тема>"** — `grep -ril '<тема>' /mnt/yandex-disk/hermes/email/**/body.md`
|
||||
или `jq 'select(.subject | test("<тема>"))' /mnt/yandex-disk/hermes/email/**/meta.json`
|
||||
5. **"Проверь статус архива"** — показать содержимое `~/.local/state/mail-archive-last-*.json`
|
||||
6. **"Сколько писем в архиве"** — `find /mnt/yandex-disk/hermes/email -name meta.json | wc -l`
|
||||
|
||||
## Правила
|
||||
|
||||
- НЕ изменять и не удалять файлы в `/mnt/yandex-disk/hermes/email/`
|
||||
или `~/.local/state/mail-archive-last-*.json` без явной команды
|
||||
- НЕ переписывать логику архивации сырыми командами Himalaya
|
||||
- Если скрипт падает — сообщить об ошибке пользователю
|
||||
- Пароль в `backend.auth.raw` в конфиге — не показывать в логах
|
||||
|
||||
## Примеры
|
||||
|
||||
**Пользователь:** "Обнови локальный архив почты из INBOX"
|
||||
|
||||
**Агент (команда):**
|
||||
```bash
|
||||
mail-archive.sh --folder INBOX --limit 200
|
||||
```
|
||||
|
||||
**Агент (сообщает результат):**
|
||||
```
|
||||
📁 INBOX (last_uid=11559)
|
||||
✓ UID 11560 (Новое письмо от Иванова)
|
||||
✓ UID 11561 (Счёт на оплату)
|
||||
→ last_uid обновлён до 11561, обработано 2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Пользователь:** "Архивируй все папки полностью"
|
||||
|
||||
**Агент (команда):**
|
||||
```bash
|
||||
mail-archive.sh --all --limit 500
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Пользователь:** "Найди письмо про договор с Торик"
|
||||
|
||||
**Агент (поиск):**
|
||||
```bash
|
||||
grep -ril 'договор.*торик\|торик.*договор' /mnt/yandex-disk/hermes/email/**/body.md 2>/dev/null
|
||||
```
|
||||
|
||||
**Агент (показывает результат):**
|
||||
```
|
||||
/mnt/yandex-disk/hermes/email/INBOX/2025/06/10234/body.md
|
||||
/mnt/yandex-disk/hermes/email/INBOX/!Торик/2026/03/11050/body.md
|
||||
```
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Contacts extractor — запускается cron-ом раз в 30 минут.
|
||||
set -euo pipefail
|
||||
|
||||
cd /opt/hermes/email-assistant
|
||||
|
||||
# Лимит: 15 писем за запуск (Qwen3:8b ~20с/письмо = ~5 мин)
|
||||
exec python3 scripts/contacts_extractor.py --limit 15
|
||||
@@ -0,0 +1,593 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
contacts_extractor.py — извлечение адресной книги из подписей писем.
|
||||
|
||||
Сканирует INBOX-письма (email.md), вызывает Qwen3:8b через Ollama API
|
||||
для парсинга подписи, дедуплицирует и пишет:
|
||||
/opt/hermes/email/contacts/contacts.json — машиночитаемая база
|
||||
/opt/hermes/email/contacts/index.json — email → contact_id
|
||||
/opt/hermes/email/contacts/contacts.vcf — vCard 4.0 для импорта
|
||||
/opt/hermes/email/contacts/last_scan.json — трекинг обработанных
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
# ─── Конфиг ───────────────────────────────────────────────────────────────────
|
||||
|
||||
EMAIL_ROOT = Path("/opt/hermes/email")
|
||||
CONTACTS_DIR = EMAIL_ROOT / "contacts"
|
||||
STATE_DIR = EMAIL_ROOT / "state"
|
||||
DB_PATH = EMAIL_ROOT / "mail_index.db"
|
||||
|
||||
OLLAMA_URL = "http://localhost:11434/api/generate"
|
||||
OLLAMA_MODEL = "qwen3:8b"
|
||||
|
||||
# Папки, которые сканируем (только входящие)
|
||||
SCAN_FOLDERS = ["INBOX", "INBOX/!Scan", "INBOX/!Битрикс", "INBOX/!ВГ Чек листы",
|
||||
"INBOX/!Документооборот", "INBOX/!Завки", "INBOX/!Материалы",
|
||||
"INBOX/!Отчеты", "INBOX/!Персонал", "INBOX/!Протоколы",
|
||||
"INBOX/!Реестр оплаты", "INBOX/!Торик", "INBOX/Бюджет",
|
||||
"INBOX/Контрагенты", "INBOX/ЛНД", "INBOX/Организация работы",
|
||||
"INBOX/Приемка и стройка", "INBOX/Системы", "INBOX/Эксплуатация"]
|
||||
|
||||
LLM_TIMEOUT = 30 # секунд на один запрос к Ollama
|
||||
MAX_BODY_CHARS = 5000 # обрезаем body для LLM (первые N символов)
|
||||
|
||||
# ─── Промпт ───────────────────────────────────────────────────────────────────
|
||||
|
||||
PROMPT_TEMPLATE = """Ты — экстрактор контактных данных из писем. Твоя задача — найти подпись ОТПРАВИТЕЛЯ (автора этого письма) и извлечь структурированные данные.
|
||||
|
||||
ВАЖНО: Тебе передаётся ТОЛЬКО новое сообщение, без цитируемой переписки. Подпись отправителя — в самом конце этого текста.
|
||||
|
||||
Правила поиска подписи:
|
||||
- Подпись обычно отделена от тела письма: "С уважением,", "С наилучшими пожеланиями,", "Best regards,", "Kind regards,", "С ув.,", "—\\n", "—\\n", "—\\n"
|
||||
- Если разделителя нет — последние 5-10 строк это подпись
|
||||
- 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 — полный текст найденной подписи (для отладки)
|
||||
|
||||
Если никакой подписи не найдено — верни JSON со всеми полями null.
|
||||
|
||||
Верни ТОЛЬКО JSON, без пояснений:
|
||||
{{"full_name": null, "email": null, "phone": null, "phone_secondary": null, "position": null, "company": null, "address": null, "raw_signature": null}}
|
||||
|
||||
Вот текст письма:
|
||||
|
||||
{body}"""
|
||||
|
||||
|
||||
# ─── Вспомогательные ──────────────────────────────────────────────────────────
|
||||
|
||||
def contact_id(email_addr):
|
||||
"""MD5-хэш email для id контакта."""
|
||||
if not email_addr:
|
||||
return None
|
||||
return hashlib.md5(email_addr.strip().lower().encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
def load_json(path, default=None):
|
||||
"""Загрузить JSON, вернуть default если нет или битый."""
|
||||
if default is None:
|
||||
default = {}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return default
|
||||
|
||||
|
||||
def save_json(path, data):
|
||||
"""Сохранить JSON атомарно."""
|
||||
tmp = path.with_suffix(".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def find_email_md_files(root):
|
||||
"""Рекурсивно найти все email.md в папке."""
|
||||
return sorted(root.rglob("email.md"))
|
||||
|
||||
|
||||
def parse_email_md(path):
|
||||
"""Прочитать email.md, вернуть (headers_dict, body_text)."""
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
# YAML frontmatter: первая строка "---", потом YAML, потом "---"
|
||||
match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", content, re.DOTALL)
|
||||
if match:
|
||||
yaml_block = match.group(1)
|
||||
body = match.group(2).strip()
|
||||
# Парсим YAML вручную (без PyYAML)
|
||||
headers = parse_simple_yaml(yaml_block)
|
||||
else:
|
||||
headers = {}
|
||||
body = content.strip()
|
||||
return headers, body
|
||||
|
||||
|
||||
def parse_simple_yaml(text):
|
||||
"""Примитивный парсер YAML frontmatter (ключ-значение, без вложенности)."""
|
||||
result = {}
|
||||
for line in text.strip().split("\n"):
|
||||
m = re.match(r"^(\w[\w_-]*)\s*:\s*(.*)", line)
|
||||
if m:
|
||||
key = m.group(1)
|
||||
val = m.group(2).strip().strip('"').strip("'")
|
||||
result[key] = val
|
||||
return result
|
||||
|
||||
|
||||
def clean_body(body):
|
||||
"""Очистить тело письма от HTML, цитируемой переписки и мусора."""
|
||||
# Удаляем <#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-пробелы на обычные
|
||||
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)
|
||||
# Удаляем цитируемую переписку — отрезаем всё от САМОГО РАННЕГО маркера цитирования
|
||||
# Маркеры: Outlook (рус/англ headers), forwarded, > lines, андерскор-разделители
|
||||
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()
|
||||
else:
|
||||
# Fallback: ищем любой маркер цитирования в последних 500 символах
|
||||
# (От: Стороженко, From: ... — вшитые в строку подписи маркеры)
|
||||
tail = body[-500:] if len(body) > 500 else body
|
||||
for pattern in [r'От:', r'Отправлено:', r'From:', r'Sent:', r'Кому:', r'To:', r'Тема:', r'Subject:']:
|
||||
m2 = re.search(pattern, tail)
|
||||
if m2:
|
||||
offset = len(body) - len(tail) + m2.start()
|
||||
body = body[:offset].strip()
|
||||
break
|
||||
|
||||
# Удаляем строки начинающиеся с > (если остались)
|
||||
lines = body.split('\n')
|
||||
cleaned = [l for l in lines if not re.match(r'^\s*>', l)]
|
||||
body = '\n'.join(cleaned)
|
||||
body = re.sub(r'\n{3,}', '\n\n', body)
|
||||
return body.strip()
|
||||
|
||||
|
||||
def call_llm(body_text, max_retries=2):
|
||||
"""Вызвать Qwen через Ollama API, вернуть JSON."""
|
||||
# Очищаем body
|
||||
body_text = clean_body(body_text)
|
||||
# Обрезаем body
|
||||
truncated = body_text[:MAX_BODY_CHARS]
|
||||
prompt = PROMPT_TEMPLATE.format(body=truncated)
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
if attempt > 0:
|
||||
time.sleep(1)
|
||||
|
||||
payload = json.dumps({
|
||||
"model": OLLAMA_MODEL,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": 0.1,
|
||||
"num_predict": 1024,
|
||||
}
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
OLLAMA_URL,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=LLM_TIMEOUT)
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
response_text = data.get("response", "").strip()
|
||||
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError) as e:
|
||||
if attempt < max_retries:
|
||||
continue
|
||||
print(f" ⚠ LLM error: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
if not response_text:
|
||||
if attempt < max_retries:
|
||||
continue
|
||||
print(f" ⚠ LLM empty response", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# Пробуем распарсить весь ответ как 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
|
||||
|
||||
if attempt < max_retries:
|
||||
continue
|
||||
print(f" ⚠ LLM JSON parse error, raw: {response_text[:300]}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
# ─── Основная логика ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_uid_from_path(path):
|
||||
"""Извлечь UID из пути INBOX/YYYY/MM/UID/email.md."""
|
||||
parts = path.parts
|
||||
try:
|
||||
# Ищем часть, которая является числом (UID)
|
||||
for p in parts:
|
||||
if p.isdigit():
|
||||
return int(p)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_folder_from_path(path, root):
|
||||
"""Извлечь имя папки (INBOX/!Протоколы) относительно корня почты."""
|
||||
rel = path.relative_to(root)
|
||||
parts = rel.parts
|
||||
# Формат: <folder>/YYYY/MM/UID/email.md
|
||||
# folder может быть "INBOX" или "INBOX/!Протоколы"
|
||||
folder_parts = []
|
||||
for p in parts:
|
||||
if p.isdigit() or re.match(r"^\d{4}$", p):
|
||||
break
|
||||
folder_parts.append(p)
|
||||
return "/".join(folder_parts)
|
||||
|
||||
|
||||
def get_date_from_path(path):
|
||||
"""Извлечь дату из пути (по году/месяцу)."""
|
||||
parts = path.parts
|
||||
year = None
|
||||
month = None
|
||||
for p in parts:
|
||||
if re.match(r"^\d{4}$", p) and 2020 <= int(p) <= 2030:
|
||||
year = int(p)
|
||||
elif re.match(r"^\d{2}$", p) and 1 <= int(p) <= 12:
|
||||
month = int(p)
|
||||
if year and month:
|
||||
return date(year, month, 1)
|
||||
return date.today()
|
||||
|
||||
|
||||
def save_progress(contacts_db, contacts_dir, processed_uids, processed_emails):
|
||||
"""Инкрементальное сохранение контактов и last_scan."""
|
||||
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)
|
||||
print(f" 💾 Сохранено ({len(contacts_db['contacts'])} контактов)", flush=True)
|
||||
|
||||
|
||||
def scan(limit=0):
|
||||
"""Основной цикл сканирования с SQLite-трекингом."""
|
||||
import sqlite3
|
||||
contacts_dir = CONTACTS_DIR
|
||||
contacts_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Загружаем контакты
|
||||
contacts_db = load_json(contacts_dir / "contacts.json", {"version": 1, "contacts": [], "by_email": {}})
|
||||
today_str = date.today().isoformat()
|
||||
|
||||
# Открываем SQLite
|
||||
if not DB_PATH.exists():
|
||||
print("❌ mail_index.db не найден. Сначала запусти: python3 mail_index.py")
|
||||
return
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
# Берём письма, где контакты ещё не извлечены
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT rowid, path, uid, folder, from_addr, subject
|
||||
FROM emails
|
||||
WHERE contacts_extracted = 0 AND contacts_skipped = 0
|
||||
ORDER BY folder, uid
|
||||
LIMIT ?
|
||||
""", (limit if limit > 0 else 999999,))
|
||||
pending = cursor.fetchall()
|
||||
|
||||
if not pending:
|
||||
print(" Нет новых писем для обработки")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
print(f" Найдено писем к обработке: {len(pending)}", flush=True)
|
||||
|
||||
total_new = 0
|
||||
total_skipped = 0
|
||||
processed_count = 0
|
||||
save_counter = 0
|
||||
|
||||
for row in pending:
|
||||
rowid = row["rowid"]
|
||||
rel_path = row["path"]
|
||||
uid = row["uid"]
|
||||
folder = row["folder"]
|
||||
sender = row["from_addr"]
|
||||
subject = row["subject"]
|
||||
|
||||
file_path = EMAIL_ROOT / rel_path
|
||||
if not file_path.exists():
|
||||
# Письмо удалено — отмечаем чтоб не дёргать
|
||||
cursor.execute("UPDATE emails SET contacts_skipped=1 WHERE rowid=?", (rowid,))
|
||||
continue
|
||||
|
||||
# Извлекаем email отправителя
|
||||
from_email = None
|
||||
email_match = re.search(r'<([^>]+@[^>]+)>', sender)
|
||||
if email_match:
|
||||
from_email = email_match.group(1).strip().lower()
|
||||
elif "@" in sender:
|
||||
from_email = sender.strip().lower()
|
||||
|
||||
# Пропускаем, если уже обработан (по email)
|
||||
if from_email and from_email in contacts_db["by_email"]:
|
||||
contact = contacts_db["contacts"][contacts_db["by_email"][from_email]]
|
||||
last_seen = contact.get("last_seen", "2000-01-01")
|
||||
days_since = (date.today() - date.fromisoformat(last_seen)).days
|
||||
if days_since < 30:
|
||||
cursor.execute("UPDATE emails SET contacts_extracted=1, last_scanned=? WHERE rowid=?", (datetime.now().isoformat(timespec="seconds"), rowid))
|
||||
conn.commit()
|
||||
total_skipped += 1
|
||||
continue
|
||||
|
||||
# Читаем тело письма
|
||||
headers, body = parse_email_md(file_path)
|
||||
|
||||
print(f" UID {uid} ({sender[:50]})...", end=" ", flush=True)
|
||||
|
||||
# Вызываем LLM
|
||||
llm_result = call_llm(body)
|
||||
|
||||
if llm_result is None:
|
||||
print("⚠ skip (LLM error)", flush=True)
|
||||
cursor.execute("UPDATE emails SET contacts_skipped=1, last_scanned=? WHERE rowid=?", (datetime.now().isoformat(timespec="seconds"), rowid))
|
||||
conn.commit()
|
||||
total_skipped += 1
|
||||
continue
|
||||
|
||||
# Если контакт не найден
|
||||
full_name = (llm_result.get("full_name") or "").strip()
|
||||
if not full_name or full_name == "null":
|
||||
print("→ нет подписи", flush=True)
|
||||
cursor.execute("UPDATE emails SET contacts_skipped=1, last_scanned=? WHERE rowid=?", (datetime.now().isoformat(timespec="seconds"), rowid))
|
||||
conn.commit()
|
||||
total_skipped += 1
|
||||
continue
|
||||
|
||||
# Извлекаем email из LLM-результата
|
||||
contact_email = llm_result.get("email") or from_email
|
||||
if contact_email and contact_email.strip().lower() != "null":
|
||||
contact_email = contact_email.strip().lower()
|
||||
else:
|
||||
contact_email = from_email
|
||||
|
||||
if not contact_email:
|
||||
print("→ нет email, skip", flush=True)
|
||||
cursor.execute("UPDATE emails SET contacts_skipped=1, last_scanned=? WHERE rowid=?", (datetime.now().isoformat(timespec="seconds"), rowid))
|
||||
conn.commit()
|
||||
total_skipped += 1
|
||||
continue
|
||||
|
||||
cid = contact_id(contact_email)
|
||||
source_path = str(rel_path)
|
||||
|
||||
# Создаём запись контакта
|
||||
new_contact = {
|
||||
"id": cid,
|
||||
"email": contact_email,
|
||||
"full_name": full_name,
|
||||
"phone": llm_result.get("phone") or None,
|
||||
"phone_secondary": llm_result.get("phone_secondary") or None,
|
||||
"position": llm_result.get("position") or None,
|
||||
"company": llm_result.get("company") or None,
|
||||
"address": llm_result.get("address") or None,
|
||||
"first_seen": today_str,
|
||||
"last_seen": today_str,
|
||||
"source_uids": [source_path],
|
||||
"source_folders": [folder],
|
||||
}
|
||||
|
||||
# Дедупликация
|
||||
existing_idx = contacts_db["by_email"].get(contact_email)
|
||||
if existing_idx is not None:
|
||||
existing = contacts_db["contacts"][existing_idx]
|
||||
for key in ["full_name", "phone", "phone_secondary", "position", "company", "address"]:
|
||||
if new_contact.get(key):
|
||||
existing[key] = new_contact[key]
|
||||
existing["last_seen"] = today_str
|
||||
if source_path not in existing["source_uids"]:
|
||||
existing["source_uids"].append(source_path)
|
||||
if folder not in existing["source_folders"]:
|
||||
existing["source_folders"].append(folder)
|
||||
print(f"✓ обновлён: {full_name} <{contact_email}>", flush=True)
|
||||
else:
|
||||
contacts_db["contacts"].append(new_contact)
|
||||
contacts_db["by_email"][contact_email] = len(contacts_db["contacts"]) - 1
|
||||
print(f"✓ новый: {full_name} <{contact_email}>", flush=True)
|
||||
|
||||
# Отмечаем в SQLite
|
||||
cursor.execute("UPDATE emails SET contacts_extracted=1, last_scanned=? WHERE rowid=?", (datetime.now().isoformat(timespec="seconds"), rowid))
|
||||
conn.commit()
|
||||
|
||||
total_new += 1
|
||||
processed_count += 1
|
||||
save_counter += 1
|
||||
|
||||
if limit > 0 and processed_count >= limit:
|
||||
print(f" ⏸ лимит {limit} достигнут", flush=True)
|
||||
break
|
||||
|
||||
# Сохраняемся каждые 5 писем
|
||||
if save_counter >= 5:
|
||||
save_counter = 0
|
||||
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"])
|
||||
print(f" 💾 Сохранено ({len(contacts_db['contacts'])} контактов, uid={uid})", flush=True)
|
||||
|
||||
# Финальное сохранение
|
||||
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"])
|
||||
|
||||
# Статистика
|
||||
cursor.execute("SELECT COUNT(*) as cnt FROM emails WHERE contacts_extracted=1")
|
||||
extracted_total = cursor.fetchone()["cnt"]
|
||||
cursor.execute("SELECT COUNT(*) as cnt FROM emails WHERE contacts_skipped=1")
|
||||
skipped_total = cursor.fetchone()["cnt"]
|
||||
cursor.execute("SELECT COUNT(*) as cnt FROM emails WHERE contacts_extracted=0 AND contacts_skipped=0")
|
||||
remaining = cursor.fetchone()["cnt"]
|
||||
|
||||
conn.close()
|
||||
|
||||
print(f"\n{'=' * 50}")
|
||||
print(f"Готово. Новых контактов: {total_new}, пропущено: {total_skipped}")
|
||||
print(f"Всего в базе: {len(contacts_db['contacts'])} контактов")
|
||||
print(f"Индекс: {extracted_total} извлечено, {skipped_total} пропущено, {remaining} осталось")
|
||||
|
||||
|
||||
def generate_vcard(contacts_dir, contacts):
|
||||
"""Сгенерировать contacts.vcf (vCard 4.0)."""
|
||||
lines = []
|
||||
for c in contacts:
|
||||
if not c.get("email"):
|
||||
continue
|
||||
lines.append("BEGIN:VCARD")
|
||||
lines.append("VERSION:4.0")
|
||||
lines.append(f"FN:{c['full_name']}")
|
||||
# N:Фамилия;Имя;Отчество;;
|
||||
name_parts = c["full_name"].split(maxsplit=2)
|
||||
if len(name_parts) >= 2:
|
||||
n_line = f"N:{name_parts[-1]};{name_parts[0]};{' '.join(name_parts[1:-1])};;"
|
||||
else:
|
||||
n_line = f"N:{c['full_name']};;;;"
|
||||
lines.append(n_line)
|
||||
lines.append(f"EMAIL;TYPE=WORK:{c['email']}")
|
||||
if c.get("phone"):
|
||||
lines.append(f"TEL;TYPE=WORK:{c['phone']}")
|
||||
if c.get("phone_secondary"):
|
||||
lines.append(f"TEL;TYPE=CELL:{c['phone_secondary']}")
|
||||
if c.get("position"):
|
||||
lines.append(f"TITLE:{c['position']}")
|
||||
if c.get("company"):
|
||||
lines.append(f"ORG:{c['company']}")
|
||||
if c.get("address"):
|
||||
lines.append(f"ADR;TYPE=WORK:;;{c['address']};;;")
|
||||
# NOTE с источником
|
||||
sources = ", ".join(c.get("source_uids", []))
|
||||
lines.append(f"NOTE:Извлечено из писем: {sources}")
|
||||
lines.append("END:VCARD")
|
||||
lines.append("")
|
||||
|
||||
vcf_path = contacts_dir / "contacts.vcf"
|
||||
vcf_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
print(f" vCard: {vcf_path} ({len(contacts)} контактов)")
|
||||
|
||||
|
||||
# ─── CLI ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Извлечение контактов из писем")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Не вызывать LLM, только показать что будет обработано")
|
||||
parser.add_argument("--limit", type=int, default=0, help="Максимум писем для обработки (0 = все)")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("📇 Contacts Extractor")
|
||||
print(f" База: {CONTACTS_DIR}")
|
||||
print(f" Модель: {OLLAMA_MODEL}")
|
||||
if args.dry_run:
|
||||
print(" 🔍 DRY RUN — без LLM\n")
|
||||
|
||||
# В dry-run просто сканируем
|
||||
if args.dry_run:
|
||||
contacts_dir = CONTACTS_DIR
|
||||
contacts_dir.mkdir(parents=True, exist_ok=True)
|
||||
last_scan = load_json(contacts_dir / "last_scan.json")
|
||||
processed_uids = last_scan.get("processed_uids", {})
|
||||
total_to_process = 0
|
||||
|
||||
for folder in SCAN_FOLDERS:
|
||||
folder_path = EMAIL_ROOT / folder
|
||||
if not folder_path.is_dir():
|
||||
continue
|
||||
processed_for_folder = processed_uids.get(folder, 0)
|
||||
email_files = find_email_md_files(folder_path)
|
||||
new_files = [f for f in email_files if (get_uid_from_path(f) or 0) > processed_for_folder]
|
||||
print(f" {folder}: {len(email_files)} total, {len(new_files)} new (after UID {processed_for_folder})")
|
||||
total_to_process += len(new_files)
|
||||
|
||||
print(f"\nВсего новых писем к обработке: {total_to_process}")
|
||||
return
|
||||
|
||||
scan(limit=args.limit)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
digest.py — Еженедельный дайджест почты.
|
||||
|
||||
Собирает все письма за 7 дней, группирует по папкам,
|
||||
вызывает LLM для генерации краткого дайджеста.
|
||||
|
||||
Использование:
|
||||
python3 digest.py — дайджест за 7 дней
|
||||
python3 digest.py --days 14 — за 14 дней
|
||||
python3 digest.py --folder INBOX — только INBOX
|
||||
python3 digest.py --output — только файл, без вывода в stdout
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import sqlite3
|
||||
|
||||
# ─── Конфиг ───────────────────────────────────────────────────────────────────
|
||||
|
||||
DB_PATH = Path("/opt/hermes/email/mail_index.db")
|
||||
DIGEST_DIR = Path("/opt/hermes/email/digests")
|
||||
OLLAMA_URL = "http://localhost:11434/api/generate"
|
||||
OLLAMA_MODEL = "qwen3:8b"
|
||||
OLLAMA_TIMEOUT = 60
|
||||
|
||||
# ─── Промпты ──────────────────────────────────────────────────────────────────
|
||||
|
||||
SUMMARIZE_PROMPT = """Ты — ассистент, который делает ежедневный дайджест корпоративной почты.
|
||||
|
||||
Ниже — список писем за последние {days} дней, сгруппированный по папкам.
|
||||
Твоя задача — написать КРАТКИЙ дайджест на русском языке для руководителя.
|
||||
|
||||
Формат:
|
||||
1. Заголовок: "📬 Дайджест почты — {date_range}"
|
||||
2. Общая статистика: сколько писем, сколько папок затронуто
|
||||
3. По каждой папке — 1-3 предложения: основные темы, важные отправители, ключевые решения
|
||||
4. Выдели особенно важные письма (от руководства, по тендерам, договорам, финансам)
|
||||
|
||||
ВАЖНО:
|
||||
- Пиши ТОЛЬКО на русском
|
||||
- Будь краток — максимум 300 слов
|
||||
- Не перечисляй каждое письмо — выделяй тренды и главное
|
||||
- Если тема понятна из темы письма — группируй
|
||||
- Пропусти технический мусор (уведомления систем, автоответы)
|
||||
|
||||
Вот данные для анализа:
|
||||
|
||||
{folder_groups}"""
|
||||
|
||||
# ─── Вспомогательные ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def call_llm(prompt, max_retries=2):
|
||||
"""Вызвать Qwen через Ollama API."""
|
||||
payload = json.dumps({
|
||||
"model": OLLAMA_MODEL,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": 0.3,
|
||||
"num_predict": 2048,
|
||||
}
|
||||
}).encode("utf-8")
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
if attempt > 0:
|
||||
time.sleep(2)
|
||||
req = urllib.request.Request(
|
||||
OLLAMA_URL,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=OLLAMA_TIMEOUT)
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
return data.get("response", "").strip()
|
||||
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError) as e:
|
||||
if attempt < max_retries:
|
||||
continue
|
||||
return f"⚠ Ошибка LLM: {e}"
|
||||
|
||||
return "⚠ Не удалось сгенерировать дайджест"
|
||||
|
||||
|
||||
def format_date(d):
|
||||
"""Привести дату к dd.mm.yyyy."""
|
||||
if not d:
|
||||
return "—"
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S%z",
|
||||
"%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
|
||||
try:
|
||||
dt = datetime.strptime(d.rstrip("Z"), fmt)
|
||||
return dt.strftime("%d.%m.%Y")
|
||||
except ValueError:
|
||||
continue
|
||||
return d[:10]
|
||||
|
||||
|
||||
# ─── Основная логика ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_digest(days=7, folder_filter=None):
|
||||
"""Собрать дайджест за N дней."""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
cutoff = datetime.now() - timedelta(days=days)
|
||||
|
||||
# Берём письма за период
|
||||
query = """
|
||||
SELECT folder, date, from_addr, subject, contacts_extracted, path
|
||||
FROM emails
|
||||
WHERE date >= ?
|
||||
ORDER BY date DESC
|
||||
"""
|
||||
params = [cutoff.strftime("%Y-%m-%d")]
|
||||
|
||||
if folder_filter:
|
||||
query += " AND folder = ?"
|
||||
params.append(folder_filter)
|
||||
|
||||
rows = c.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
|
||||
if not rows:
|
||||
msg = f" Нет писем за последние {days} дней"
|
||||
print(msg)
|
||||
return msg
|
||||
|
||||
# Группируем по папкам
|
||||
groups = {}
|
||||
for r in rows:
|
||||
f = r["folder"]
|
||||
if f not in groups:
|
||||
groups[f] = []
|
||||
groups[f].append(r)
|
||||
|
||||
# Формируем текстовое представление для LLM
|
||||
folder_parts = []
|
||||
total = 0
|
||||
for fname, emails in sorted(groups.items()):
|
||||
total += len(emails)
|
||||
display_name = fname.replace("INBOX/", "") if fname.startswith("INBOX") else fname
|
||||
items = []
|
||||
for e in emails:
|
||||
date_str = format_date(e["date"])
|
||||
extr = "✓" if e["contacts_extracted"] else " "
|
||||
from_short = e["from_addr"][:40]
|
||||
subj = (e["subject"] or "(без темы)")[:60]
|
||||
items.append(f" [{extr}] {date_str} | {from_short} | {subj}")
|
||||
folder_parts.append(f"─── {display_name} ({len(emails)} писем) ───\n" + "\n".join(items))
|
||||
|
||||
folder_text = "\n\n".join(folder_parts)
|
||||
|
||||
# Дата-диапазон
|
||||
date_range = f"{format_date((datetime.now() - timedelta(days=days)).isoformat())} — {datetime.now().strftime('%d.%m.%Y')}"
|
||||
|
||||
prompt = SUMMARIZE_PROMPT.format(
|
||||
days=days,
|
||||
date_range=date_range,
|
||||
folder_groups=folder_text
|
||||
)
|
||||
|
||||
print(" Генерирую дайджест через LLM...", flush=True)
|
||||
digest = call_llm(prompt)
|
||||
|
||||
# Форматируем
|
||||
header = f"📬 Дайджест корпоративной почты"
|
||||
subheader = f" {total} писем из {len(groups)} папок | {date_range}"
|
||||
divider = "─" * 60
|
||||
|
||||
result = f"{header}\n{subheader}\n{divider}\n\n{digest}\n\n{divider}"
|
||||
|
||||
# Сохраняем в файл
|
||||
DIGEST_DIR.mkdir(parents=True, exist_ok=True)
|
||||
filename = datetime.now().strftime("digest-%Y-%m-%d.md")
|
||||
filepath = DIGEST_DIR / filename
|
||||
filepath.write_text(result, encoding="utf-8")
|
||||
|
||||
print(f"\n✅ Дайджест сохранён: {filepath}")
|
||||
print()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Еженедельный дайджест почты")
|
||||
parser.add_argument("--days", type=int, default=7, help="Глубина в днях")
|
||||
parser.add_argument("--folder", type=str, help="Фильтр по папке")
|
||||
parser.add_argument("--output", choices=["stdout", "file", "both"], default="both",
|
||||
help="Куда вывести результат")
|
||||
args = parser.parse_args()
|
||||
|
||||
result = build_digest(days=args.days, folder_filter=args.folder)
|
||||
|
||||
if args.output in ("stdout", "both"):
|
||||
print()
|
||||
print(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Обёртка для mail_archive.py
|
||||
# Добавляет Himalaya и Python в PATH (нужно для systemd/cron)
|
||||
|
||||
export PATH="$HOME/.local/bin:$HOME/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
export PYTHONUNBUFFERED=1
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
case "${1:-}" in
|
||||
--help|-h)
|
||||
exec python3 "$SCRIPT_DIR/mail_archive.py" --help
|
||||
;;
|
||||
--all)
|
||||
shift
|
||||
exec python3 "$SCRIPT_DIR/mail_archive.py" --all "$@"
|
||||
;;
|
||||
*)
|
||||
exec python3 "$SCRIPT_DIR/mail_archive.py" "$@"
|
||||
;;
|
||||
esac
|
||||
Executable
+440
@@ -0,0 +1,440 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
mail_archive.py — инкрементальный архиватор почты в локальную файловую базу.
|
||||
|
||||
Сохраняет письма из IMAP-ящика в структуру:
|
||||
/opt/hermes/email/<folder>/YYYY/MM/UID/
|
||||
├── email.md # YAML-frontmatter + тело письма
|
||||
└── attachments/ # вложения (если есть)
|
||||
|
||||
Формат email.md:
|
||||
---
|
||||
id: 255
|
||||
folder: Sent
|
||||
subject: "Re: ..."
|
||||
from: "Name <addr>"
|
||||
to: "Name <addr>"
|
||||
date: "2025-07-10 11:51+03:00"
|
||||
flags: ["Seen"]
|
||||
has_attachment: false
|
||||
message_id: <...@vinogorod.ru>
|
||||
in_reply_to: <...@vinogorod.ru>
|
||||
references: <...> <...>
|
||||
cc: "Name <addr>"
|
||||
content_type: multipart/mixed; boundary=...
|
||||
---
|
||||
|
||||
Body text here...
|
||||
|
||||
Отслеживает last_uid для каждой папки в /opt/hermes/email/state/mail-archive-last-<folder>.json
|
||||
|
||||
Запуск:
|
||||
python3 ~/bin/mail_archive.py --folder INBOX --limit 100
|
||||
python3 ~/bin/mail_archive.py --folder "Отправленные" --limit 200
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import json
|
||||
import sys
|
||||
import argparse
|
||||
import re
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Конфигурация
|
||||
ARCHIVE_ROOT = Path("/opt/hermes/email")
|
||||
STATE_DIR = ARCHIVE_ROOT / "state"
|
||||
HIMALAYA_CMD = ["himalaya"]
|
||||
|
||||
# Папки для полной архивации (основные папки)
|
||||
FOLDERS = ["INBOX", "Отправленные", "Archive", "Sent"]
|
||||
|
||||
# Вложенные папки INBOX, которые тоже архивируем
|
||||
INBOX_SUBFOLDERS = [
|
||||
"INBOX/!Scan",
|
||||
"INBOX/!Битрикс",
|
||||
"INBOX/!ВГ Чек листы",
|
||||
"INBOX/!Документооборот",
|
||||
"INBOX/!Завки",
|
||||
"INBOX/!Материалы",
|
||||
"INBOX/!Отчеты",
|
||||
"INBOX/!Персонал",
|
||||
"INBOX/!Протоколы",
|
||||
"INBOX/!Реестр оплаты",
|
||||
"INBOX/!Торик",
|
||||
"INBOX/Бюджет",
|
||||
"INBOX/Контрагенты",
|
||||
"INBOX/ЛНД",
|
||||
"INBOX/Организация работы",
|
||||
"INBOX/Приемка и стройка",
|
||||
"INBOX/Системы",
|
||||
"INBOX/Эксплуатация",
|
||||
]
|
||||
|
||||
# Какие дополнительные заголовки вытягивать через --header
|
||||
EXTRA_HEADERS = [
|
||||
"Message-ID",
|
||||
"References",
|
||||
"In-Reply-To",
|
||||
"CC",
|
||||
"Content-Type",
|
||||
]
|
||||
|
||||
|
||||
def run_cmd(cmd, timeout=60):
|
||||
"""Выполнить команду, вернуть stdout."""
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr_text = result.stderr.decode("utf-8", errors="ignore")
|
||||
if "No such folder" in stderr_text:
|
||||
return ""
|
||||
raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{stderr_text}")
|
||||
return result.stdout.decode("utf-8", errors="ignore")
|
||||
|
||||
|
||||
def get_state_file(folder):
|
||||
"""Получить путь к файлу состояния для папки."""
|
||||
safe_name = folder.replace("/", "_").replace(" ", "_")
|
||||
if not all(ord(c) < 128 for c in safe_name):
|
||||
h = hashlib.md5(folder.encode()).hexdigest()[:12]
|
||||
safe_name = f"folder_{h}"
|
||||
return STATE_DIR / f"mail-archive-last-{safe_name}.json"
|
||||
|
||||
|
||||
def load_state(folder):
|
||||
"""Загрузить last_uid для папки."""
|
||||
state_file = get_state_file(folder)
|
||||
if not state_file.exists():
|
||||
return {"last_uid": 0}
|
||||
try:
|
||||
with state_file.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {"last_uid": 0}
|
||||
|
||||
|
||||
def save_state(folder, state):
|
||||
"""Сохранить last_uid для папки."""
|
||||
state_file = get_state_file(folder)
|
||||
state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with state_file.open("w", encoding="utf-8") as f:
|
||||
json.dump(state, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def parse_date(date_str):
|
||||
"""Извлечь год/месяц из строки даты."""
|
||||
if not date_str:
|
||||
return datetime.now().year, datetime.now().month
|
||||
try:
|
||||
dt = datetime.fromisoformat(date_str)
|
||||
return dt.year, dt.month
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
dt = datetime.strptime(date_str[:25], "%a, %d %b %Y %H:%M:%S")
|
||||
return dt.year, dt.month
|
||||
except Exception:
|
||||
pass
|
||||
return datetime.now().year, datetime.now().month
|
||||
|
||||
|
||||
def fmt_contact(c):
|
||||
"""Форматировать контакт из {name, addr} в 'Name <addr>'."""
|
||||
if not c:
|
||||
return ""
|
||||
name = c.get("name") or ""
|
||||
addr = c.get("addr") or ""
|
||||
if name and addr:
|
||||
return f"{name} <{addr}>"
|
||||
return addr or name
|
||||
|
||||
|
||||
def yaml_val(v):
|
||||
"""Экранировать значение для YAML: строки в кавычки, если нужно."""
|
||||
return json.dumps(v, ensure_ascii=False)
|
||||
|
||||
|
||||
def get_envelopes(folder, limit=100, last_uid=0):
|
||||
"""
|
||||
Получить список конвертов из папки.
|
||||
Останавливается раньше, если набрал достаточно новых писем (UID > last_uid).
|
||||
"""
|
||||
envelopes = []
|
||||
page = 1
|
||||
page_size = 500 # больше писем за страницу = меньше запросов
|
||||
|
||||
while True:
|
||||
try:
|
||||
stdout = run_cmd(
|
||||
HIMALAYA_CMD + [
|
||||
"envelope", "list",
|
||||
"--folder", folder,
|
||||
"--page", str(page),
|
||||
"--page-size", str(page_size),
|
||||
"--output", "json",
|
||||
],
|
||||
timeout=60,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
if "No such folder" in str(e):
|
||||
return []
|
||||
raise
|
||||
|
||||
if not stdout.strip():
|
||||
break
|
||||
|
||||
try:
|
||||
batch = json.loads(stdout)
|
||||
except json.JSONDecodeError:
|
||||
break
|
||||
|
||||
if not batch:
|
||||
break
|
||||
|
||||
envelopes.extend(batch)
|
||||
|
||||
# Считаем сколько новых писем уже набрали
|
||||
new_count = 0
|
||||
for e in batch:
|
||||
uid = int(e.get("uid") or e.get("id") or 0)
|
||||
if uid > last_uid:
|
||||
new_count += 1
|
||||
|
||||
# Хватит — не тащим остальные страницы
|
||||
total_new = sum(
|
||||
1 for e in envelopes
|
||||
if int(e.get("uid") or e.get("id") or 0) > last_uid
|
||||
)
|
||||
if total_new >= limit:
|
||||
break
|
||||
|
||||
# Если ВСЕ письма на этой странице уже старые (UID <= last_uid) —
|
||||
# дальше можно не ходить, там только старее (сортировка по UID descending)
|
||||
if len(batch) < page_size:
|
||||
break
|
||||
last_batch_min = min(
|
||||
int(e.get("uid") or e.get("id") or 0) for e in batch
|
||||
)
|
||||
if last_batch_min <= last_uid:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
return envelopes
|
||||
|
||||
|
||||
def get_email_content(uid, folder):
|
||||
"""
|
||||
Получить тело письма и дополнительные заголовки.
|
||||
Возвращает (headers_dict, body_text).
|
||||
"""
|
||||
# Собираем аргументы --header
|
||||
header_args = []
|
||||
for h in EXTRA_HEADERS:
|
||||
header_args.extend(["--header", h])
|
||||
|
||||
try:
|
||||
stdout = run_cmd(
|
||||
HIMALAYA_CMD + [
|
||||
"message", "read", str(uid),
|
||||
"--folder", folder,
|
||||
] + header_args,
|
||||
timeout=30,
|
||||
)
|
||||
except RuntimeError:
|
||||
return {}, "(body unavailable)\n"
|
||||
|
||||
# Разделяем на блок заголовков и тело
|
||||
# Первый \n\n — граница между запрошенными заголовками и остальным
|
||||
parts = stdout.split("\n\n", 1)
|
||||
header_block = parts[0] if parts else ""
|
||||
body = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
# Парсим заголовки
|
||||
headers = {}
|
||||
for line in header_block.split("\n"):
|
||||
if ":" in line:
|
||||
key, val = line.split(":", 1)
|
||||
headers[key.strip()] = val.strip()
|
||||
|
||||
return headers, body
|
||||
|
||||
|
||||
def make_email_md(meta, extra_headers, body, folder_name):
|
||||
"""
|
||||
Собрать email.md с YAML-frontmatter из meta.json + extra_headers + body.
|
||||
"""
|
||||
lines = []
|
||||
lines.append("---")
|
||||
|
||||
# Основные поля из envelope
|
||||
lines.append(f"id: {meta.get('id', '0')}")
|
||||
lines.append(f"folder: {folder_name}")
|
||||
lines.append(f"subject: {yaml_val(meta.get('subject', ''))}")
|
||||
|
||||
from_str = fmt_contact(meta.get("from"))
|
||||
to_str = fmt_contact(meta.get("to"))
|
||||
lines.append(f"from: {yaml_val(from_str)}")
|
||||
lines.append(f"to: {yaml_val(to_str)}")
|
||||
|
||||
date_str = meta.get("date") or meta.get("internal_date") or ""
|
||||
lines.append(f"date: {yaml_val(date_str)}")
|
||||
lines.append(f"flags: {json.dumps(meta.get('flags', []))}")
|
||||
lines.append(f"has_attachment: {str(meta.get('has_attachment', False)).lower()}")
|
||||
|
||||
# Дополнительные заголовки
|
||||
for h in EXTRA_HEADERS:
|
||||
val = extra_headers.get(h)
|
||||
if val:
|
||||
lines.append(f"{h}: {yaml_val(val)}")
|
||||
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
|
||||
# Тело письма
|
||||
lines.append(body.rstrip("\n"))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_attachments(uid, folder, dest_dir):
|
||||
"""Скачать вложения письма в dest_dir."""
|
||||
try:
|
||||
run_cmd(
|
||||
HIMALAYA_CMD + [
|
||||
"attachment", "download", str(uid),
|
||||
"--folder", folder,
|
||||
"--dir", str(dest_dir),
|
||||
],
|
||||
timeout=60,
|
||||
)
|
||||
except RuntimeError:
|
||||
pass # нет вложений — норм
|
||||
|
||||
|
||||
def archive_folder(folder, limit=100):
|
||||
"""
|
||||
Архивировать все новые письма из папки.
|
||||
Возвращает количество заархивированных писем.
|
||||
"""
|
||||
state = load_state(folder)
|
||||
last_uid = state.get("last_uid", 0)
|
||||
|
||||
print(f"\n📁 {folder} (last_uid={last_uid})")
|
||||
|
||||
envelopes = get_envelopes(folder, limit=limit, last_uid=last_uid)
|
||||
if not envelopes:
|
||||
print(f" → нет писем или папка не найдена")
|
||||
return 0
|
||||
|
||||
# Отфильтровать новые по UID
|
||||
new = []
|
||||
for env in envelopes:
|
||||
uid = env.get("uid") or env.get("id") or 0
|
||||
try:
|
||||
uid = int(uid)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if uid > last_uid:
|
||||
new.append((uid, env))
|
||||
|
||||
if not new:
|
||||
print(f" → новых писем нет")
|
||||
return 0
|
||||
|
||||
new.sort(key=lambda x: x[0])
|
||||
|
||||
processed = 0
|
||||
for uid, env in new:
|
||||
if processed >= limit:
|
||||
print(f" → достигнут лимит {limit}, осталось ещё {len(new) - processed}")
|
||||
break
|
||||
|
||||
# Год/месяц
|
||||
date_str = env.get("date") or env.get("internal_date") or ""
|
||||
year, month = parse_date(date_str)
|
||||
|
||||
# Путь: /mnt/yandex-disk/hermes/email/<folder>/YYYY/MM/UID/
|
||||
msg_dir = ARCHIVE_ROOT / folder / f"{year:04d}" / f"{month:02d}" / str(uid)
|
||||
email_path = msg_dir / "email.md"
|
||||
attachments_dir = msg_dir / "attachments"
|
||||
|
||||
# Проверка — уже сохранено
|
||||
if email_path.exists():
|
||||
print(f" UID {uid} уже есть, пропускаю")
|
||||
last_uid = max(last_uid, uid)
|
||||
processed += 1
|
||||
continue
|
||||
|
||||
msg_dir.mkdir(parents=True, exist_ok=True)
|
||||
attachments_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Получаем заголовки и тело
|
||||
extra_headers, body = get_email_content(uid, folder)
|
||||
|
||||
# Собираем email.md
|
||||
content = make_email_md(env, extra_headers, body, folder)
|
||||
email_path.write_text(content, encoding="utf-8")
|
||||
|
||||
# Вложения
|
||||
get_attachments(uid, folder, attachments_dir)
|
||||
|
||||
subj = (env.get("subject") or "")[:60]
|
||||
print(f" ✓ UID {uid} ({subj})")
|
||||
last_uid = uid
|
||||
processed += 1
|
||||
|
||||
# Сохранить состояние
|
||||
state["last_uid"] = last_uid
|
||||
save_state(folder, state)
|
||||
print(f" → last_uid обновлён до {last_uid}, обработано {processed}")
|
||||
return processed
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Архиватор почты — инкрементальное сохранение в /mnt/yandex-disk/hermes/email/"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--folder", default=None,
|
||||
help="Архивировать только одну папку (по умолчанию: все основные папки)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit", type=int, default=200,
|
||||
help="Максимум писем за один запуск (по умолчанию: 200)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all", action="store_true",
|
||||
help="Архивировать включая вложенные папки INBOX"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Определить список папок
|
||||
if args.folder:
|
||||
folders_to_archive = [args.folder]
|
||||
elif args.all:
|
||||
folders_to_archive = FOLDERS + INBOX_SUBFOLDERS
|
||||
else:
|
||||
folders_to_archive = FOLDERS
|
||||
|
||||
total = 0
|
||||
for folder in folders_to_archive:
|
||||
try:
|
||||
count = archive_folder(folder, limit=args.limit)
|
||||
total += count
|
||||
except Exception as e:
|
||||
print(f" [ERROR] {folder}: {e}", file=sys.stderr)
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f"Готово. Всего заархивировано писем: {total}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
mail_index.py — SQLite-индекс всех email.md для быстрого поиска и трекинга.
|
||||
|
||||
Создаёт /opt/hermes/email/mail_index.db с таблицами:
|
||||
- emails: path, uid, folder, date, from_addr, to_addrs, subject,
|
||||
contacts_extracted, contacts_skipped, first_seen, last_scanned
|
||||
- email_fts (FTS5): полнотекстовый поиск по subject + from + to
|
||||
|
||||
Использование:
|
||||
python3 mail_index.py — полная переиндексация
|
||||
python3 mail_index.py --incremental — только новые файлы (по mtime last_index)
|
||||
python3 mail_index.py --search "запрос" — поиск по индексу
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
EMAIL_ROOT = Path("/opt/hermes/email")
|
||||
DB_PATH = EMAIL_ROOT / "mail_index.db"
|
||||
CONTACTS_DIR = EMAIL_ROOT / "contacts"
|
||||
LAST_SCAN_FILE = CONTACTS_DIR / "last_scan.json"
|
||||
STATE_DIR = EMAIL_ROOT / "state"
|
||||
|
||||
# Папки, которые не индексируем
|
||||
EXCLUDE_DIRS = {"contacts", "state"}
|
||||
|
||||
SCHEMA = """
|
||||
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_skipped INTEGER DEFAULT 0, -- 0/1 (нет подписи / LLM error)
|
||||
first_seen TEXT, -- когда проиндексировано
|
||||
last_scanned TEXT, -- последняя проверка contacts
|
||||
file_mtime REAL -- mtime файла для инкрементальной проверки
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_emails_folder ON emails(folder);
|
||||
CREATE INDEX IF NOT EXISTS idx_emails_uid ON emails(uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_emails_contacts ON emails(contacts_extracted);
|
||||
CREATE INDEX IF NOT EXISTS idx_emails_date ON emails(date);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS email_fts USING fts5(
|
||||
subject, from_addr, to_addrs, body_preview,
|
||||
content='emails',
|
||||
content_rowid='rowid',
|
||||
tokenize='unicode61'
|
||||
);
|
||||
|
||||
-- Триггеры для синхронизации FTS
|
||||
CREATE TRIGGER IF NOT EXISTS emails_ai AFTER INSERT ON emails BEGIN
|
||||
INSERT INTO email_fts(rowid, subject, from_addr, to_addrs, body_preview)
|
||||
VALUES (new.rowid, new.subject, new.from_addr, new.to_addrs, new.body_preview);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS emails_ad AFTER DELETE ON emails BEGIN
|
||||
INSERT INTO email_fts(email_fts, rowid, subject, from_addr, to_addrs, body_preview)
|
||||
VALUES ('delete', old.rowid, old.subject, old.from_addr, old.to_addrs, old.body_preview);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS emails_au AFTER UPDATE ON emails BEGIN
|
||||
INSERT INTO email_fts(email_fts, rowid, subject, from_addr, to_addrs, body_preview)
|
||||
VALUES ('delete', old.rowid, old.subject, old.from_addr, old.to_addrs, old.body_preview);
|
||||
INSERT INTO email_fts(rowid, subject, from_addr, to_addrs, body_preview)
|
||||
VALUES (new.rowid, new.subject, new.from_addr, new.to_addrs, new.body_preview);
|
||||
END;
|
||||
"""
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Открыть/создать БД."""
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
conn.executescript(SCHEMA)
|
||||
return conn
|
||||
|
||||
|
||||
def parse_frontmatter(content):
|
||||
"""Парсит YAML-frontmatter из email.md."""
|
||||
meta = {}
|
||||
m = re.match(r'^---\s*\n(.*?)\n---\s*\n', content, re.DOTALL)
|
||||
if not m:
|
||||
return meta
|
||||
yaml_block = m.group(1)
|
||||
for line in yaml_block.split('\n'):
|
||||
line = line.strip()
|
||||
if ':' in line:
|
||||
key, _, val = line.partition(':')
|
||||
key = key.strip().lower()
|
||||
val = val.strip().strip('"').strip("'")
|
||||
# Обрабатываем списки
|
||||
if val.startswith('[') and val.endswith(']'):
|
||||
val = val[1:-1].replace('"', '').replace("'", '').strip()
|
||||
meta[key] = val
|
||||
return meta
|
||||
|
||||
|
||||
def clean_html(text):
|
||||
"""Удалить HTML-теги и мусор."""
|
||||
text = re.sub(r'<#part[^>]*>', '', text)
|
||||
text = re.sub(r'<#/part>', '', text)
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
text = re.sub(r'\s+', ' ', text)
|
||||
text = re.sub(r'&[a-z]+;', ' ', text)
|
||||
text = re.sub(r'https?://\S+', '', text)
|
||||
return text.strip()[:500]
|
||||
|
||||
|
||||
def extract_uid_from_path(path):
|
||||
"""Извлекает UID из пути: .../YYYY/MM/UID/email.md"""
|
||||
m = re.search(r'/(\d+)/email\.md$', str(path))
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def find_email_md_files(root_path):
|
||||
"""Найти все email.md, исключая contacts/ и state/."""
|
||||
files = []
|
||||
root_path = Path(root_path)
|
||||
for f in sorted(root_path.rglob("email.md")):
|
||||
rel = f.relative_to(root_path)
|
||||
parts = rel.parts
|
||||
# Пропускаем служебные папки
|
||||
if any(p in EXCLUDE_DIRS for p in parts):
|
||||
continue
|
||||
files.append(f)
|
||||
return files
|
||||
|
||||
|
||||
def index_emails(incremental=False):
|
||||
"""Проиндексировать все email.md в SQLite."""
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
if incremental:
|
||||
# Получаем последний mtime из БД
|
||||
cursor.execute("SELECT COALESCE(MAX(file_mtime), 0) FROM emails")
|
||||
last_mtime = cursor.fetchone()[0] or 0
|
||||
print(f"📇 Инкрементальная индексация (mtime > {last_mtime})", flush=True)
|
||||
else:
|
||||
last_mtime = 0
|
||||
print("📇 Полная индексация", flush=True)
|
||||
|
||||
email_files = find_email_md_files(EMAIL_ROOT)
|
||||
|
||||
new_count = 0
|
||||
updated_count = 0
|
||||
total = len(email_files)
|
||||
|
||||
for i, f in enumerate(email_files):
|
||||
rel_path = str(f.relative_to(EMAIL_ROOT))
|
||||
mtime = os.path.getmtime(f)
|
||||
|
||||
if incremental and mtime <= last_mtime:
|
||||
continue
|
||||
|
||||
content = f.read_text(encoding='utf-8', errors='replace')
|
||||
meta = parse_frontmatter(content)
|
||||
uid = extract_uid_from_path(f)
|
||||
folder = meta.get('folder', '') or str(f.parent.parent.parent.relative_to(EMAIL_ROOT))
|
||||
subject = meta.get('subject', '')
|
||||
from_addr = meta.get('from', '')
|
||||
to_addrs = meta.get('to', '')
|
||||
date_val = meta.get('date', '')
|
||||
|
||||
# Тело
|
||||
body_match = re.search(r'^---\s*\n(.*?)\n---\s*\n(.*)', content, re.DOTALL)
|
||||
body_preview = ''
|
||||
if body_match:
|
||||
body_preview = clean_html(body_match.group(2))
|
||||
if not body_preview:
|
||||
body_preview = clean_html(content)
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO emails (path, uid, folder, date, from_addr, to_addrs, subject,
|
||||
body_preview, first_seen, file_mtime)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
uid=excluded.uid,
|
||||
folder=excluded.folder,
|
||||
date=excluded.date,
|
||||
from_addr=excluded.from_addr,
|
||||
to_addrs=excluded.to_addrs,
|
||||
subject=excluded.subject,
|
||||
body_preview=excluded.body_preview,
|
||||
file_mtime=excluded.file_mtime
|
||||
""", (
|
||||
rel_path, uid, folder, date_val, from_addr, to_addrs, subject,
|
||||
body_preview, datetime.now(timezone.utc).isoformat(), mtime
|
||||
))
|
||||
|
||||
if cursor.rowcount == 1 and cursor.lastrowid:
|
||||
new_count += 1
|
||||
else:
|
||||
updated_count += 1
|
||||
|
||||
if (i + 1) % 200 == 0:
|
||||
conn.commit()
|
||||
print(f" {i+1}/{total} ({new_count} new, {updated_count} updated)", flush=True)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Обновляем FTS5 (перестроить для согласованности)
|
||||
cursor.execute("INSERT INTO email_fts(email_fts) VALUES('rebuild')")
|
||||
conn.commit()
|
||||
|
||||
print(f"\n✅ Готово: {total} файлов, {new_count} новых, {updated_count} обновлено")
|
||||
print(f" База: {DB_PATH}")
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM emails")
|
||||
total_in_db = cursor.fetchone()[0]
|
||||
cursor.execute("SELECT COUNT(*) FROM emails WHERE contacts_extracted=1")
|
||||
extracted = cursor.fetchone()[0]
|
||||
print(f" Всего в БД: {total_in_db}, контакты извлечены: {extracted}")
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
def search(query, limit=10):
|
||||
"""Поиск по FTS5."""
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT e.path, e.folder, e.date, e.from_addr, e.subject, e.contacts_extracted,
|
||||
e.contacts_skipped, e.body_preview
|
||||
FROM email_fts f
|
||||
JOIN emails e ON e.rowid = f.rowid
|
||||
WHERE email_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
""", (query, limit))
|
||||
|
||||
results = cursor.fetchall()
|
||||
if not results:
|
||||
print(f" Ничего не найдено по запросу: {query}")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
print(f" Найдено: {len(results)} писем\n")
|
||||
for r in results:
|
||||
extracted = "✓" if r["contacts_extracted"] else "—"
|
||||
skipped = "✗" if r["contacts_skipped"] else " "
|
||||
print(f" [{extracted}{skipped}] {r['folder']}")
|
||||
print(f" От: {r['from_addr']}")
|
||||
print(f" Тема: {r['subject']}")
|
||||
print(f" Дата: {r['date']}")
|
||||
print(f" Путь: {r['path']}")
|
||||
print()
|
||||
except sqlite3.OperationalError as e:
|
||||
print(f" Ошибка поиска: {e}")
|
||||
print(f" Используй: python3 mail_index.py --search '\"точная фраза\"'")
|
||||
print(f" Или: python3 mail_index.py --search 'тема OR отправитель'")
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
def stats():
|
||||
"""Статистика индекса."""
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM emails")
|
||||
total = cursor.fetchone()[0]
|
||||
cursor.execute("SELECT COUNT(*) FROM emails WHERE contacts_extracted=1")
|
||||
extracted = cursor.fetchone()[0]
|
||||
cursor.execute("SELECT COUNT(*) FROM emails WHERE contacts_skipped=1")
|
||||
skipped = cursor.fetchone()[0]
|
||||
cursor.execute("SELECT folder, COUNT(*) FROM emails GROUP BY folder ORDER BY COUNT(*) DESC")
|
||||
by_folder = cursor.fetchall()
|
||||
|
||||
print(f"📊 Статистика индекса писем")
|
||||
print(f" Всего: {total}")
|
||||
print(f" Контакты извлечены: {extracted}")
|
||||
print(f" Пропущено (нет подписи): {skipped}")
|
||||
print(f" Осталось: {total - extracted - skipped}")
|
||||
print(f"\n По папкам:")
|
||||
for r in by_folder:
|
||||
print(f" {r['folder']}: {r['COUNT(*)']}")
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Индекс писем в SQLite")
|
||||
parser.add_argument("--incremental", action="store_true", help="Только новые письма")
|
||||
parser.add_argument("--search", type=str, help="Поиск по индексу")
|
||||
parser.add_argument("--stats", action="store_true", help="Статистика")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.search:
|
||||
search(args.search)
|
||||
elif args.stats:
|
||||
stats()
|
||||
else:
|
||||
index_emails(incremental=args.incremental)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
migrate_to_email_md.py — конвертация существующего архива из meta.json + body.md в email.md.
|
||||
|
||||
Сканирует /mnt/yandex-disk/hermes/email/<folder>/YYYY/MM/UID/,
|
||||
где лежат meta.json + body.md, и объединяет их в один email.md
|
||||
с YAML-frontmatter. После успешной записи удаляет meta.json и body.md.
|
||||
|
||||
Запуск:
|
||||
python3 migrate_to_email_md.py # все папки
|
||||
python3 migrate_to_email_md.py --dry-run # только показать, что будет
|
||||
python3 migrate_to_email_md.py --folder INBOX # только одну папку
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
ARCHIVE_ROOT = Path("/mnt/yandex-disk/hermes/email")
|
||||
|
||||
|
||||
def make_frontmatter(meta: dict, body: str) -> str:
|
||||
"""Собрать YAML frontmatter + body в один email.md."""
|
||||
# Извлекаем поля из meta.json
|
||||
msg_id = meta.get("id", "0")
|
||||
flags = meta.get("flags", [])
|
||||
subject = meta.get("subject", "")
|
||||
from_ = meta.get("from", {})
|
||||
to_ = meta.get("to", {})
|
||||
date = meta.get("date", "")
|
||||
has_attachment = meta.get("has_attachment", False)
|
||||
|
||||
# Форматируем from/to как "Name <addr>"
|
||||
def fmt_contact(c):
|
||||
if not c:
|
||||
return ""
|
||||
name = c.get("name") or ""
|
||||
addr = c.get("addr") or ""
|
||||
if name and addr:
|
||||
return f"{name} <{addr}>"
|
||||
return addr or name
|
||||
|
||||
from_str = fmt_contact(from_)
|
||||
to_str = fmt_contact(to_)
|
||||
|
||||
# Собираем YAML-поля вручную, чтобы сохранить порядок и читаемость
|
||||
lines = []
|
||||
lines.append("---")
|
||||
lines.append(f"id: {msg_id}")
|
||||
lines.append(f"folder: {meta.get('_folder', '')}")
|
||||
lines.append(f"subject: {json.dumps(subject, ensure_ascii=False)}")
|
||||
lines.append(f"from: {json.dumps(from_str, ensure_ascii=False)}")
|
||||
lines.append(f"to: {json.dumps(to_str, ensure_ascii=False)}")
|
||||
lines.append(f"date: {json.dumps(date, ensure_ascii=False)}")
|
||||
lines.append(f"flags: {json.dumps(flags)}")
|
||||
lines.append(f"has_attachment: {str(has_attachment).lower()}")
|
||||
# Дополнительные поля, если есть (Message-ID и т.д.)
|
||||
for extra_field in ["message_id", "in_reply_to", "references", "cc", "content_type"]:
|
||||
val = meta.get(extra_field)
|
||||
if val:
|
||||
lines.append(f"{extra_field}: {json.dumps(val, ensure_ascii=False)}")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
|
||||
# Тело письма — уже в body.md
|
||||
lines.append(body.rstrip("\n"))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def migrate_email(msg_dir: Path, dry_run: bool = False) -> bool:
|
||||
"""
|
||||
Конвертировать один email из meta.json + body.md в email.md.
|
||||
Возвращает True, если было что конвертировать.
|
||||
"""
|
||||
meta_path = msg_dir / "meta.json"
|
||||
body_path = msg_dir / "body.md"
|
||||
email_path = msg_dir / "email.md"
|
||||
|
||||
if not meta_path.exists() and not body_path.exists():
|
||||
return False
|
||||
|
||||
if email_path.exists() and not meta_path.exists() and not body_path.exists():
|
||||
# Уже сконвертировано
|
||||
return False
|
||||
|
||||
# Читаем meta.json (если есть, иначе пустой словарь)
|
||||
meta = {}
|
||||
if meta_path.exists():
|
||||
try:
|
||||
with meta_path.open("r", encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
except Exception as e:
|
||||
print(f" [WARN] {msg_dir}/meta.json: {e}", file=sys.stderr)
|
||||
|
||||
# Читаем body.md (если есть)
|
||||
body = ""
|
||||
if body_path.exists():
|
||||
try:
|
||||
body = body_path.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
print(f" [WARN] {msg_dir}/body.md: {e}", file=sys.stderr)
|
||||
|
||||
# Добавляем имя папки в meta
|
||||
# Извлекаем относительный путь от ARCHIVE_ROOT
|
||||
try:
|
||||
rel = msg_dir.relative_to(ARCHIVE_ROOT)
|
||||
folder = str(rel.parent.parent.parent) # folder/YYYY/MM/UID -> folder
|
||||
except ValueError:
|
||||
folder = "unknown"
|
||||
meta["_folder"] = folder
|
||||
|
||||
content = make_frontmatter(meta, body)
|
||||
|
||||
if dry_run:
|
||||
uid = meta.get("id", "?")
|
||||
subj = meta.get("subject", "(no subject)")[:50]
|
||||
print(f" [DRY] {folder}/{uid} ({subj}) → email.md")
|
||||
return True
|
||||
|
||||
# Пишем email.md
|
||||
email_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
email_path.write_text(content, encoding="utf-8")
|
||||
|
||||
# Удаляем старые файлы
|
||||
if meta_path.exists():
|
||||
meta_path.unlink()
|
||||
if body_path.exists():
|
||||
body_path.unlink()
|
||||
|
||||
uid = meta.get("id", "?")
|
||||
subj = meta.get("subject", "")[:50]
|
||||
print(f" ✓ {folder}/{uid} ({subj})")
|
||||
return True
|
||||
|
||||
|
||||
def scan_folders(root: Path) -> list[Path]:
|
||||
"""Найти все директории вида <folder>/YYYY/MM/UID/."""
|
||||
msg_dirs = []
|
||||
for folder_dir in root.iterdir():
|
||||
if not folder_dir.is_dir():
|
||||
continue
|
||||
for year_dir in folder_dir.iterdir():
|
||||
if not year_dir.is_dir() or not year_dir.name.isdigit():
|
||||
continue
|
||||
for month_dir in year_dir.iterdir():
|
||||
if not month_dir.is_dir() or not month_dir.name.isdigit():
|
||||
continue
|
||||
for uid_dir in month_dir.iterdir():
|
||||
if not uid_dir.is_dir():
|
||||
continue
|
||||
msg_dirs.append(uid_dir)
|
||||
return msg_dirs
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Миграция meta.json + body.md → email.md с YAML-frontmatter"
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Показать, что будет сделано, без изменений")
|
||||
parser.add_argument("--folder", default=None,
|
||||
help="Конвертировать только одну папку (например, INBOX)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not ARCHIVE_ROOT.exists():
|
||||
print(f"Ошибка: {ARCHIVE_ROOT} не существует", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
msg_dirs = scan_folders(ARCHIVE_ROOT)
|
||||
|
||||
if args.folder:
|
||||
msg_dirs = [d for d in msg_dirs if str(d.relative_to(ARCHIVE_ROOT)).startswith(args.folder + "/")]
|
||||
|
||||
if not msg_dirs:
|
||||
print("Нет директорий с письмами для конвертации.")
|
||||
return 0
|
||||
|
||||
total = 0
|
||||
skipped = 0
|
||||
for msg_dir in sorted(msg_dirs):
|
||||
try:
|
||||
if migrate_email(msg_dir, dry_run=args.dry_run):
|
||||
total += 1
|
||||
else:
|
||||
skipped += 1
|
||||
except Exception as e:
|
||||
print(f" [ERROR] {msg_dir}: {e}", file=sys.stderr)
|
||||
|
||||
if args.dry_run:
|
||||
print(f"\nБудет сконвертировано: {total}, пропущено (уже email.md): {skipped}")
|
||||
else:
|
||||
print(f"\nСконвертировано: {total}, пропущено: {skipped}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create repo on gitea and push email-assistant
|
||||
set -e
|
||||
|
||||
REPO="email-assistant"
|
||||
GITEA_URL="https://gitea.nixg.ru"
|
||||
USERNAME="hermes"
|
||||
PASSWORD="twJohTWObFn1vO15"
|
||||
|
||||
echo "=== Creating repo ${REPO} on ${GITEA_URL} ==="
|
||||
RESP=$(curl -s -w "\n%{http_code}" -X POST "${GITEA_URL}/api/v1/user/repos" \
|
||||
-u "${USERNAME}:${PASSWORD}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"${REPO}\",\"description\":\"Email Assistant — локальный архив и ассистент почты\",\"private\":false,\"auto_init\":false}")
|
||||
|
||||
HTTP_CODE=$(echo "$RESP" | tail -1)
|
||||
BODY=$(echo "$RESP" | head -n -1)
|
||||
echo "HTTP: ${HTTP_CODE}"
|
||||
echo "Response: ${BODY:0:500}"
|
||||
|
||||
if [ "$HTTP_CODE" -eq 201 ]; then
|
||||
echo "=== Repo created ==="
|
||||
elif echo "$BODY" | grep -q "already exists"; then
|
||||
echo "=== Repo already exists ==="
|
||||
else
|
||||
echo "=== FAILED ==="
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Pushing to ${GITEA_URL}/${USERNAME}/${REPO}.git ==="
|
||||
cd /opt/hermes/email-assistant
|
||||
git remote remove origin 2>/dev/null || true
|
||||
git remote add origin "${GITEA_URL}/${USERNAME}/${REPO}.git"
|
||||
git add -A
|
||||
git commit -m "Initial commit: Email Assistant project" 2>/dev/null || echo "Nothing to commit"
|
||||
git push -u origin master 2>&1 || git push -u origin main 2>&1
|
||||
echo "=== DONE ==="
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
sqlite_search.py — FTS5-поиск по архиву писем.
|
||||
|
||||
Использование:
|
||||
python3 sqlite_search.py "запрос"
|
||||
python3 sqlite_search.py "запрос" --limit 20
|
||||
python3 sqlite_search.py "тема:отчёт" --folder "INBOX/!Отчеты"
|
||||
python3 sqlite_search.py "from:example@mail.ru" --body
|
||||
|
||||
Операторы FTS5 (по умолчанию AND):
|
||||
"точная фраза" — точное совпадение
|
||||
OR — любой из терминов
|
||||
-исключить — исключить термин
|
||||
prefix* — префикс (wildcard)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
DB_PATH = Path("/opt/hermes/email/mail_index.db")
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def format_date(d):
|
||||
"""Привести дату к читаемому виду."""
|
||||
if not d:
|
||||
return "—"
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S%z",
|
||||
"%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
|
||||
try:
|
||||
dt = datetime.strptime(d.rstrip("Z"), fmt)
|
||||
return dt.strftime("%d.%m.%Y %H:%M")
|
||||
except ValueError:
|
||||
continue
|
||||
return d[:10]
|
||||
|
||||
|
||||
def search(query, limit=15, folder=None, search_body=False):
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
# Обрабатываем префиксы from:/subject:/body:
|
||||
from_filter = None
|
||||
subject_filter = None
|
||||
body_only = search_body
|
||||
|
||||
# Если запрос содержит from: или subject: — выносим в WHERE
|
||||
import re
|
||||
mf = re.search(r'\bfrom:(\S+)', query)
|
||||
if mf:
|
||||
from_filter = mf.group(1).replace('"', '').replace("'", "")
|
||||
query = query.replace(mf.group(0), '').strip()
|
||||
|
||||
ms = re.search(r'\bsubject:(\S+)', query)
|
||||
if ms:
|
||||
subject_filter = ms.group(1).replace('"', '').replace("'", "")
|
||||
query = query.replace(ms.group(0), '').strip()
|
||||
|
||||
if not query:
|
||||
print("❌ Укажите поисковый запрос")
|
||||
sys.exit(1)
|
||||
|
||||
# Строим SQL
|
||||
if body_only:
|
||||
# Ищем в FTS5 с телом письма
|
||||
sql = """
|
||||
SELECT e.path, e.folder, e.date, e.from_addr, e.subject,
|
||||
e.body_preview, e.contacts_extracted, e.contacts_skipped
|
||||
FROM email_fts f
|
||||
JOIN emails e ON e.rowid = f.rowid
|
||||
WHERE email_fts MATCH ?
|
||||
"""
|
||||
params = [query]
|
||||
else:
|
||||
# Стандартный поиск — FTS5 по subject + from + to
|
||||
sql = """
|
||||
SELECT e.path, e.folder, e.date, e.from_addr, e.subject,
|
||||
e.body_preview, e.contacts_extracted, e.contacts_skipped
|
||||
FROM email_fts f
|
||||
JOIN emails e ON e.rowid = f.rowid
|
||||
WHERE email_fts MATCH ?
|
||||
"""
|
||||
params = [query]
|
||||
|
||||
if folder:
|
||||
sql += " AND e.folder = ?"
|
||||
params.append(folder)
|
||||
|
||||
# Дополнительные фильтры для точного поиска
|
||||
conditions = []
|
||||
if from_filter:
|
||||
conditions.append("e.from_addr LIKE ?")
|
||||
params.append(f"%{from_filter}%")
|
||||
if subject_filter:
|
||||
conditions.append("e.subject LIKE ?")
|
||||
params.append(f"%{subject_filter}%")
|
||||
|
||||
if conditions:
|
||||
sql += " AND " + " AND ".join(conditions)
|
||||
|
||||
sql += " ORDER BY e.date DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
try:
|
||||
rows = c.execute(sql, params).fetchall()
|
||||
except sqlite3.OperationalError as e:
|
||||
print(f"❌ Ошибка FTS5: {e}")
|
||||
print()
|
||||
print("Подсказки:")
|
||||
print(" Используйте кавычки для точной фразы: \"Иван Иванов\"")
|
||||
print(" Избегайте спецсимволов: * ? - (их не должно быть в простых словах)")
|
||||
print(" Пример: python3 sqlite_search.py 'битрикс OR контрагент'")
|
||||
conn.close()
|
||||
sys.exit(1)
|
||||
|
||||
if not rows:
|
||||
print(" Ничего не найдено")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
print(f" Найдено: {len(rows)} писем\n")
|
||||
|
||||
for r in rows:
|
||||
extr = "+" if r["contacts_extracted"] else "·"
|
||||
if r["contacts_skipped"]:
|
||||
extr = "-"
|
||||
date_fmt = format_date(r["date"])
|
||||
folder_short = r["folder"].replace("INBOX/", "") if r["folder"].startswith("INBOX") else r["folder"]
|
||||
|
||||
print(f" [{extr}] {date_fmt} | {folder_short:>20}")
|
||||
print(f" От: {r['from_addr'][:60]}")
|
||||
print(f" Тема: {r['subject'][:80]}")
|
||||
print(f" {EMAIL_ROOT / r['path']}")
|
||||
print()
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
global EMAIL_ROOT
|
||||
from pathlib import Path as P
|
||||
EMAIL_ROOT = P("/opt/hermes/email")
|
||||
|
||||
parser = argparse.ArgumentParser(description="FTS5-поиск по архиву писем")
|
||||
parser.add_argument("query", type=str, nargs="?", help="Поисковый запрос")
|
||||
parser.add_argument("--limit", "-l", type=int, default=15, help="Макс. результатов")
|
||||
parser.add_argument("--folder", "-f", type=str, help="Фильтр по папке")
|
||||
parser.add_argument("--body", "-b", action="store_true", help="Поиск с учётом тела письма")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.query:
|
||||
parser.print_help()
|
||||
print()
|
||||
print("Примеры:")
|
||||
print(f" {sys.argv[0]} 'Стороженко'")
|
||||
print(f" {sys.argv[0]} 'from:example@mail'")
|
||||
print(f" {sys.argv[0]} 'subject:отчёт' --folder 'INBOX/!Отчеты'")
|
||||
print(f" {sys.argv[0]} 'битрикс OR контрагент'")
|
||||
sys.exit(0)
|
||||
|
||||
search(args.query, args.limit, args.folder, args.body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user