commit 004977b1f8409507fdcfba951a873734274bbb58 Author: hermes Date: Sun Jul 19 16:19:14 2026 +0000 Initial commit: Email Assistant project diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5acf28f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1667838 --- /dev/null +++ b/README.md @@ -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) \ No newline at end of file diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..fd91371 --- /dev/null +++ b/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-.json` +- Himalaya читает envelope (from, to, subject, date, message-id) → YAML frontmatter +- `himalaya envelope --page-size 500` для быстрой загрузки списка писем +- Каждое письмо: `himalaya get | 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/` diff --git a/config/himalaya-config.toml b/config/himalaya-config.toml new file mode 100644 index 0000000..0622972 --- /dev/null +++ b/config/himalaya-config.toml @@ -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" diff --git a/context/CONTACTS.md b/context/CONTACTS.md new file mode 100644 index 0000000..b5489a0 --- /dev/null +++ b/context/CONTACTS.md @@ -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` diff --git a/context/CONTEXT.md b/context/CONTEXT.md new file mode 100644 index 0000000..f063b16 --- /dev/null +++ b/context/CONTEXT.md @@ -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//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) diff --git a/context/SKILL.md b/context/SKILL.md new file mode 100644 index 0000000..18bcebe --- /dev/null +++ b/context/SKILL.md @@ -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//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 +``` diff --git a/scripts/contacts-cron.sh b/scripts/contacts-cron.sh new file mode 100755 index 0000000..e8a87a1 --- /dev/null +++ b/scripts/contacts-cron.sh @@ -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 diff --git a/scripts/contacts_extractor.py b/scripts/contacts_extractor.py new file mode 100644 index 0000000..7e37f7a --- /dev/null +++ b/scripts/contacts_extractor.py @@ -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 + # Формат: /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() diff --git a/scripts/digest.py b/scripts/digest.py new file mode 100644 index 0000000..dc0f01c --- /dev/null +++ b/scripts/digest.py @@ -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() diff --git a/scripts/mail-archive.sh b/scripts/mail-archive.sh new file mode 100755 index 0000000..d0eef1a --- /dev/null +++ b/scripts/mail-archive.sh @@ -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 diff --git a/scripts/mail_archive.py b/scripts/mail_archive.py new file mode 100755 index 0000000..8ba835b --- /dev/null +++ b/scripts/mail_archive.py @@ -0,0 +1,440 @@ +#!/usr/bin/env python3 +""" +mail_archive.py — инкрементальный архиватор почты в локальную файловую базу. + +Сохраняет письма из IMAP-ящика в структуру: + /opt/hermes/email//YYYY/MM/UID/ + ├── email.md # YAML-frontmatter + тело письма + └── attachments/ # вложения (если есть) + +Формат email.md: +--- +id: 255 +folder: Sent +subject: "Re: ..." +from: "Name " +to: "Name " +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 " +content_type: multipart/mixed; boundary=... +--- + +Body text here... + +Отслеживает last_uid для каждой папки в /opt/hermes/email/state/mail-archive-last-.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 '.""" + 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//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()) \ No newline at end of file diff --git a/scripts/mail_index.py b/scripts/mail_index.py new file mode 100644 index 0000000..638afb4 --- /dev/null +++ b/scripts/mail_index.py @@ -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() \ No newline at end of file diff --git a/scripts/migrate_to_email_md.py b/scripts/migrate_to_email_md.py new file mode 100644 index 0000000..3e7cb0f --- /dev/null +++ b/scripts/migrate_to_email_md.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +migrate_to_email_md.py — конвертация существующего архива из meta.json + body.md в email.md. + +Сканирует /mnt/yandex-disk/hermes/email//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 " + 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]: + """Найти все директории вида /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()) \ No newline at end of file diff --git a/scripts/push_to_gitea.sh b/scripts/push_to_gitea.sh new file mode 100644 index 0000000..873d3d6 --- /dev/null +++ b/scripts/push_to_gitea.sh @@ -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 ===" \ No newline at end of file diff --git a/scripts/sqlite_search.py b/scripts/sqlite_search.py new file mode 100644 index 0000000..02f172a --- /dev/null +++ b/scripts/sqlite_search.py @@ -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()