Files
email-assistant/scripts/mail_index.py
T

317 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()