feat: auto-send invitation email with PDF onboarding guide

When an admin creates an invitation with an email address, an email is
now automatically sent containing the registration link and an attached
PDF guide (3 pages: registration steps, feature overview, contact info).

- Add fpdf2 for PDF generation with Unicode font support
- Add PDF guide generator (backend/app/services/pdf_guide.py)
- Extend send_email() to support file attachments
- Fire-and-forget email in create_invitation endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-03-02 11:05:19 +00:00
parent 83bc1f5865
commit 3afe03b0a9
8 changed files with 340 additions and 5 deletions

View file

@ -1,5 +1,6 @@
"""Admin-only API endpoints: user CRUD, invitation management, audit log.""" """Admin-only API endpoints: user CRUD, invitation management, audit log."""
import logging
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
@ -17,13 +18,77 @@ from app.schemas.disclosure import (
DisclosureCountResponse, DisclosureRequestResponse, DisclosureRequestUpdate, DisclosureCountResponse, DisclosureRequestResponse, DisclosureRequestUpdate,
) )
from app.schemas.user import UserCreate, UserResponse, UserUpdate from app.schemas.user import UserCreate, UserResponse, UserUpdate
from app.config import get_settings
from app.services.audit_service import log_action from app.services.audit_service import log_action
from app.services.disclosure_service import ( from app.services.disclosure_service import (
admin_delete_disclosure, get_pending_count, reactivate_disclosure, admin_delete_disclosure, get_pending_count, reactivate_disclosure,
review_disclosure_request, revoke_disclosure, review_disclosure_request, revoke_disclosure,
) )
from app.services.notification_service import send_email
from app.services.pdf_guide import generate_guide_pdf
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
_settings = get_settings()
# ---------------------------------------------------------------------------
# Invitation email helper
# ---------------------------------------------------------------------------
def _send_invitation_email(
email: str, token: str, expires_at: datetime
) -> None:
"""Send an invitation email with a PDF onboarding guide attached."""
try:
invite_url = f"{_settings.FRONTEND_BASE_URL}/register?token={token}"
pdf_bytes = generate_guide_pdf(invite_url)
expires_str = expires_at.strftime("%d.%m.%Y")
plain_body = (
"Guten Tag,\n\n"
"Sie wurden zum DAK Zweitmeinungs-Portal eingeladen.\n\n"
"Bitte registrieren Sie sich unter folgendem Link:\n"
f"{invite_url}\n\n"
f"Der Link ist gueltig bis zum {expires_str}.\n\n"
"Im Anhang finden Sie eine Anleitung (PDF) mit allen "
"wichtigen Informationen zur Registrierung und Nutzung "
"des Portals.\n\n"
"Mit freundlichen Gruessen\n"
"Complex Care Solutions GmbH"
)
html_body = (
"<div style='font-family: Arial, sans-serif; max-width: 600px;'>"
"<h2 style='color: #005293;'>DAK Zweitmeinungs-Portal</h2>"
"<p>Guten Tag,</p>"
"<p>Sie wurden zum <strong>DAK Zweitmeinungs-Portal</strong> eingeladen.</p>"
"<p>Bitte registrieren Sie sich unter folgendem Link:</p>"
f"<p><a href='{invite_url}' style='color: #005293; "
f"font-weight: bold;'>{invite_url}</a></p>"
f"<p>Der Link ist g&uuml;ltig bis zum <strong>{expires_str}</strong>.</p>"
"<p>Im Anhang finden Sie eine <strong>Anleitung (PDF)</strong> mit allen "
"wichtigen Informationen zur Registrierung und Nutzung des Portals.</p>"
"<hr style='border: none; border-top: 1px solid #dee2e6; margin: 20px 0;'>"
"<p style='color: #6c757d; font-size: 12px;'>"
"Complex Care Solutions GmbH<br>"
"https://dak.complexcaresolutions.de</p>"
"</div>"
)
send_email(
to=email,
subject="[DAK Portal] Ihre Einladung zum Zweitmeinungs-Portal",
body=plain_body,
html_body=html_body,
attachments=[
("DAK-Portal-Anleitung.pdf", pdf_bytes, "application/pdf")
],
)
except Exception:
logger.exception("Failed to send invitation email to %s", email)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -250,6 +315,10 @@ def create_invitation(
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
# Send invitation email with PDF guide (fire-and-forget)
if payload.email:
_send_invitation_email(payload.email, token, expires_at)
return invitation return invitation

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -5,8 +5,10 @@ from __future__ import annotations
import logging import logging
import smtplib import smtplib
from datetime import datetime, timezone from datetime import datetime, timezone
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email import encoders
from typing import Optional from typing import Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -28,21 +30,45 @@ def send_email(
subject: str, subject: str,
body: str, body: str,
html_body: str | None = None, html_body: str | None = None,
attachments: list[tuple[str, bytes, str]] | None = None,
) -> bool: ) -> bool:
"""Send an email via SMTP SSL. """Send an email via SMTP SSL.
Parameters
----------
attachments:
Optional list of ``(filename, data, mime_type)`` tuples.
Returns *True* on success, *False* on failure (the error is logged). Returns *True* on success, *False* on failure (the error is logged).
""" """
try: try:
msg = MIMEMultipart("alternative") if attachments:
msg["From"] = settings.SMTP_FROM msg = MIMEMultipart("mixed")
msg["To"] = to text_part = MIMEMultipart("alternative")
msg["Subject"] = subject text_part.attach(MIMEText(body, "plain"))
if html_body:
text_part.attach(MIMEText(html_body, "html"))
msg.attach(text_part)
for filename, data, mime_type in attachments:
maintype, subtype = mime_type.split("/", 1)
part = MIMEBase(maintype, subtype)
part.set_payload(data)
encoders.encode_base64(part)
part.add_header(
"Content-Disposition", "attachment", filename=filename
)
msg.attach(part)
else:
msg = MIMEMultipart("alternative")
msg.attach(MIMEText(body, "plain")) msg.attach(MIMEText(body, "plain"))
if html_body: if html_body:
msg.attach(MIMEText(html_body, "html")) msg.attach(MIMEText(html_body, "html"))
msg["From"] = settings.SMTP_FROM
msg["To"] = to
msg["Subject"] = subject
with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT) as server: with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT) as server:
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD) server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
server.send_message(msg) server.send_message(msg)

View file

@ -0,0 +1,239 @@
"""PDF guide generator for DAK onboarding invitations."""
from __future__ import annotations
from pathlib import Path
from fpdf import FPDF
ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
LOGO_PATH = ASSETS_DIR / "dak_logo.png"
FONT_DIR = ASSETS_DIR
# Font family name used throughout the PDF
_FONT = "LiberationSans"
# Colours (DAK blue tones)
DAK_BLUE = (0, 82, 147)
DAK_DARK = (33, 37, 41)
DAK_GREY = (108, 117, 125)
DAK_LIGHT_BG = (240, 244, 248)
class _GuidePDF(FPDF):
"""Custom FPDF subclass with shared header/footer styling."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
# Register Liberation Sans (Unicode TTF) in Regular, Bold, Italic
self.add_font(_FONT, "", str(FONT_DIR / "LiberationSans-Regular.ttf"))
self.add_font(_FONT, "B", str(FONT_DIR / "LiberationSans-Bold.ttf"))
self.add_font(_FONT, "I", str(FONT_DIR / "LiberationSans-Italic.ttf"))
def header(self):
if self.page_no() == 1:
return # Page 1 has its own header with logo
self.set_font(_FONT, "I", 8)
self.set_text_color(*DAK_GREY)
self.cell(0, 8, "DAK Zweitmeinungs-Portal \u2014 Anleitung", align="R")
self.ln(12)
def footer(self):
self.set_y(-15)
self.set_font(_FONT, "I", 8)
self.set_text_color(*DAK_GREY)
self.cell(0, 10, f"Seite {self.page_no()}/{{nb}}", align="C")
# -- helpers ---------------------------------------------------------------
def section_title(self, text: str) -> None:
self.set_font(_FONT, "B", 14)
self.set_text_color(*DAK_BLUE)
self.cell(0, 10, text)
self.ln(8)
# blue underline
self.set_draw_color(*DAK_BLUE)
self.set_line_width(0.5)
self.line(self.l_margin, self.get_y(), self.w - self.r_margin, self.get_y())
self.ln(6)
def body_text(self, text: str) -> None:
self.set_font(_FONT, "", 10)
self.set_text_color(*DAK_DARK)
self.multi_cell(0, 6, text)
self.ln(2)
def numbered_step(self, number: int, text: str) -> None:
self.set_font(_FONT, "B", 10)
self.set_text_color(*DAK_BLUE)
self.cell(8, 6, f"{number}.")
self.set_font(_FONT, "", 10)
self.set_text_color(*DAK_DARK)
self.multi_cell(0, 6, text)
self.ln(1)
def bullet_item(self, title: str, description: str) -> None:
self.set_font(_FONT, "B", 10)
self.set_text_color(*DAK_DARK)
self.cell(4, 6, "\u2022 ")
self.cell(0, 6, title)
self.ln(6)
if description:
self.set_font(_FONT, "", 9)
self.set_text_color(*DAK_GREY)
self.set_x(self.l_margin + 6)
self.multi_cell(0, 5, description)
self.ln(2)
def info_box(self, text: str) -> None:
self.set_fill_color(*DAK_LIGHT_BG)
self.set_draw_color(*DAK_BLUE)
x = self.l_margin
y = self.get_y()
w = self.w - self.l_margin - self.r_margin
self.set_font(_FONT, "", 9)
self.set_text_color(*DAK_DARK)
# calculate height
self.set_xy(x + 4, y + 4)
self.multi_cell(w - 8, 5, text)
h = self.get_y() - y + 4
# draw box behind
self.rect(x, y, w, h, style="DF")
# re-render text on top
self.set_xy(x + 4, y + 4)
self.multi_cell(w - 8, 5, text)
self.ln(4)
def generate_guide_pdf(invite_url: str) -> bytes:
"""Generate the DAK onboarding guide as PDF bytes.
Parameters
----------
invite_url:
The personalised invitation/registration URL to embed in the document.
Returns
-------
bytes
The raw PDF content (no disk I/O).
"""
pdf = _GuidePDF(orientation="P", unit="mm", format="A4")
pdf.alias_nb_pages()
pdf.set_auto_page_break(auto=True, margin=20)
pdf.set_left_margin(20)
pdf.set_right_margin(20)
# ── Page 1: Welcome & Registration ────────────────────────────────────
pdf.add_page()
# Logo
if LOGO_PATH.exists():
pdf.image(str(LOGO_PATH), x=75, y=15, w=60)
pdf.ln(50)
else:
pdf.ln(15)
# Title
pdf.set_font(_FONT, "B", 20)
pdf.set_text_color(*DAK_BLUE)
pdf.cell(0, 12, "Willkommen im", align="C")
pdf.ln(12)
pdf.cell(0, 12, "DAK Zweitmeinungs-Portal", align="C")
pdf.ln(20)
# Registration section
pdf.section_title("Konto erstellen")
pdf.body_text(
"Um Ihr Konto zu erstellen, folgen Sie bitte diesen Schritten:"
)
pdf.numbered_step(1, "Registrierungslink im Browser öffnen (siehe unten)")
pdf.numbered_step(2, "Benutzername festlegen (mindestens 3 Zeichen)")
pdf.numbered_step(3, "E-Mail-Adresse eingeben")
pdf.numbered_step(4, "Passwort setzen (mindestens 8 Zeichen)")
pdf.numbered_step(5, "\u201eRegistrieren\u201c anklicken")
pdf.ln(2)
# Invite URL box
pdf.set_font(_FONT, "B", 10)
pdf.set_text_color(*DAK_BLUE)
pdf.cell(0, 6, "Ihr Registrierungslink:")
pdf.ln(7)
pdf.info_box(invite_url)
# First login
pdf.section_title("Erste Anmeldung")
pdf.body_text(
"Melden Sie sich mit Ihrem Benutzernamen (oder E-Mail-Adresse) "
"und dem gewählten Passwort an."
)
pdf.body_text(
"Empfehlung: Aktivieren Sie die Zwei-Faktor-Authentifizierung "
"unter Kontoverwaltung für zusätzliche Sicherheit."
)
# ── Page 2: Feature overview ──────────────────────────────────────────
pdf.add_page()
pdf.section_title("Funktionsübersicht")
pdf.body_text(
"Das DAK Zweitmeinungs-Portal bietet Ihnen folgende Funktionen:"
)
pdf.ln(2)
features = [
("Dashboard", "Übersicht über KPIs, Diagramme und Jahresvergleich."),
("Fälle", "Fallübersicht mit Such- und Filterfunktionen."),
("ICD-Eingabe", "ICD-Codes für Fälle erfassen und bearbeiten."),
("Berichte", "Wochenberichte als Excel-Datei herunterladen."),
(
"Wochenübersicht",
"Datenübersicht pro Kalenderwoche und ICD-Upload per Excel.",
),
(
"Freigaben",
"Personenbezogene Daten bei Bedarf anfordern (24 Stunden Gültigkeit).",
),
(
"Kontoverwaltung",
"Profil bearbeiten, Passwort ändern, Zwei-Faktor-Authentifizierung.",
),
]
for title, desc in features:
pdf.bullet_item(title, desc)
# ── Page 3: Closing notes ─────────────────────────────────────────────
pdf.add_page()
pdf.section_title("Weitere Informationen")
pdf.body_text(
"Ausführliche Anleitungen zu allen Funktionen finden Sie direkt "
"im Portal unter dem Menüpunkt \u201eAnleitung\u201c."
)
pdf.ln(4)
pdf.body_text(
"Bei Fragen oder Problemen wenden Sie sich bitte an Ihren "
"Ansprechpartner bei Complex Care Solutions:"
)
pdf.ln(2)
pdf.set_font(_FONT, "B", 10)
pdf.set_text_color(*DAK_DARK)
pdf.cell(0, 6, "Complex Care Solutions GmbH")
pdf.ln(6)
pdf.set_font(_FONT, "", 10)
pdf.cell(0, 6, "E-Mail: info@complexcaresolutions.de")
pdf.ln(6)
pdf.cell(0, 6, "Portal: https://dak.complexcaresolutions.de")
pdf.ln(16)
# Final info box
pdf.info_box(
"Dieses Dokument wurde automatisch generiert und enthält "
"Ihren persönlichen Registrierungslink. "
"Bitte geben Sie diesen Link nicht an Dritte weiter."
)
return pdf.output()

View file

@ -16,6 +16,7 @@ pydantic==2.10.4
pydantic-settings==2.7.1 pydantic-settings==2.7.1
python-dotenv==1.0.1 python-dotenv==1.0.1
email-validator==2.2.0 email-validator==2.2.0
fpdf2==2.8.3
httpx==0.28.1 httpx==0.28.1
# Dev/Test # Dev/Test