d0e3621192
Mail Render V2 (compliance/services/mail_render_v2/) — 11-Modul-Subpackage
das einen einheitlichen Audit-Mail-Output erzeugt mit:
- Header + KPI-Kacheln (Score / Findings / Docs / Vendors)
- TOC + Sprung-Links
- 3-Bucket-Trennung: Kritische Befunde / Manuelle Prüfung / Interne Reminder
- Cookie-Inventar (Name·Vendor·Kategorie·Speicherdauer·Löschfrist·Sitzland·Quelle·Status)
- Sofortmaßnahmen-Aggregator ("Sitzland ergänzen für 11 Cookies")
- 24 Legacy-Wrappers — alle alten build_*_html in V2-Sections
- Scope-Filter: FIN/GOV/MED/INS/EDU/LEG aus Berichten wenn nicht relevant
- Hint/Action-Dedup: keine doppelten Sätze pro Card mehr
Aktiviert via env MAIL_RENDER_V2=true (Default: legacy renderer).
5 neue deterministische Findings als Phase D-2b/B4/B5/B6/B7/B8:
B4 vendor_consistency_check — Cross-Doc-Provider-Widerspruch
(Elli: DSE nennt Vertex AI für Chatbot, /de/cookies nennt Iadvize → HIGH).
6 Service-Types: chatbot/analytics/tag_manager/pixel/cdn/cmp.
B5 ai_act_transparency_check — AI Act Art. 50 Transparenzpflicht
(Elli: Vertex AI vorhanden ohne Pre-Chat-Disclosure → HIGH).
Plus B5-Erweiterung: Rechtsgrundlage Art-6-Abs-1-lit-f bei AI → MED
(Einwilligung empfehlen).
B6 cross_doc_dpo_check — DPO in DSE genannt, nicht im Impressum (LOW).
B7 doc_staleness_check — Datum-Extraktion aus DSE/AGB/Nutzungsbedingungen.
Cap: AGB/NB 3y, DSE 2y. Älter → MEDIUM (Elli NB Stand 2018 → HIGH).
B8 cmp_fingerprint_check — Banner detected, aber CMP-Provider generic
(kein Usercentrics/OneTrust/Cookiebot/etc → MED).
B3-Erweiterung detect_intra_doc_contradictions — Widersprüchliche
Speicherdauer im SELBEN Doc (Elli: Logfile 7d vs 30d → HIGH).
LLM-Plausibility-Phase (Phase D-2b, finding_plausibility_check.py):
- Läuft AFTER MC pipeline, BEFORE D3 render
- Prompt mit Beispiel-IDs + 3-Phase-Mapping: exact-ID / position-fallback /
fuzzy-tail-match
- Stempelt llm_title / llm_severity / llm_recommendation / llm_drop auf
jeden FAIL CheckItem
- V2-Render zeigt "🤖 LLM-Plausibility:" Box pro Finding wenn gestempelt
- KNOWN ISSUE: qwen3:30b-a3b liefert oft empty content auf format='json' +
8000-char-excerpt prompts. Pipeline läuft mit stamped=0 weiter. Task #16.
Coverage gegen Elli Ground Truth (zeroclaw/docs/ground-truth/elli_eco_2026-06-06.json,
13 expected findings via WebFetch-Agent-Crawl):
- 4/4 HIGH-Findings ✓ (COOKIE-CONSENT-UX-001 + WIDERRUFSBELEHRUNG-001 +
VENDOR-CONSISTENCY-001 + AI-ACT-TRANSPARENCY-001)
- 4/6 MEDIUM ✓
- 2/3 LOW ✓
- Total: 10/13 = 77% (Sprung von 4/13 = 31%)
Restliche 3 Gaps als Task #17: IMPRESSUM-001 (multi-entity USt-IdNr),
TRANSFER-001 (Vendor-Mechanismus DPF/SCC), TH-RETENTION-002 (AI-Retention
pro Datenkategorie).
V2-Mail-Preview in Mailpit: 'v2all@local.test' Subject '[V2 ALL] ELLI'.
Backend healthy, B1+B3+B4+B5+B6+B7+B8 alle live im Orchestrator.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
201 lines
7.6 KiB
Python
201 lines
7.6 KiB
Python
"""Mail-V2 style system — single source of truth for all visual props.
|
|
|
|
Email rendering = inline styles only (most clients strip <style> tags
|
|
or sandbox them). Table-based layouts because flex/grid is unreliable
|
|
in Outlook. Font stack = email-safe (no web fonts).
|
|
|
|
Public helpers:
|
|
- `section(title, body_html, *, sev=None, anchor=None)` →
|
|
standardized full-width card with optional severity stripe + TOC
|
|
anchor
|
|
- `card(body_html, *, sev=None)` → smaller card inside a section
|
|
- `kpi(label, value, sub=None, sev=None)` → single KPI tile (used
|
|
in 4-column header grid)
|
|
- `kpi_row(items)` → evenly-sized row of KPIs
|
|
- `chip(text, sev)` → inline pill for severity / status
|
|
- `table(headers, rows, *, sev_col=None)` → consistent zebra table
|
|
|
|
`sev` is one of: "pass" | "fail" | "warn" | "info" | None (neutral)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
# ── Color palette ─────────────────────────────────────────────────
|
|
PAGE_BG = "#f8fafc"
|
|
CARD_BG = "#ffffff"
|
|
BORDER = "#e2e8f0"
|
|
TEXT = "#1e293b"
|
|
TEXT_MUTED = "#64748b"
|
|
HEADER_BG = "#0f172a"
|
|
HEADER_FG = "#f8fafc"
|
|
|
|
SEV = {
|
|
"pass": {"bg": "#dcfce7", "fg": "#15803d", "stripe": "#16a34a"},
|
|
"fail": {"bg": "#fee2e2", "fg": "#991b1b", "stripe": "#dc2626"},
|
|
"warn": {"bg": "#fef3c7", "fg": "#92400e", "stripe": "#f59e0b"},
|
|
"info": {"bg": "#dbeafe", "fg": "#1e40af", "stripe": "#3b82f6"},
|
|
}
|
|
NEUTRAL_STRIPE = "#cbd5e1"
|
|
|
|
# ── Typography ────────────────────────────────────────────────────
|
|
FONT = ("-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,"
|
|
"Ubuntu,sans-serif")
|
|
SZ_TITLE = "24px"
|
|
SZ_H2 = "18px"
|
|
SZ_H3 = "15px"
|
|
SZ_BODY = "14px"
|
|
SZ_SMALL = "12px"
|
|
|
|
# ── Layout ────────────────────────────────────────────────────────
|
|
MAX_W = "720px"
|
|
PAD_SECTION = "20px"
|
|
PAD_CARD = "14px"
|
|
RADIUS = "6px"
|
|
|
|
|
|
def _stripe(sev: str | None) -> str:
|
|
return SEV[sev]["stripe"] if sev in SEV else NEUTRAL_STRIPE
|
|
|
|
|
|
def section(title: str, body_html: str, *,
|
|
sev: str | None = None, anchor: str | None = None) -> str:
|
|
"""Top-level audit card — every report section uses this shell."""
|
|
stripe = _stripe(sev)
|
|
a = f'<a id="{anchor}"></a>' if anchor else ""
|
|
return (
|
|
f'{a}<table role="presentation" cellpadding="0" cellspacing="0" '
|
|
f'border="0" width="100%" style="margin:24px 0;border-collapse:'
|
|
f'separate;border-spacing:0;">'
|
|
f'<tr><td style="background:{CARD_BG};border:1px solid {BORDER};'
|
|
f'border-left:4px solid {stripe};border-radius:{RADIUS};'
|
|
f'padding:{PAD_SECTION};">'
|
|
f'<h2 style="margin:0 0 12px;font-family:{FONT};font-size:{SZ_H2};'
|
|
f'color:{TEXT};font-weight:600;">{title}</h2>'
|
|
f'<div style="font-family:{FONT};font-size:{SZ_BODY};color:{TEXT};'
|
|
f'line-height:1.5;">{body_html}</div>'
|
|
f'</td></tr></table>'
|
|
)
|
|
|
|
|
|
def card(body_html: str, *, sev: str | None = None) -> str:
|
|
"""Sub-card inside a section."""
|
|
stripe = _stripe(sev)
|
|
return (
|
|
f'<table role="presentation" cellpadding="0" cellspacing="0" '
|
|
f'border="0" width="100%" style="margin:8px 0;border-collapse:'
|
|
f'separate;border-spacing:0;">'
|
|
f'<tr><td style="background:{CARD_BG};border:1px solid {BORDER};'
|
|
f'border-left:3px solid {stripe};border-radius:{RADIUS};'
|
|
f'padding:{PAD_CARD};font-family:{FONT};font-size:{SZ_BODY};'
|
|
f'color:{TEXT};">'
|
|
f'{body_html}'
|
|
f'</td></tr></table>'
|
|
)
|
|
|
|
|
|
def kpi(label: str, value: str, sub: str | None = None,
|
|
sev: str | None = None) -> str:
|
|
"""One KPI tile. Used 4-in-a-row in the header."""
|
|
value_color = SEV[sev]["fg"] if sev in SEV else TEXT
|
|
sub_html = (
|
|
f'<div style="font-family:{FONT};font-size:{SZ_SMALL};'
|
|
f'color:{TEXT_MUTED};margin-top:2px;">{sub}</div>'
|
|
if sub else ""
|
|
)
|
|
return (
|
|
f'<td style="background:{CARD_BG};border:1px solid {BORDER};'
|
|
f'border-radius:{RADIUS};padding:14px;text-align:center;'
|
|
f'width:25%;vertical-align:top;">'
|
|
f'<div style="font-family:{FONT};font-size:{SZ_SMALL};'
|
|
f'color:{TEXT_MUTED};text-transform:uppercase;letter-spacing:.5px;">'
|
|
f'{label}</div>'
|
|
f'<div style="font-family:{FONT};font-size:26px;color:{value_color};'
|
|
f'font-weight:700;margin-top:6px;">{value}</div>'
|
|
f'{sub_html}'
|
|
f'</td>'
|
|
)
|
|
|
|
|
|
def kpi_row(items: list[dict]) -> str:
|
|
"""Render a row of 2-4 KPI tiles, equally sized."""
|
|
cells = "".join(
|
|
kpi(it["label"], it["value"], it.get("sub"), it.get("sev"))
|
|
for it in items
|
|
)
|
|
spacers = "".join(
|
|
'<td style="width:8px;"></td>' for _ in range(max(0, len(items) - 1))
|
|
)
|
|
# interleave
|
|
parts = items[:]
|
|
cells_list = [
|
|
kpi(it["label"], it["value"], it.get("sub"), it.get("sev"))
|
|
for it in parts
|
|
]
|
|
interleaved = '<td style="width:8px;"></td>'.join(cells_list)
|
|
return (
|
|
f'<table role="presentation" cellpadding="0" cellspacing="0" '
|
|
f'border="0" width="100%" style="margin:12px 0;border-collapse:'
|
|
f'separate;border-spacing:0;"><tr>{interleaved}</tr></table>'
|
|
)
|
|
|
|
|
|
def chip(text: str, sev: str | None = None) -> str:
|
|
"""Inline pill for severity / status."""
|
|
pal = SEV.get(sev or "", {"bg": "#f1f5f9", "fg": TEXT_MUTED})
|
|
return (
|
|
f'<span style="display:inline-block;background:{pal["bg"]};'
|
|
f'color:{pal["fg"]};font-family:{FONT};font-size:11px;font-weight:600;'
|
|
f'padding:2px 8px;border-radius:999px;'
|
|
f'text-transform:uppercase;letter-spacing:.3px;">{text}</span>'
|
|
)
|
|
|
|
|
|
def table(headers: list[str], rows: list[list[str]], *,
|
|
sev_col: int | None = None) -> str:
|
|
"""Render a consistent zebra table.
|
|
|
|
`sev_col`, when set, indicates which column already contains a
|
|
chip() (so we don't escape it).
|
|
"""
|
|
th = "".join(
|
|
f'<th style="text-align:left;padding:8px 10px;font-family:{FONT};'
|
|
f'font-size:{SZ_SMALL};color:{TEXT_MUTED};text-transform:uppercase;'
|
|
f'letter-spacing:.5px;border-bottom:1px solid {BORDER};">{h}</th>'
|
|
for h in headers
|
|
)
|
|
body_rows = []
|
|
for i, r in enumerate(rows):
|
|
bg = "#ffffff" if i % 2 == 0 else "#f8fafc"
|
|
cells = "".join(
|
|
f'<td style="padding:8px 10px;font-family:{FONT};font-size:13px;'
|
|
f'color:{TEXT};border-bottom:1px solid {BORDER};vertical-align:top;">'
|
|
f'{c}</td>' for c in r
|
|
)
|
|
body_rows.append(f'<tr style="background:{bg};">{cells}</tr>')
|
|
body = "".join(body_rows)
|
|
return (
|
|
f'<table role="presentation" cellpadding="0" cellspacing="0" '
|
|
f'border="0" width="100%" style="border-collapse:collapse;'
|
|
f'margin:8px 0;background:{CARD_BG};border:1px solid {BORDER};'
|
|
f'border-radius:{RADIUS};overflow:hidden;">'
|
|
f'<thead><tr>{th}</tr></thead><tbody>{body}</tbody></table>'
|
|
)
|
|
|
|
|
|
def page_open(site_name: str) -> str:
|
|
return (
|
|
f'<div style="background:{PAGE_BG};padding:24px 16px;font-family:{FONT};">'
|
|
f'<div style="max-width:{MAX_W};margin:0 auto;">'
|
|
)
|
|
|
|
|
|
def page_close(check_id: str, build_sha: str) -> str:
|
|
return (
|
|
f'<div style="margin-top:32px;padding:16px;font-family:{FONT};'
|
|
f'font-size:11px;color:{TEXT_MUTED};text-align:center;">'
|
|
f'BreakPilot Compliance · check_id <code>{check_id}</code> · '
|
|
f'build <code>{build_sha}</code>'
|
|
f'</div>'
|
|
f'</div></div>'
|
|
)
|