489 lines
20 KiB
Python
489 lines
20 KiB
Python
#!/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_<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 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() |