Initial commit: Email Assistant project

This commit is contained in:
2026-07-19 16:19:14 +00:00
commit 004977b1f8
16 changed files with 2653 additions and 0 deletions
+21
View File
@@ -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
+9
View File
@@ -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)
+215
View File
@@ -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/`
+17
View File
@@ -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"
+192
View File
@@ -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`
+68
View File
@@ -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)
+119
View File
@@ -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
```
+8
View File
@@ -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
+593
View File
@@ -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()
+218
View File
@@ -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()
+24
View File
@@ -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
+440
View File
@@ -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())
+317
View File
@@ -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()
+201
View File
@@ -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())
+37
View File
@@ -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 ==="
+174
View File
@@ -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()