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
+7
View File
@@ -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
+1
View File
@@ -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".
+3
View File
@@ -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"}
+1
View File
@@ -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):
+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
+1 -1
View File
@@ -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
+8
View File
@@ -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:
+67
View File
@@ -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,
+283
View File
@@ -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'<tr><td class="k">{label}:</td><td>{html.escape(value)}</td></tr>')
subject = html.escape(_decode_header_value(msg.get("Subject")) or "(kein Betreff)")
return f'<div class="mf-head"><div class="subj">{subject}</div><table>{"".join(rows)}</table></div>'
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"<script\b.*?</script>", "", body, flags=re.I | re.S)
if re.search(r"<body\b[^>]*>", body, flags=re.I):
document = re.sub(r"(<body\b[^>]*>)", lambda m: m.group(1) + head, body, count=1, flags=re.I)
else:
document = f"<html><head><meta charset='utf-8'></head><body>{head}{body}</body></html>"
else:
stats = {"loaded": 0, "skipped": 0, "cid": 0}
text = html.escape(text_body or "")
document = f"<html><head><meta charset='utf-8'></head><body>{head}<pre class='mf-text'>{text}</pre></body></html>"
# 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
+31
View File
@@ -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)
+53 -2
View File
@@ -82,7 +82,10 @@
<section class="dlg-section test-section">
<div class="test-head">
<h4>Regel testen</h4>
<button type="button" class="outline small" id="test-btn" onclick="runTest()">Gegen Postfach testen</button>
<span>
<button type="button" class="outline small" id="pdf-preview-btn" onclick="runPdfPreview()" hidden title="PDF der neuesten passenden Mail erzeugen und anzeigen — es wird nichts verändert">PDF-Vorschau</button>
<button type="button" class="outline small" id="test-btn" onclick="runTest()">Gegen Postfach testen</button>
</span>
</div>
<p class="muted small-text">Prüft die aktuelle (ungespeicherte) Regel nur lesend gegen die neuesten passenden Mails im Quellordner. Es wird nichts verschoben.</p>
<div id="test-result"></div>
@@ -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 = '<p class="muted">Erst mindestens eine Bedingung anlegen.</p>'; 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 = '<p class="muted small-text">PDF wird erzeugt (Bilder werden geladen)…</p>';
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 = `<p class="reason">${esc(typeof d.detail === 'string' ? d.detail : JSON.stringify(d.detail))}</p>`;
return;
}
const url = URL.createObjectURL(await resp.blob());
if (win) win.location = url; else window.open(url, '_blank');
out.innerHTML = `<p class="muted small-text">PDF-Vorschau geöffnet · Bilder: ${esc(resp.headers.get('X-Preview-Images') || '')}</p>`;
} catch (e) {
if (win) win.close();
out.innerHTML = `<p class="reason">Netzwerk-Fehler: ${esc(e.message)}</p>`;
} finally {
btn.removeAttribute('aria-busy');
btn.disabled = false;
}
}
async function runTest() {
const data = collectFormData();
const out = document.getElementById('test-result');
+2 -1
View File
@@ -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;
}
+1
View File
@@ -9,3 +9,4 @@ apscheduler>=3.10
pyyaml>=6.0
cryptography>=42.0
alembic>=1.13
weasyprint>=70,<71