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 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 15:27:52 +02:00
parent a2ddb6b94d
commit aff166f89d
2 changed files with 123 additions and 15 deletions

137
app.py
View File

@@ -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"""<html><body style="font-family:sans-serif;max-width:800px;margin:auto;padding:20px">
{body_html}
</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:

View File

@@ -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