From aff166f89d40d242a77c9cd54950a0c2ba2c422f Mon Sep 17 00:00:00 2001 From: wb Date: Tue, 23 Jun 2026 15:27:52 +0200 Subject: [PATCH] feat(email): result as HTML body, intermediate steps as .md + .pdf attachments - Final pipeline result becomes the email body (plain text + HTML rendered from Markdown via multipart/alternative) - All intermediate steps (transcript + prior analyses) are attached each as a .md file and a .pdf file - _md_to_pdf(): pure-Python PDF generation via fpdf2 with minimal Markdown rendering (headers, bullets, numbered lists, inline cleanup) - _collect_doc_chain(): walks source_document_id links to gather all ancestor documents oldest-first - Add fpdf2==2.8.3 to requirements.txt Co-Authored-By: Claude Sonnet 4.6 --- app.py | 137 +++++++++++++++++++++++++++++++++++++++++------ requirements.txt | 1 + 2 files changed, 123 insertions(+), 15 deletions(-) diff --git a/app.py b/app.py index ba606e0..ed7134f 100644 --- a/app.py +++ b/app.py @@ -14,6 +14,7 @@ from datetime import datetime from pathlib import Path from typing import List, Optional +import fpdf as fpdflib import markdown as md import requests from fastapi import FastAPI, File, Form, HTTPException, UploadFile @@ -259,7 +260,91 @@ def _split_thinking(text: str) -> tuple[str, str]: return "", text -def _send_email(to_addrs: list[str], subject: str, body_md: str, attachment_name: str): +def _md_to_pdf(title: str, content_md: str) -> bytes: + """Render Markdown content to a PDF byte string.""" + class PDF(fpdflib.FPDF): + def header(self): + self.set_font("Helvetica", "B", 9) + self.set_text_color(100, 100, 100) + self.cell(0, 6, title, align="L") + self.ln(2) + self.set_draw_color(200, 200, 200) + self.line(self.l_margin, self.get_y(), self.w - self.r_margin, self.get_y()) + self.ln(4) + + def footer(self): + self.set_y(-12) + self.set_font("Helvetica", "I", 8) + self.set_text_color(160, 160, 160) + self.cell(0, 6, f"Seite {self.page_no()}", align="C") + + pdf = PDF(orientation="P", unit="mm", format="A4") + pdf.set_auto_page_break(auto=True, margin=15) + pdf.add_page() + + # Render markdown line by line with minimal formatting + for raw_line in content_md.split("\n"): + line = raw_line.rstrip() + if line.startswith("# "): + pdf.set_font("Helvetica", "B", 14) + pdf.set_text_color(20, 20, 20) + pdf.multi_cell(0, 7, line[2:]) + pdf.ln(1) + elif line.startswith("## "): + pdf.set_font("Helvetica", "B", 12) + pdf.set_text_color(40, 40, 40) + pdf.multi_cell(0, 6, line[3:]) + pdf.ln(1) + elif line.startswith("### "): + pdf.set_font("Helvetica", "B", 11) + pdf.set_text_color(60, 60, 60) + pdf.multi_cell(0, 6, line[4:]) + elif line.startswith(("- ", "* ", "+ ")): + pdf.set_font("Helvetica", "", 10) + pdf.set_text_color(30, 30, 30) + pdf.multi_cell(0, 5, " • " + line[2:]) + elif re.match(r"^\d+\. ", line): + pdf.set_font("Helvetica", "", 10) + pdf.set_text_color(30, 30, 30) + pdf.multi_cell(0, 5, " " + line) + elif line == "" or line == "---": + pdf.ln(3) + else: + # Strip inline markdown markers for bold/italic + clean = re.sub(r"\*\*(.+?)\*\*", r"\1", line) + clean = re.sub(r"\*(.+?)\*", r"\1", clean) + clean = re.sub(r"`(.+?)`", r"\1", clean) + pdf.set_font("Helvetica", "", 10) + pdf.set_text_color(30, 30, 30) + pdf.multi_cell(0, 5, clean) + + return bytes(pdf.output()) + + +def _collect_doc_chain(input_doc) -> list[dict]: + """Walk source_document_id links back to the original, return list oldest-first.""" + chain = [] + current = dict(input_doc) + chain.append(current) + while current.get("source_document_id"): + with db() as c: + row = c.execute("SELECT * FROM documents WHERE id=?", (current["source_document_id"],)).fetchone() + if not row: + break + current = dict(row) + chain.insert(0, current) + return chain + + +def _safe_fname(title: str) -> str: + return re.sub(r"[^a-zA-Z0-9äöüÄÖÜß\-_]", "_", title)[:60] + + +def _send_email(to_addrs: list[str], subject: str, body_md: str, attachments: list[tuple]): + """ + Send email with body_md as HTML/plain body and attachments as list of + (filename, bytes_content, mime_maintype, mime_subtype). + """ host = get_setting("smtp_host", "") if not host: raise RuntimeError("SMTP-Host nicht konfiguriert") @@ -269,29 +354,39 @@ def _send_email(to_addrs: list[str], subject: str, body_md: str, attachment_name from_addr = get_setting("smtp_from", user) use_tls = get_setting("smtp_tls", "true") == "true" - msg = email.mime.multipart.MIMEMultipart() - msg["From"] = from_addr - msg["To"] = ", ".join(to_addrs) - msg["Subject"] = subject - msg.attach(email.mime.text.MIMEText("Anbei das Ergebnis als Markdown-Datei.", "plain", "utf-8")) + outer = email.mime.multipart.MIMEMultipart("mixed") + outer["From"] = from_addr + outer["To"] = ", ".join(to_addrs) + outer["Subject"] = subject - part = email.mime.base.MIMEBase("text", "markdown") - part.set_payload(body_md.encode("utf-8")) - email.encoders.encode_base64(part) - part.add_header("Content-Disposition", "attachment", filename=attachment_name) - msg.attach(part) + # Multipart/alternative body (plain + HTML) + body_html = md.markdown(body_md, extensions=["fenced_code", "tables", "nl2br"]) + full_html = f""" +{body_html} +""" + alt = email.mime.multipart.MIMEMultipart("alternative") + alt.attach(email.mime.text.MIMEText(body_md, "plain", "utf-8")) + alt.attach(email.mime.text.MIMEText(full_html, "html", "utf-8")) + outer.attach(alt) + + for fname, data, maintype, subtype in attachments: + part = email.mime.base.MIMEBase(maintype, subtype) + part.set_payload(data) + email.encoders.encode_base64(part) + part.add_header("Content-Disposition", "attachment", filename=fname) + outer.attach(part) if use_tls: with smtplib.SMTP(host, port, timeout=30) as s: s.starttls() if user: s.login(user, password) - s.sendmail(from_addr, to_addrs, msg.as_string()) + s.sendmail(from_addr, to_addrs, outer.as_string()) else: with smtplib.SMTP(host, port, timeout=30) as s: if user: s.login(user, password) - s.sendmail(from_addr, to_addrs, msg.as_string()) + s.sendmail(from_addr, to_addrs, outer.as_string()) def layout(title: str, body: str) -> str: @@ -667,10 +762,22 @@ def _process_analysis_job(job_id: int): email_to=json.dumps(email_to), ) elif email_to: - safe_title = (doc["title"] or "ergebnis").replace("/", "_").replace(" ", "_") subject = f"VoiceLog: {prm['name']} – {doc['title']}" try: - _send_email(email_to, subject, answer, f"{safe_title}_{prm['name']}.md") + # Collect intermediate docs (everything that fed into the final result) + chain = _collect_doc_chain(doc) + attachments = [] + for step_doc in chain: + step_title = step_doc.get("title") or step_doc.get("kind", "dokument") + fname_base = _safe_fname(step_title) + content = step_doc.get("content_md") or "" + attachments.append((f"{fname_base}.md", content.encode("utf-8"), "text", "markdown")) + try: + pdf_bytes = _md_to_pdf(step_title, content) + attachments.append((f"{fname_base}.pdf", pdf_bytes, "application", "pdf")) + except Exception: + pass + _send_email(email_to, subject, answer, attachments) except Exception as mail_err: _job_set(job_id, error=f"[E-Mail-Fehler] {mail_err}") except Exception as e: diff --git a/requirements.txt b/requirements.txt index 96047bc..2c1c626 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ uvicorn[standard]==0.32.1 requests==2.32.3 python-multipart==0.0.12 markdown==3.7 +fpdf2==2.8.3