Files
email-assistant/scripts/email_handlers.py
T

523 lines
21 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
"""
email_handlers.py — обработчики по тегам классификации писем.
Сканирует email.md архива, для писем с тегом classification и без соответствующего
поля handled_* в frontmatter выполняет обработчик:
urgent → Telegram (через Bot API + SOCKS5-туннель; from/subject/превью)
task → Radicale CalDAV: VTODO в календарь «Задачи» (SUMMARY=тема,
DESCRIPTION=ссылка на email.md, при наличии даты DTSTART/DUE)
meeting → Radicale CalDAV: VEVENT в календарь «Рабочий» (SUMMARY=тема,
DTSTART из meeting_datetime или ближайший рабочий день 11:00)
info → ничего (только тег в frontmatter)
После успешной обработки в frontmatter пишется handled_urgent/handled_task/
handled_meeting: true — повторный запуск не создаёт дубликатов (идемпотентность).
Секреты — только из .env (рядом со скриптом):
RADICALE_URL / RADICALE_USER / RADICALE_PASS — доступ к Radicale
VESTI_BOT_TOKEN (или TELEGRAM_BOT_TOKEN) — токен бота Telegram
TG_PROXY — SOCKS5 до Bot API (по умолчанию socks5://127.0.0.1:1080)
TELEGRAM_CHAT_ID — куда слать urgent (по умолчанию @dedinit_vesti)
Запуск:
python3 scripts/email_handlers.py # все необработанные
python3 scripts/email_handlers.py --limit 10
python3 scripts/email_handlers.py --folder INBOX
python3 scripts/email_handlers.py --dry-run # показать, что бы сделал
"""
import argparse
import http.client
import json
import os
import re
import sys
import urllib.error
import urllib.request
from datetime import datetime, timedelta
from functools import lru_cache
from pathlib import Path
try:
from dotenv import load_dotenv
except ImportError:
load_dotenv = None
def _load_env_file(path):
"""Загрузить KEY=VALUE из .env-файла, не перезаписывая уже заданные env.
stdlib-фолбэк python-dotenv (в проекте нет сторонних зависимостей).
"""
p = Path(path)
if not p.exists():
return
try:
lines = p.read_text(encoding="utf-8").splitlines()
except OSError:
return
for line in lines:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
key = key.strip()
val = val.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = val
# Каталог скрипта → .env рядом с проектом (+ radicale/.env для RADICALE_PASS)
BASE_DIR = Path(__file__).resolve().parents[1]
if load_dotenv:
load_dotenv(BASE_DIR / ".env", override=False)
# radicale/.env — фактический источник RADICALE_PASS (проектного .env нет)
load_dotenv(BASE_DIR / "radicale" / ".env", override=False)
else:
_load_env_file(BASE_DIR / ".env")
_load_env_file(BASE_DIR / "radicale" / ".env")
# Токен Telegram живёт в /opt/vesti/.env (проект-источник бота @dedinit_vesti);
# опционально: если файл есть, берём VESTI_BOT_TOKEN/TELEGRAM_CHAT_ID оттуда.
vesti_env = Path("/opt/vesti/.env")
if vesti_env.exists():
_load_env_file(vesti_env)
EMAIL_ROOT = Path(os.getenv("EMAIL_ROOT", "/opt/hermes/email"))
# --- Radicale (CalDAV) ---
RADICALE_URL = os.getenv("RADICALE_URL", "http://127.0.0.1:5232").rstrip("/")
RADICALE_USER = os.getenv("RADICALE_USER", "estorozhenko")
RADICALE_PASS = os.getenv("RADICALE_PASS", "")
# Календари (percent-encoded, «Задачи» и «Рабочий» — кириллица)
TASKS_CAL = os.getenv("RADICALE_TASKS_CAL", "%D0%97%D0%B0%D0%B4%D0%B0%D1%87%D0%B8") # Задачи
WORK_CAL = os.getenv("RADICALE_WORK_CAL", "%D0%A0%D0%B0%D0%B1%D0%BE%D1%87%D0%B8%D0%B9") # Рабочий
# --- Telegram (Bot API через SOCKS5) ---
TG_TOKEN = os.getenv("VESTI_BOT_TOKEN") or os.getenv("TELEGRAM_BOT_TOKEN") or ""
TG_PROXY = os.getenv("TG_PROXY", "socks5://127.0.0.1:1080")
TG_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "@dedinit_vesti")
TG_API = "https://api.telegram.org"
# --- Общие ---
LLM_TIMEOUT = 20
MAX_PREVIEW_CHARS = 400 # превью письма для Telegram
TG_TIMEOUT = 20
# ---------------------------------------------------------------------------
# Frontmatter
# ---------------------------------------------------------------------------
def _unquote_yaml(v):
"""Снять обрамляющие двойные кавычки YAML-значения (артефакт простого парсера).
Если значение начинается/заканчивается на ", снять их и разэкранировать \\".
"""
v = v.strip()
if len(v) >= 2 and v[0] == '"' and v[-1] == '"':
v = v[1:-1].replace('\\"', '"').replace("\\\\", "\\")
return v
def parse_email_md(path):
"""Прочитать email.md, вернуть (headers, body, content, fm_end)."""
content = path.read_text(encoding="utf-8", errors="replace")
match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", content, re.DOTALL)
if match:
yaml_block = match.group(1)
body = match.group(2).strip()
fm_end = match.end(1)
headers = {}
for line in yaml_block.split("\n"):
m = re.match(r"^(\w[\w_-]*)\s*:\s*(.*)$", line)
if m:
headers[m.group(1)] = _unquote_yaml(m.group(2))
else:
headers, body, fm_end = {}, content.strip(), None
return headers, body, content, fm_end
def yaml_quote(v):
"""YAML-значение: обернуть в двойные кавычки при спецсимволах."""
v = str(v)
if v == "":
return '""'
if re.search(r'[:#\[\]{}&*!|>\'"%@`\n]|^\s|\s$', v):
return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
return v
def add_to_frontmatter(content, fm_end, fields):
"""Добавить поля в frontmatter (перед закрывающим ---)."""
add_lines = []
for k, v in fields:
if v is None or v == "":
continue
add_lines.append(f"{k}: {yaml_quote(v)}")
if not add_lines:
return content
before = content[:fm_end]
after = content[fm_end:]
return before + "\n" + "\n".join(add_lines) + after
def mark_handled(path, tag):
"""Пометить письмо handled_<tag>: true. Возвращает True при изменении."""
headers, body, content, fm_end = parse_email_md(path)
if fm_end is None:
return False
key = f"handled_{tag}"
if headers.get(key) == "true":
return False
new_content = add_to_frontmatter(content, fm_end, [(key, "true")])
if new_content != content:
path.write_text(new_content, encoding="utf-8")
return True
# ---------------------------------------------------------------------------
# Radicale (CalDAV)
# ---------------------------------------------------------------------------
def _caldav(path: str, method="GET", body=None, content_type=None):
"""Базовый HTTP к Radicale с Basic-auth через http.client.
path — ПОЛНЫЙ URL (например http://127.0.0.1:5232/estorozhenko/...).
urllib.request не умеет URL с percent-encoded кириллицей в пути
(Errno -2 Name or service not known), поэтому используем http.client
напрямую — он корректно работает с encoded path.
Возвращает (status, text).
"""
import base64
from urllib.parse import urlsplit
if not path.startswith("http"):
path = RADICALE_URL + path
parsed = urlsplit(path)
conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=LLM_TIMEOUT)
full_path = parsed.path + (("?" + parsed.query) if parsed.query else "")
headers = {}
if content_type:
headers["Content-Type"] = content_type
if method == "PROPFIND":
headers["Depth"] = "1"
cred = base64.b64encode(f"{RADICALE_USER}:{RADICALE_PASS}".encode()).decode()
headers["Authorization"] = f"Basic {cred}"
try:
conn.request(method, full_path, body=body, headers=headers)
resp = conn.getresponse()
return resp.status, resp.read().decode("utf-8", errors="replace")
except Exception as e:
return 0, str(e)
finally:
conn.close()
def ics_escape(s):
"""Экранирование значений iCalendar (RFC 5545): backslash, semicolon, comma, переносы."""
s = str(s).replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,")
return s.replace("\r\n", "\\n").replace("\n", "\\n")
@lru_cache(maxsize=1)
def find_calendar_url():
"""Определить URL коллекций «Задачи» и «Рабочий» из PROPFIND (xml.etree)."""
status, text = _caldav(f"/{RADICALE_USER}/", "PROPFIND", body=b"", content_type="application/xml")
found: dict = {"Задачи": None, "Рабочий": None}
if status != 207:
return found
try:
import xml.etree.ElementTree as ET
root = ET.fromstring(text)
# Пространства имён: DAV: (по умолчанию), C: — caldav
ns = {"d": "DAV:", "c": "urn:ietf:params:xml:ns:caldav"}
for resp in root.findall("d:response", ns):
href_el = resp.find("d:href", ns)
if href_el is None:
continue
href = (href_el.text or "").strip()
rt = resp.find(".//d:resourcetype", ns)
if rt is None:
continue
is_cal = rt.find("c:calendar", ns) is not None
if not is_cal:
continue
import urllib.parse
dec = urllib.parse.unquote(href)
for name, key in [("Задачи", "Задачи"), ("Рабочий", "Рабочий")]:
if key in dec and found[name] is None:
found[name] = RADICALE_URL + href
except Exception as e:
print(f" ⚠ find_calendar_url: {e}", file=sys.stderr)
return found
def vtodo(uid, summary, description, due_dt=None):
"""Сформировать VTODO (iCalendar). due_dt: 'YYYY-MM-DD HH:MM' или None."""
now = datetime.now().strftime("%Y%m%dT%H%M%S")
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//email-assistant//VTODO//RU",
"BEGIN:VTODO",
f"UID:{uid}@email-assistant",
f"DTSTAMP:{now}",
f"SUMMARY:{ics_escape(summary)}",
f"DESCRIPTION:{ics_escape(description)}",
]
if due_dt:
lines.append(f"DUE:{due_dt.replace(' ', 'T')}:00")
lines += ["STATUS:NEEDS-ACTION", "END:VTODO", "END:VCALENDAR"]
return "\r\n".join(lines)
def vevent(uid, summary, description, start_dt, duration_min=60):
"""Сформировать VEVENT. start_dt: 'YYYY-MM-DD HH:MM'."""
now = datetime.now().strftime("%Y%m%dT%H%M%S")
start = datetime.strptime(start_dt, "%Y-%m-%d %H:%M")
start_ics = start.strftime("%Y%m%dT%H%M%S")
end_ics = (start + timedelta(minutes=duration_min)).strftime("%Y%m%dT%H%M%S")
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//email-assistant//VEVENT//RU",
"BEGIN:VEVENT",
f"UID:{uid}@email-assistant",
f"DTSTAMP:{now}",
f"SUMMARY:{ics_escape(summary)}",
f"DESCRIPTION:{ics_escape(description)}",
f"DTSTART:{start_ics}",
f"DTEND:{end_ics}",
"END:VEVENT",
"END:VCALENDAR",
]
return "\r\n".join(lines)
def next_workday_1100(now=None):
"""Ближайший будний день (пн-пт) в 11:00. now: datetime."""
now = now or datetime.now()
d = now
while d.weekday() >= 5: # сб/вс
d += timedelta(days=1)
return d.strftime("%Y-%m-%d") + " 11:00"
def handle_task(path, headers, body):
"""Создать VTODO в Radicale «Задачи». Возвращает (ok, detail)."""
summary = (headers.get("subject") or "(без темы)").strip()
# UID стабильный: от пути письма
rel = path.relative_to(EMAIL_ROOT) if EMAIL_ROOT in path.parents else path
uid = re.sub(r"[^a-zA-Z0-9]+", "-", str(rel)).strip("-")
description = f"Из письма: {path}"
due = None
mdt = headers.get("meeting_datetime") or headers.get("date")
if mdt:
mdt = mdt.replace("T", " ")[:16]
if re.match(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$", mdt):
due = mdt
# URL коллекции «Задачи»
cals = find_calendar_url()
cal_url = cals.get("Задачи")
if not cal_url:
return False, "Коллекция «Задачи» не найдена в PROPFIND"
ics = vtodo(uid, summary, description, due)
resp_status, resp_text = _caldav(cal_url + uid + ".ics", "PUT", body=ics.encode("utf-8"),
content_type="text/calendar; charset=utf-8")
if resp_status in (200, 201, 204):
return True, f"VTODO создан ({resp_status})"
return False, f"PUT {resp_status}: {resp_text[:200]}"
def handle_meeting(path, headers, body):
"""Создать VEVENT в Radicale «Рабочий». Возвращает (ok, detail)."""
summary = (headers.get("subject") or "(без темы)").strip()
rel = path.relative_to(EMAIL_ROOT) if EMAIL_ROOT in path.parents else path
uid = re.sub(r"[^a-zA-Z0-9]+", "-", str(rel)).strip("-")
description = f"Из письма: {path}"
start = None
mdt = headers.get("meeting_datetime")
if mdt:
mdt = mdt.replace("T", " ")[:16]
if re.match(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$", mdt):
start = mdt
if not start:
start = next_workday_1100()
cals = find_calendar_url()
cal_url = cals.get("Рабочий")
if not cal_url:
return False, "Коллекция «Рабочий» не найдена в PROPFIND"
ics = vevent(uid, summary, description, start)
resp_status, resp_text = _caldav(cal_url + uid + ".ics", "PUT", body=ics.encode("utf-8"),
content_type="text/calendar; charset=utf-8")
if resp_status in (200, 201, 204):
return True, f"VEVENT создан ({resp_status})"
return False, f"PUT {resp_status}: {resp_text[:200]}"
# ---------------------------------------------------------------------------
# Telegram (Bot API через SOCKS5)
# ---------------------------------------------------------------------------
def tg_client():
"""httpx.Client с SOCKS5-прокси. Путь прокси из TG_PROXY."""
try:
import httpx
except ImportError:
return None
return httpx.Client(proxy=TG_PROXY, timeout=TG_TIMEOUT)
def tg_call(method, **params):
"""Вызвать метод Bot API. Возвращает result dict. Ошибки -> исключение."""
if not TG_TOKEN:
raise RuntimeError("Нет токена Telegram (VESTI_BOT_TOKEN/TELEGRAM_BOT_TOKEN не задан)")
client = tg_client()
if client is None:
raise RuntimeError("httpx не установлен — нужен для Telegram")
try:
with client:
r = client.post(f"{TG_API}/bot{TG_TOKEN}/{method}", json=params, timeout=TG_TIMEOUT)
except Exception as e:
raise RuntimeError(f"Сеть/прокси до Bot API: {e}") from e
if r.status_code != 200:
try:
desc = r.json().get("description", "")
except Exception:
desc = r.text[:200]
raise RuntimeError(f"Bot API {method}: HTTP {r.status_code} {desc}")
data = r.json()
if not data.get("ok"):
raise RuntimeError(f"Bot API {method}: {data.get('description','')}")
return data.get("result", {})
def format_date_for_tg(date_str):
"""'2026-09-02 10:49+03:00' → '02.09.2026 10:49' (ДД.ММ.ГГГГ ЧЧ:ММ).
Известные форматы frontmatter:
'2026-09-02 10:49+03:00' (ISO, может быть пробел/буква перед таймзоной)
'2026-09-02T10:49:03+03:00' (RFC3339)
Если распарсить не удалось — вернуть как есть (не ломать уведомление).
"""
s = (date_str or "").strip()
if not s:
return ""
t = s
# нормализуем: отрезаем таймзону (+03:00 / +0300 / Z)
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})", s)
if m:
return f"{m.group(3)}.{m.group(2)}.{m.group(1)} {m.group(4)}:{m.group(5)}"
return s
def handle_urgent(path, headers, body):
"""Отправить уведомление в Telegram. Возвращает (ok, detail)."""
subject = (headers.get("subject") or "(без темы)").strip()
sender = (headers.get("from") or "?").strip()
# Дата/время получения — из frontmatter, формат ДД.ММ.ГГГГ ЧЧ:ММ
date = format_date_for_tg(headers.get("date") or headers.get("internal_date"))
date_line = f"📅 {date}\n" if date else ""
preview = body.strip()
if len(preview) > MAX_PREVIEW_CHARS:
preview = preview[:MAX_PREVIEW_CHARS].rstrip() + "…"
text = (
f"⚠️ СРОЧНОЕ письмо\n"
f"{date_line}"
f"От: {sender}\n"
f"Тема: {subject}\n\n"
f"{preview}\n\n"
f"Письмо: file://{path}"
)
try:
result = tg_call("sendMessage", chat_id=TG_CHAT_ID, text=text,
link_preview_options={"is_disabled": True})
msg_id = result.get("message_id")
return True, f"Отправлено в {TG_CHAT_ID} (msg_id={msg_id})"
except Exception as e:
return False, f"Telegram: {e}"
# ---------------------------------------------------------------------------
# Основной проход
# ---------------------------------------------------------------------------
def find_email_md_files(root, folder=None):
files = []
for p in sorted(root.rglob("email.md")):
if folder:
rel = p.relative_to(root)
if not rel.parts[0] == folder:
continue
files.append(p)
return files
def email_sort_key(path):
"""Свежие письма первыми."""
import re as _re
parts = path.parts
year = next((int(p) for p in parts if _re.fullmatch(r"\d{4}", p)), 0)
month = next((int(p) for p in parts if _re.fullmatch(r"\d{2}", p) and 1 <= int(p) <= 12), 0)
uid = next((int(p) for p in parts if p.isdigit() and int(p) > 100), 0)
if not year:
try:
return (-float(path.stat().st_mtime),)
except OSError:
return (0,)
return (-year, -month, -uid)
HANDLERS = {
"urgent": handle_urgent,
"task": handle_task,
"meeting": handle_meeting,
}
def main():
ap = argparse.ArgumentParser(description="Обработчики по тегам классификации писем")
ap.add_argument("--limit", type=int, default=0, help="Максимум писем за проход")
ap.add_argument("--folder", default=None, help="Только папка (INBOX)")
ap.add_argument("--dry-run", action="store_true", help="Не писать, только показать")
args = ap.parse_args()
files = find_email_md_files(EMAIL_ROOT, args.folder)
pending = []
for p in files:
headers, body, content, fm_end = parse_email_md(p)
cls = headers.get("classification", "")
if not cls:
continue
tags = [t.strip() for t in cls.split(",") if t.strip()]
need = [t for t in tags if t in HANDLERS and headers.get(f"handled_{t}") != "true"]
if need:
pending.append((p, headers, body, need))
pending.sort(key=lambda x: email_sort_key(x[0]))
print(f"Обработать: {len(pending)}")
if args.limit > 0:
pending = pending[:args.limit]
results = {"urgent": 0, "task": 0, "meeting": 0, "errors": 0}
for p, headers, body, need in pending:
for tag in need:
handler = HANDLERS[tag]
try:
ok, detail = handler(p, headers, body)
if ok:
if not args.dry_run:
mark_handled(p, tag)
results[tag] += 1
print(f" ✓ [{tag}] {p.parent.parent.parent.name}/{p.parent.name}: {detail}")
else:
results["errors"] += 1
print(f" ✗ [{tag}] {p.parent.parent.parent.name}/{p.parent.name}: {detail}", file=sys.stderr)
except Exception as e:
results["errors"] += 1
print(f" ✗ [{tag}] {p}: {e}", file=sys.stderr)
print(f"\nГотово: urgent={results['urgent']}, task={results['task']}, meeting={results['meeting']}, ошибок={results['errors']}")
if args.dry_run:
print("(dry-run: ничего не записано и не отправлено)")
if __name__ == "__main__":
main()