#!/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 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)] = m.group(2).strip() 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_: 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 handle_urgent(path, headers, body): """Отправить уведомление в Telegram. Возвращает (ok, detail).""" subject = (headers.get("subject") or "(без темы)").strip() sender = (headers.get("from") or "?").strip() preview = body.strip() if len(preview) > MAX_PREVIEW_CHARS: preview = preview[:MAX_PREVIEW_CHARS].rstrip() + "…" text = ( f"⚠️ СРОЧНОЕ письмо\n\n" 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()