new action: attach the message as PDF and file it

For senders whose mail body is the receipt (e.g. GoCardless payouts)
there is no attachment to file. The new action "Als PDF anhängen +
nach <Ordner>" renders the mail like a print from a mail client
(header block From/To/Date/Subject, then the content) and replaces it
in the target folder with: original content + PDF + the original mail
byte-for-byte as Original.eml, so the unaltered original is retained
(GoBD).

- Rendering with WeasyPrint. Images are fetched by us and inlined as
  data: URIs; WeasyPrint itself may only read data:. Fetching is
  restricted to http(s), public addresses (private, loopback,
  link-local and PDF_BLOCKED_NETWORKS, default 172.0.0.0/12, are
  refused; redirects are checked hop by hop), with time and size
  limits. cid: images come from the MIME parts.
- The replacement is APPENDed with the original flags and INTERNALDATE,
  verified in the target via its new Message-ID, and only then is the
  original deleted. Already converted mails are only moved.
- "PDF-Vorschau" in the rule dialog renders the newest matching mail
  read-only and opens it in the browser.
- List/log show 📄 targets; warnings for PDF + move/delete and for
  PDF + "Hat Anhang = Ja"; log folder search covers PDF targets.
- Docker image gets Pango and fonts (~316 MB total).

Tested with the real GoCardless mail (3 remote images, rendered both
locally and inside the built image) and a simulated IMAP server for
success, rejected APPEND, missing copy and already converted mails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 19:51:26 +02:00
co-authored by Claude Opus 5.5
parent a622db3205
commit d5cb0a83f3
13 changed files with 486 additions and 5 deletions
+28 -1
View File
@@ -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