diff --git a/Dockerfile b/Dockerfile index e8a3231..a9a2b29 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,13 @@ FROM python:3.11-slim WORKDIR /app +# WeasyPrint (Mail → PDF) braucht Pango zum Setzen von Text sowie Schriften +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0 \ + fonts-dejavu-core fonts-liberation \ + && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/README.md b/README.md index 2c15c38..b878cec 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ Beim Start wird `config/filters.yaml` automatisch importiert (konfigurierbar via | `LOG_RETENTION_DAYS` | `30` | Verarbeitungslog: Einträge älter als N Tage werden gelöscht | | `LOG_MAX_ENTRIES` | `200000` | Verarbeitungslog: höchstens so viele (neueste) Einträge behalten | | `MAINTENANCE_HOUR_UTC` | `2` | Stunde (UTC) der täglichen DB-Wartung; läuft zusätzlich 5 Min. nach jedem Start | +| `PDF_BLOCKED_NETWORKS` | `172.0.0.0/12` | „Als PDF anhängen": aus diesen Netzen werden keine Bilder geladen (zusätzlich zu privaten Netzen, die immer gesperrt sind) | | `LOG_VERBOSE` | `false` | Jede Nicht-Übereinstimmung loggen — erzeugt Millionen Einträge, nur zur Fehlersuche | Die tägliche Wartung löscht alte Log-Einträge, entfernt Verarbeitungsmarker gelöschter Regeln und verkleinert die SQLite-Datei per `VACUUM`, sobald mehr als 25 % ungenutzt sind (nur wenn genug Plattenplatz frei ist). Manuell auslösbar im Log unter ⋯ → „Datenbank aufräumen & verkleinern". diff --git a/app/config.py b/app/config.py index 0458910..6ad37d8 100644 --- a/app/config.py +++ b/app/config.py @@ -18,6 +18,9 @@ class Settings(BaseSettings): log_retention_days: int = 30 log_max_entries: int = 200_000 maintenance_hour_utc: int = 2 + # Mail→PDF: Bilder werden nie aus privaten Netzen geladen. Zusätzlich + # gesperrte Netze (kommagetrennt), z.B. intern genutzte öffentliche Bereiche. + pdf_blocked_networks: str = "172.0.0.0/12" model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} diff --git a/app/models/db_models.py b/app/models/db_models.py index f58cc3d..f492ba0 100644 --- a/app/models/db_models.py +++ b/app/models/db_models.py @@ -39,6 +39,7 @@ class ActionType(str, enum.Enum): FORWARD = "forward" DELETE = "delete" MARK_READ = "mark_read" + PDF = "pdf" # Nachricht als PDF anhängen und in Ordner (parameter) ablegen class Account(Base): diff --git a/app/routers/filters.py b/app/routers/filters.py index d8d307c..2cf8e32 100644 --- a/app/routers/filters.py +++ b/app/routers/filters.py @@ -1,6 +1,7 @@ from datetime import datetime from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response from sqlalchemy import func, or_, text from sqlalchemy.orm import Session @@ -23,7 +24,7 @@ from app.schemas.schemas import ( FilterRuleTest, FilterRuleUpdate, ) -from app.services.rule_tester import test_rule +from app.services.rule_tester import preview_pdf, test_rule logger = logging.getLogger(__name__) @@ -58,6 +59,32 @@ def test_filter(data: FilterRuleTest, db: Session = Depends(get_db)): raise HTTPException(500, f"Test fehlgeschlagen: {e}") +@router.post("/pdf-preview") +def pdf_preview(data: FilterRuleTest, db: Session = Depends(get_db)): + """PDF-Vorschau der neuesten passenden Mail (für die Aktion 'Als PDF') — + read-only, es wird nichts verändert.""" + account = db.get(Account, data.account_id) + if not account: + raise HTTPException(404, "Konto nicht gefunden") + if not data.conditions: + raise HTTPException(400, "Keine Bedingungen angegeben") + try: + pdf, info = preview_pdf(account, data.source_folder, data.conditions, data.limit) + except LookupError as e: + raise HTTPException(404, str(e)) + except Exception as e: + logger.exception("PDF-Vorschau fehlgeschlagen") + raise HTTPException(500, f"PDF-Vorschau fehlgeschlagen: {e}") + return Response( + content=pdf, + media_type="application/pdf", + headers={ + "Content-Disposition": 'inline; filename="vorschau.pdf"', + "X-Preview-Images": f"{info['loaded']} geladen, {info['skipped']} übersprungen, {info['cid']} eingebettet", + }, + ) + + @router.patch("/{rule_id}/enabled", response_model=FilterRuleResponse) def set_filter_enabled(rule_id: int, data: FilterRuleEnabled, db: Session = Depends(get_db)): """Regel ein-/ausschalten, ohne den Verarbeitungsstatus zurückzusetzen diff --git a/app/routers/logs.py b/app/routers/logs.py index fe6b701..7b94bbe 100644 --- a/app/routers/logs.py +++ b/app/routers/logs.py @@ -93,7 +93,7 @@ def get_logs( pat = _like(folder) query = query.filter(or_( FilterLog.folder.ilike(pat, escape="\\"), - (FilterLog.action_type == "move") & FilterLog.details.ilike(pat, escape="\\"), + FilterLog.action_type.in_(("move", "pdf")) & FilterLog.details.ilike(pat, escape="\\"), )) count_query = query diff --git a/app/services/filter_engine.py b/app/services/filter_engine.py index e601f73..15b90ff 100644 --- a/app/services/filter_engine.py +++ b/app/services/filter_engine.py @@ -200,6 +200,14 @@ def execute_action( return imap_client.delete_mail( mail.uid, trash, source_folder=source_folder ) + case ActionType.PDF: + if not action.parameter: + imap_client.last_error = "Kein Zielordner in der Regel angegeben" + logger.error(imap_client.last_error) + return False + return imap_client.convert_pdf_and_move( + mail.uid, action.parameter, source_folder=source_folder + ) case ActionType.MARK_READ: return imap_client.mark_as_read(mail.uid) case ActionType.FORWARD: diff --git a/app/services/imap_client.py b/app/services/imap_client.py index 2af84d8..9a25f7b 100644 --- a/app/services/imap_client.py +++ b/app/services/imap_client.py @@ -490,6 +490,73 @@ class IMAPClient: pass return self._fail(f"Fehler beim Verschieben von Mail {uid} nach '{target_folder}': {e}") + def fetch_raw(self, uid: str) -> tuple[bytes, str, str | None] | None: + """Rohe Mail plus Flags (ohne \\Recent/\\Deleted) und INTERNALDATE im + IMAP-Format — für APPEND einer Ersatz-Mail mit gleichen Eigenschaften.""" + status, data = self.conn.uid("FETCH", uid, "(FLAGS INTERNALDATE BODY.PEEK[])") + if status != "OK": + return None + raw = _fetch_payload(data) + if raw is None: + return None + meta = b" ".join(p[0] if isinstance(p, tuple) else p for p in data if isinstance(p, (tuple, bytes))) + flags_m = re.search(rb"FLAGS \(([^)]*)\)", meta) + flags = [f for f in (flags_m.group(1).decode().split() if flags_m else []) + if f.lower() not in ("\\recent", "\\deleted")] + date_m = re.search(rb'INTERNALDATE ("[^"]+")', meta) + return raw, "(" + " ".join(flags) + ")", date_m.group(1).decode() if date_m else None + + def convert_pdf_and_move(self, uid: str, target_folder: str, source_folder: str | None) -> bool: + """Mail als PDF anhängen und in target_folder ablegen. + + Ersatz-Mail = Original-Inhalt + PDF + Original unverändert als .eml. + Sie wird per APPEND mit gleichen Flags und gleichem Eingangsdatum in den + Zielordner gelegt, dort über ihre neue Message-ID nachgewiesen, und erst + dann wird das Original in der Quelle gelöscht. Schon umgewandelte Mails + (z.B. bei Zielordner = Quellordner) werden nur verschoben.""" + from app.services.mail_pdf import build_converted_message, is_converted, render_pdf + + self.last_error = None + if not source_folder: + return self._fail(f"Mail {uid}: Quellordner unbekannt — PDF-Umwandlung abgebrochen") + try: + fetched = self.fetch_raw(uid) + if not fetched: + return self._fail(f"Mail {uid} konnte für die PDF-Umwandlung nicht geladen werden") + raw, flags, internal_date = fetched + if is_converted(raw): + return self.move_mail(uid, target_folder, source_folder=source_folder) + + try: + pdf, _ = render_pdf(raw) + except Exception as e: + return self._fail(f"PDF-Erzeugung für Mail {uid} fehlgeschlagen: {e}") + new_raw, new_id = build_converted_message(raw, pdf) + + status, data = self.conn.append(_quote_mailbox(target_folder), flags, internal_date, new_raw) + if status != "OK": + self.conn.select(_quote_mailbox(source_folder)) + detail = b" ".join(d for d in (data or []) if isinstance(d, bytes)).decode(errors="replace") + return self._fail(f"Server hat das Ablegen in '{target_folder}' abgelehnt: {status} {detail}".strip()) + + verified = self._mail_exists_in_folder(target_folder, new_id) + self.conn.select(_quote_mailbox(source_folder)) + if not verified: + return self._fail( + f"PDF-Fassung von Mail {uid} im Ziel '{target_folder}' nicht gefunden — Original bleibt in der Quelle" + ) + + self.conn.uid("STORE", uid, "+FLAGS", "(\\Deleted)") + self.conn.expunge() + logger.info("Mail %s als PDF angehängt und nach '%s' abgelegt (%s)", uid, target_folder, new_id) + return True + except Exception as e: + try: + self.conn.select(_quote_mailbox(source_folder)) + except Exception: + pass + return self._fail(f"Fehler bei PDF-Umwandlung von Mail {uid}: {e}") + def delete_mail( self, uid: str, diff --git a/app/services/mail_pdf.py b/app/services/mail_pdf.py new file mode 100644 index 0000000..4816c32 --- /dev/null +++ b/app/services/mail_pdf.py @@ -0,0 +1,283 @@ +"""Mail → PDF: für Absender, bei denen die Nachricht selbst der Beleg ist +(z.B. GoCardless-Auszahlungen ohne PDF-Anhang). + +Die Mail wird wie ein Ausdruck aus dem Mailprogramm gerendert (Kopfblock mit +Von/An/Datum/Betreff, darunter der Inhalt). Bilder werden vorher selbst geladen +und als data:-URIs eingebettet; WeasyPrint darf selbst nichts nachladen. Beim +Nachladen gelten Schutzregeln, weil auch Spam-Mails Ziel einer Regel sein +können: nur http(s), nur öffentliche Adressen (keine Anfragen ins interne +Netz), Zeit- und Größenlimits, Redirects werden einzeln geprüft. + +build_converted_message() baut die Ersatz-Mail: Original-Inhalt (Anzeige wie +vorher) + PDF-Anhang + die Original-Mail Byte für Byte als .eml-Anhang, damit +das unveränderte Original erhalten bleibt (GoBD).""" +import base64 +import email +import html +import ipaddress +import logging +import re +import socket +import urllib.error +import urllib.parse +import urllib.request +from email import policy +from email.generator import BytesGenerator +from email.message import Message +from email.mime.application import MIMEApplication +from email.mime.multipart import MIMEMultipart +from email.utils import make_msgid +from io import BytesIO + +from app.config import settings +from app.services.imap_client import _decode_header_value, _safe_decode + +logger = logging.getLogger(__name__) + +CONVERTED_HEADER = "X-Mailfilter-Converted" + +FETCH_TIMEOUT = 8 +MAX_RESOURCE_BYTES = 5 * 1024 * 1024 +MAX_TOTAL_BYTES = 20 * 1024 * 1024 +MAX_RESOURCES = 40 +MAX_REDIRECTS = 3 + +_PRINT_CSS = """ +@page { size: A4; margin: 14mm 12mm; } +html, body { font-family: 'DejaVu Sans', Arial, sans-serif; } +img { max-width: 100%; height: auto; } +table { max-width: 100%; } +.mf-head { font: 9.5pt 'DejaVu Sans', Arial, sans-serif; color: #222; border-bottom: 1px solid #bbb; + padding-bottom: 6pt; margin-bottom: 10pt; background: #fff; } +.mf-head, .mf-head * { text-align: left !important; } +.mf-head table { border-collapse: collapse; width: auto; } +.mf-head td { padding: 1pt 8pt 1pt 0; vertical-align: top; } +.mf-head td.k { color: #666; white-space: nowrap; } +.mf-head .subj { font-size: 12pt; font-weight: bold; margin-bottom: 4pt; } +pre.mf-text { white-space: pre-wrap; word-wrap: break-word; font: 10pt 'DejaVu Sans Mono', monospace; } +""" + + +# ---------- Bilder sicher laden ---------- + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None # Redirects selbst behandeln, damit jedes Ziel geprüft wird + + +_opener = urllib.request.build_opener(_NoRedirect) + + +def _blocked_networks() -> list: + nets = [] + for part in (settings.pdf_blocked_networks or "").split(","): + part = part.strip() + if part: + try: + nets.append(ipaddress.ip_network(part, strict=False)) + except ValueError: + logger.warning("PDF_BLOCKED_NETWORKS: ungültiges Netz '%s' ignoriert", part) + return nets + + +def _host_is_public(host: str) -> bool: + try: + infos = socket.getaddrinfo(host, None) + except socket.gaierror: + return False + blocked = _blocked_networks() + for info in infos: + ip = ipaddress.ip_address(info[4][0].split("%")[0]) + if not ip.is_global or ip.is_multicast or any(ip in net for net in blocked): + return False + return bool(infos) + + +def _safe_fetch(url: str, budget: dict) -> tuple[bytes, str] | None: + for _ in range(MAX_REDIRECTS + 1): + parts = urllib.parse.urlsplit(url) + if parts.scheme not in ("http", "https") or not parts.hostname: + return None + if not _host_is_public(parts.hostname): + logger.info("PDF: nicht-öffentliche Adresse übersprungen: %s", parts.hostname) + return None + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (MailFilter PDF)"}) + try: + resp = _opener.open(req, timeout=FETCH_TIMEOUT) + except urllib.error.HTTPError as e: + if e.code in (301, 302, 303, 307, 308) and e.headers.get("Location"): + url = urllib.parse.urljoin(url, e.headers["Location"]) + continue + return None + except Exception: + return None + with resp: + ctype = (resp.headers.get_content_type() or "").lower() + if not ctype.startswith("image/"): + return None + remaining = min(MAX_RESOURCE_BYTES, budget["bytes"]) + data = resp.read(remaining + 1) + if len(data) > remaining: + return None + budget["bytes"] -= len(data) + return data, ctype + return None + + +def _data_uri(data: bytes, ctype: str) -> str: + return f"data:{ctype};base64,{base64.b64encode(data).decode()}" + + +def _inline_resources(body_html: str, cid_map: dict[str, tuple[bytes, str]]) -> tuple[str, dict]: + """Ersetzt src="cid:…", src="http…", background="…" und CSS url(…) durch + data:-URIs. Nicht ladbare Ressourcen werden entfernt.""" + budget = {"bytes": MAX_TOTAL_BYTES, "count": 0} + cache: dict[str, str | None] = {} + stats = {"loaded": 0, "skipped": 0, "cid": 0} + + def resolve(raw_url: str) -> str | None: + url = html.unescape(raw_url.strip()) + if url in cache: + return cache[url] + result = None + if url.lower().startswith("cid:"): + part = cid_map.get(urllib.parse.unquote(url[4:]).strip("<>")) + if part: + result = _data_uri(*part) + stats["cid"] += 1 + elif url.lower().startswith("data:"): + result = url + elif url.lower().startswith(("http://", "https://")) and budget["count"] < MAX_RESOURCES: + budget["count"] += 1 + fetched = _safe_fetch(url, budget) + if fetched: + result = _data_uri(*fetched) + stats["loaded"] += 1 + if result is None and not url.lower().startswith("data:"): + stats["skipped"] += 1 + cache[url] = result + return result + + def attr_sub(m: re.Match) -> str: + new = resolve(m.group(3)) + return f'{m.group(1)}={m.group(2)}{new or ""}{m.group(2)}' + + body_html = re.sub(r"\s(?:srcset)\s*=\s*([\"']).*?\1", " ", body_html, flags=re.I | re.S) + body_html = re.sub(r"(\s(?:src|background))\s*=\s*([\"'])(.*?)\2", attr_sub, body_html, flags=re.I | re.S) + body_html = re.sub( + r"url\(\s*([\"']?)(.*?)\1\s*\)", + lambda m: f"url('{resolve(m.group(2)) or ''}')", + body_html, flags=re.I, + ) + return body_html, stats + + +# ---------- Rendern ---------- + +def _mail_parts(msg: Message) -> tuple[str | None, str | None, dict]: + html_body, text_body, cid_map = None, None, {} + for part in msg.walk(): + if part.is_multipart(): + continue + ctype = part.get_content_type() + disposition = str(part.get("Content-Disposition") or "").lower() + cid = part.get("Content-ID") + if cid: + payload = part.get_payload(decode=True) + if payload: + cid_map[cid.strip().strip("<>")] = (payload, ctype) + if "attachment" in disposition: + continue + if ctype == "text/html" and html_body is None: + html_body = _safe_decode(part.get_payload(decode=True) or b"", part.get_content_charset()) + elif ctype == "text/plain" and text_body is None: + text_body = _safe_decode(part.get_payload(decode=True) or b"", part.get_content_charset()) + return html_body, text_body, cid_map + + +def _header_block(msg: Message) -> str: + rows = [] + for key, label in (("From", "Von"), ("To", "An"), ("Cc", "Cc"), ("Date", "Datum")): + value = _decode_header_value(msg.get(key)) + if value: + rows.append(f'