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'{label}:{html.escape(value)}') + subject = html.escape(_decode_header_value(msg.get("Subject")) or "(kein Betreff)") + return f'
{subject}
{"".join(rows)}
' + + +def render_pdf(raw: bytes) -> tuple[bytes, dict]: + """Rendert die Mail als PDF. Liefert (pdf_bytes, stats).""" + from weasyprint import CSS, HTML + from weasyprint.urls import URLFetcher + + msg = email.message_from_bytes(raw) + html_body, text_body, cid_map = _mail_parts(msg) + head = _header_block(msg) + + if html_body: + body, stats = _inline_resources(html_body, cid_map) + body = re.sub(r"", "", body, flags=re.I | re.S) + if re.search(r"]*>", body, flags=re.I): + document = re.sub(r"(]*>)", lambda m: m.group(1) + head, body, count=1, flags=re.I) + else: + document = f"{head}{body}" + else: + stats = {"loaded": 0, "skipped": 0, "cid": 0} + text = html.escape(text_body or "") + document = f"{head}
{text}
" + + # Alles ist bereits eingebettet — WeasyPrint darf nur noch data:-URIs lesen + fetcher = URLFetcher(allowed_protocols={"data"}, allow_redirects=False) + pdf = HTML(string=document, url_fetcher=fetcher).write_pdf(stylesheets=[CSS(string=_PRINT_CSS)]) + return pdf, stats + + +# ---------- Ersatz-Mail bauen ---------- + +def _pdf_filename(msg: Message) -> str: + subject = _decode_header_value(msg.get("Subject")) or "Nachricht" + name = re.sub(r'[\\/:*?"<>|\r\n\t]+', "_", subject).strip(" ._") or "Nachricht" + return name[:90] + ".pdf" + + +def is_converted(raw_or_msg) -> bool: + msg = raw_or_msg if isinstance(raw_or_msg, Message) else email.message_from_bytes(raw_or_msg) + return msg.get(CONVERTED_HEADER) is not None + + +def build_converted_message(raw: bytes, pdf: bytes) -> tuple[bytes, str]: + """Neue Mail: gleiche Kopfzeilen, gleicher sichtbarer Inhalt, plus PDF und + das unveränderte Original als .eml. Liefert (bytes, neue Message-ID).""" + original = email.message_from_bytes(raw) + + new = MIMEMultipart("mixed") + for key in ("From", "Sender", "Reply-To", "To", "Cc", "Date", "Subject"): + for value in original.get_all(key, []): + new[key] = value + new_id = make_msgid(domain="mailfilter.local") + new["Message-ID"] = new_id + new[CONVERTED_HEADER] = "pdf" + if original.get("Message-ID"): + new["X-Mailfilter-Original-Message-ID"] = original["Message-ID"] + + # Sichtbarer Inhalt wie im Original: dessen Body-Struktur ohne Kopfzeilen + body = email.message_from_bytes(raw) + for key in list(body.keys()): + if not key.lower().startswith("content-"): + del body[key] + if not body.get("Content-Type"): + body["Content-Type"] = "text/plain; charset=utf-8" + new.attach(body) + + pdf_part = MIMEApplication(pdf, _subtype="pdf") + pdf_part.add_header("Content-Disposition", "attachment", filename=("utf-8", "", _pdf_filename(original))) + new.attach(pdf_part) + + # Original Byte für Byte (base64 ist verlustfrei) + eml_part = MIMEApplication(raw, _subtype="octet-stream") + eml_part.add_header("Content-Disposition", "attachment", filename="Original.eml") + new.attach(eml_part) + + buf = BytesIO() + BytesGenerator(buf, policy=policy.compat32.clone(linesep="\r\n"), mangle_from_=False).flatten(new) + return buf.getvalue(), new_id diff --git a/app/services/rule_tester.py b/app/services/rule_tester.py index e6ec160..1b47001 100644 --- a/app/services/rule_tester.py +++ b/app/services/rule_tester.py @@ -60,6 +60,37 @@ def _build_search(conditions) -> list[str]: return criteria +def preview_pdf(account: Account, source_folder: str, conditions, limit: int = 40) -> tuple[bytes, dict]: + """PDF der neuesten Mail, auf die die Regel zutrifft — read-only, nichts + wird verändert. Liefert (pdf, info).""" + from app.services.mail_pdf import render_pdf + + criteria = _build_search(conditions) + client = IMAPClient( + host=account.imap_host, + port=account.imap_port, + username=account.username, + password=decrypt(account.password), + use_ssl=account.use_ssl, + ) + with client: + if not client.select_folder(source_folder, readonly=True): + raise ValueError(f"Ordner '{source_folder}' konnte nicht geöffnet werden") + status, data = client.conn.uid("SEARCH", None, *criteria) + uids = data[0].split() if status == "OK" and data and data[0] else [] + for raw_uid in sorted(uids, key=int, reverse=True)[:max(1, min(limit, 100))]: + uid = raw_uid.decode() if isinstance(raw_uid, bytes) else str(raw_uid) + mail = client.fetch_mail(uid) + if not mail or not evaluate_conditions(mail, conditions)[0]: + continue + fetched = client.fetch_raw(uid) + if not fetched: + continue + pdf, stats = render_pdf(fetched[0]) + return pdf, {"uid": uid, "subject": mail.subject, **stats} + raise LookupError("Keine Mail gefunden, auf die die Regel zutrifft") + + def test_rule(account: Account, source_folder: str, conditions, limit: int = 40) -> dict: limit = max(1, min(limit, 100)) criteria = _build_search(conditions) diff --git a/app/templates/filters.html b/app/templates/filters.html index f02f17b..212e94d 100644 --- a/app/templates/filters.html +++ b/app/templates/filters.html @@ -82,7 +82,10 @@

Regel testen

- + + + +

Prüft die aktuelle (ungespeicherte) Regel nur lesend gegen die neuesten passenden Mails im Quellordner. Es wird nichts verschoben.

@@ -251,6 +254,7 @@ const DATE_MATCH_TYPES = [ const ALL_MATCH_TYPES = [...TEXT_MATCH_TYPES, ...DATE_MATCH_TYPES]; const ACTION_TYPES = [ {value: 'move', label: 'Verschieben nach', needsParam: true, needsFolder: true, paramLabel: 'Zielordner'}, + {value: 'pdf', label: 'Als PDF anhängen + nach', needsParam: true, needsFolder: true, paramLabel: 'Zielordner'}, {value: 'forward', label: 'Weiterleiten an', needsParam: true, needsFolder: false, paramLabel: 'E-Mail-Adresse'}, {value: 'delete', label: 'Löschen (in Papierkorb)', needsParam: false, needsFolder: false}, {value: 'mark_read', label: 'Als gelesen markieren', needsParam: false, needsFolder: false}, @@ -324,8 +328,15 @@ function ruleWarnings(r) { } for (const a of acts) { if (a.action_type === 'move' && !a.parameter) w.push('Verschieben ohne Zielordner.'); + if (a.action_type === 'pdf' && !a.parameter) w.push('„Als PDF anhängen“ ohne Zielordner.'); if (a.action_type === 'forward' && !a.parameter) w.push('Weiterleiten ohne Zieladresse.'); - if (a.action_type === 'move' && a.parameter && a.parameter === r.source_folder) w.push('Zielordner ist gleich dem Quellordner.'); + if ((a.action_type === 'move' || a.action_type === 'pdf') && a.parameter && a.parameter === r.source_folder) w.push('Zielordner ist gleich dem Quellordner.'); + } + if (acts.some(a => a.action_type === 'pdf')) { + if (acts.some(a => a.action_type === 'move' || a.action_type === 'delete')) + w.push('„Als PDF anhängen“ legt die Mail bereits im Zielordner ab — ein zusätzliches Verschieben/Löschen ist nicht nötig und würde scheitern.'); + if (conds.some(c => c.field === 'has_attachment' && c.value === 'true' && !c.negate)) + w.push('„Hat Anhang = Ja“ passt nicht zu „Als PDF anhängen“: die PDF-Aktion ist für Mails gedacht, bei denen die Nachricht selbst der Beleg ist.'); } return w; } @@ -407,6 +418,7 @@ function condChip(c) { function targetText(f) { return (f.actions || []).map(a => { if (a.action_type === 'move') return '📁 ' + (a.parameter || '?').replace(/^INBOX[./]/, ''); + if (a.action_type === 'pdf') return '📄 ' + (a.parameter || '?').replace(/^INBOX[./]/, ''); if (a.action_type === 'forward') return '↪ ' + (a.parameter || '?'); if (a.action_type === 'delete') return '🗑 Papierkorb'; if (a.action_type === 'mark_read') return '✓ gelesen'; @@ -694,6 +706,7 @@ function collectFormData() { function refreshWarnings() { const data = collectFormData(); + document.getElementById('pdf-preview-btn').hidden = !data.actions.some(a => a.action_type === 'pdf'); const w = ruleWarnings(data); // Erkenntnisse aus dem letzten Test ergänzen if (lastTest) { @@ -738,6 +751,44 @@ async function saveFilter() { // ---------- Test ---------- +function hasPdfAction() { + return collectActions().some(a => a.action_type === 'pdf'); +} + +async function runPdfPreview() { + const data = collectFormData(); + const out = document.getElementById('test-result'); + const btn = document.getElementById('pdf-preview-btn'); + if (!data.conditions.length) { out.innerHTML = '

Erst mindestens eine Bedingung anlegen.

'; return; } + // Fenster sofort öffnen, sonst blockt der Browser es als Popup + const win = window.open('', '_blank'); + btn.setAttribute('aria-busy', 'true'); + btn.disabled = true; + out.innerHTML = '

PDF wird erzeugt (Bilder werden geladen)…

'; + try { + const resp = await fetch('/api/filters/pdf-preview', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({account_id: data.account_id, source_folder: data.source_folder, conditions: data.conditions, limit: 40}), + }); + if (!resp.ok) { + const d = await readJson(resp); + if (win) win.close(); + out.innerHTML = `

${esc(typeof d.detail === 'string' ? d.detail : JSON.stringify(d.detail))}

`; + return; + } + const url = URL.createObjectURL(await resp.blob()); + if (win) win.location = url; else window.open(url, '_blank'); + out.innerHTML = `

PDF-Vorschau geöffnet · Bilder: ${esc(resp.headers.get('X-Preview-Images') || '')}

`; + } catch (e) { + if (win) win.close(); + out.innerHTML = `

Netzwerk-Fehler: ${esc(e.message)}

`; + } finally { + btn.removeAttribute('aria-busy'); + btn.disabled = false; + } +} + async function runTest() { const data = collectFormData(); const out = document.getElementById('test-result'); diff --git a/app/templates/logs.html b/app/templates/logs.html index be15cc8..b2fe34b 100644 --- a/app/templates/logs.html +++ b/app/templates/logs.html @@ -339,6 +339,7 @@ function emailOf(from) { function targetOf(log) { // Bei Aktionen steht der Zielordner in details, sonst Quellordner if (log.action_type === 'move' && log.details) return {text: '📁 ' + log.details.replace(/^INBOX[./]/, ''), filter: log.details}; + if (log.action_type === 'pdf' && log.details) return {text: '📄 ' + log.details.replace(/^INBOX[./]/, ''), filter: log.details}; if (log.action_type === 'forward' && log.details) return {text: '↪ ' + log.details, filter: null}; if (log.action_type === 'delete') return {text: '🗑 Papierkorb', filter: null}; if (log.folder) return {text: log.folder, filter: log.folder}; @@ -348,7 +349,7 @@ function targetOf(log) { function shortMessage(log) { const m = log.message || ''; if (log.action_type && m.startsWith('Aktion ausgeführt')) { - return {move: 'Verschoben', forward: 'Weitergeleitet', delete: 'Gelöscht', mark_read: 'Als gelesen markiert'}[log.action_type] || m; + return {move: 'Verschoben', pdf: 'Als PDF angehängt & abgelegt', forward: 'Weitergeleitet', delete: 'Gelöscht', mark_read: 'Als gelesen markiert'}[log.action_type] || m; } return m; } diff --git a/requirements.txt b/requirements.txt index 92f60a4..edaca9b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ apscheduler>=3.10 pyyaml>=6.0 cryptography>=42.0 alembic>=1.13 +weasyprint>=70,<71