Initial commit: Email Assistant project
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user