E-Mail-Versand von SMTP auf mailto-Weiterleitung umgestellt

Server-seitiger SMTP-Versand (Zugangsdaten, /api/email/senden, /api/email/test)
vollständig entfernt und durch mailto: ersetzt: PDF wird lokal generiert und
heruntergeladen, danach öffnet sich das E-Mail-Programm des Nutzers mit
vorausgefülltem Empfänger. Vorbereitung für die Docker-Containerisierung, da
dort nur ein Admin (der Betreiber) existiert und die Lösung ohne
rollenspezifische Ausnahmen für jedes Konto gleichermassen funktionieren muss.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-07-18 18:16:18 +02:00
1 parent af43371a5b
commit 6bfb7a07b6
3 files changed
+61 -149

No files matched your search

+12 -3
View File
@@ -31,7 +31,7 @@ Default login after first start: `admin` / `admin` (created automatically by `in
**Single-file Flask backend, vanilla-JS SPA frontend, zero build tooling.**
- `app.py` — the entire backend: all `/api/*` routes, SQLite access, auth, SMTP email sending, file upload handling. There's no blueprint/module split — everything lives in this one file.
- `app.py` — the entire backend: all `/api/*` routes, SQLite access, auth, file upload handling. There's no blueprint/module split — everything lives in this one file. There is no server-side email sending — see "Email: mailto hand-off, not SMTP" below.
- `templates/index.html` — the entire frontend. One Jinja template that is actually a client-rendered SPA: vanilla JS, string-templated HTML, no framework, no bundler. Key conventions in the `<script>` block:
- `go(name, extra)` is the client-side router; it maps a page name to a `pg*()` render function (`pgDash`, `pgPatienten`, `pgKatalog`, `pgBackend`, …).
- `S(id, html)` sets innerHTML by id; `R(html, title, actions)` sets the main content/title/toolbar in one call.
@@ -44,7 +44,7 @@ Default login after first start: `admin` / `admin` (created automatically by `in
- **Auth**: session-cookie based. `_get_current_user()` / `_check_admin()` read `session["nutzer_id"]`; `login_required` / `admin_required` decorators guard individual routes, while `global_auth_check()` (a Flask `before_request` hook) blanket-blocks any `/api/*` path not in the small public allowlist (`/api/auth/login`, `/api/auth/me`, `/api/einstellungen/public`). Two roles exist: `admin` and `nutzer`. Passwords are hashed with `werkzeug.security` (PBKDF2).
- **Patient isolation**: `patienten.nutzer_id` (FK to `nutzer.id`) records who owns each patient — set automatically to the creating user on `POST /api/patienten`. Two helpers in `app.py` enforce it everywhere: `_get_owned_patient(pid)` (single-patient routes — returns the row if the current user is the owner or an admin, else `None` → caller returns 404) and `_scope_sql(column="id")` (list/aggregate routes — returns a SQL fragment + params to append to a `WHERE` clause; empty for admins, `AND {column} IN (SELECT id FROM patienten WHERE nutzer_id=?)` otherwise). `admin` role is exempt and always sees every patient. Every route touching `patienten` or a child table (`antraege`, `leistungen`, `aufgaben`, `pflegetagebuch`, `dokumente`) must go through one of these two helpers — when adding a new patient-scoped route, follow the existing pattern rather than querying the table directly. `aufgaben.patient_id` is nullable in the schema, but the API now requires a `patient_id` from non-admin callers on creation (admins may still create patient-less tasks) so the isolation model stays consistent between write and read paths.
- **File storage**: everything is local under `static/` — `static/fotos/<patient_id>.<ext>` (profile photos), `static/briefe/<patient_id>/…` (generated letter PDFs), `static/dokumente/<patient_id>/…` (uploaded documents), `static/logo/` (company branding). No object storage/CDN involved.
- **Settings**: a generic key-value `einstellungen` table (accessed via `get_setting()`) holds SMTP credentials, company branding/address, and an (unused-in-current-flow) Claude API token — surfaced through `/api/admin/einstellungen`.
- **Settings**: a generic key-value `einstellungen` table (accessed via `get_setting()`) holds company branding/address and an (unused-in-current-flow) Claude API token — surfaced through `/api/admin/einstellungen`.
## Planned architecture pivot (see `refactoring.md`)
@@ -89,12 +89,21 @@ The product owner wants the app to cost **4.99 €/month per account**, provider
- Frontend: `curAbo`/`aboBlockiert` globals are set in `initApp()` from `GET /api/abo/status`. `go(name)` redirects any navigation to the `meinabo` page while `aboBlockiert` is true, so a blocked account always lands on its own status/pay screen instead of a broken page. `pgMeinAbo()` is that page (status, price, stub PayPal button) and doubles as the always-available "check my subscription" view when unblocked.
- When actually integrating PayPal: replace `/api/abo/aktivieren`'s stub body with real Checkout/Subscription creation, add a webhook endpoint to update `abo_bis`/`abo_typ` on payment events, and only then flip `abo_pflicht` to `"1"`.
## Email: mailto hand-off, not SMTP
There is **no server-side email sending anywhere in the app** — no SMTP client, no credentials, no relay. This was a deliberate removal (not a missing feature) done ahead of Docker containerization, because post-Docker the operator (Daniel) is the sole admin with no per-role differentiation to lean on — any email solution had to work identically for every account, not just an admin-configured one. `mailto:` is the "universal command": every OS/browser resolves it to the user's own default mail client (iOS → Mail, Android → Gmail/Outlook/whatever is set as default, desktop → whatever is set as default), with zero server involvement and zero secrets to manage in the container.
- Previously removed: `smtplib`/`email.mime.*` imports, `_smtp_cfg()`, `_smtp_send()`, `POST /api/email/senden`, `POST /api/email/test`, the "E-Mail (SMTP)" Backend-Einstellungen card, and the `email_smtp_*` settings keys. None of this exists anymore — don't resurrect it as a "fallback"; the user explicitly chose full removal over keeping SMTP as a backup path.
- Frontend flow (`sendMailModal(typ)` / `sendMail(typ)` in `templates/index.html`, shared by both Musterbrief and Widerspruch-Generator via the `typ` parameter): the modal collects Empfänger/Betreff/Anschreiben, then on send the existing client-side `html2pdf.js` PDF generation runs exactly as it already did for the plain "PDF herunterladen" button (`dlW()`/`dlM()`) — the blob is downloaded to the user's device **and** persisted to the patient's Akte via `POST /api/patienten/<pid>/briefe` — then `location.href='mailto:'+to+'?subject=...&body=...'` opens the user's mail client with recipient/subject/body pre-filled.
- **Hard limitation, not a bug**: `mailto:` cannot carry attachments — this is a browser/OS security restriction, not something this app can work around. The email body includes a bracketed reminder ("Bitte die heruntergeladene Datei „…" anhängen, dann diese Zeile löschen") and the modal's info box explains the same thing before sending, so the manual-attach step is expected UX, not a silent gap.
- If you add a third document type that needs "send by email", follow the same pattern: reuse the existing PDF-blob-download code for that document, then call the same `mailto:` construction — don't reintroduce a server-side send path.
## Demo account
A permanent `demo`/`demo` account (`nutzer.ist_demo=1`) exists for showing the app to prospects, created automatically in `init_db()` if missing. It's a completely ordinary `nutzer`-role account — no special-casing in the isolation model — with two exceptions layered on top:
- **Immune to the subscription paywall**: `_hat_aktiven_zugang()` returns `True` for `ist_demo` accounts unconditionally, *before* the `abo_pflicht` check. Don't remove this — a demo visitor must never see "Zahlung erforderlich" regardless of the operator's billing settings. (This was caught by testing, not designed in from the start — worth re-checking if `_hat_aktiven_zugang()` is ever refactored.)
- **Cannot send real email**: `POST /api/email/senden` and `/api/email/test` both return 403 for `ist_demo` accounts, so a demo visitor can never trigger outbound mail through the operator's real SMTP relay.
- **Email is a non-issue for the demo account**: since email sending was migrated to the client-side `mailto:` hand-off (see "Email: mailto hand-off, not SMTP" below), there is no server-side outbound relay left to abuse — a demo visitor's "send" click only opens *their own* local mail client via a `mailto:` link, never the operator's infrastructure. The earlier 403 block on `/api/email/senden`/`/api/email/test` for `ist_demo` accounts was removed along with those routes; no replacement guard was needed.
**Auto-reset every 6 hours**, lazily rather than via a background thread/cron (this app has no task scheduler and stays that way — a lazy check fits the existing "one Flask process, no queue" architecture and can't miss a tick across restarts): `_demo_reset_faellig()` compares the `einstellungen` key `demo_reset_at` against `now`; `_demo_reset()` deletes every patient owned by the demo account (cascades to antraege/leistungen/aufgaben/pflegetagebuch/dokumente via the existing `ON DELETE CASCADE` FKs — SQLite enforces this because `get_db()` sets `PRAGMA foreign_keys = ON`), then reseeds one fictional pre-filled patient (`_demo_seed()`: "Grete Musterfrau", Pflegegrad 3, one Antrag, two Leistungen, two Aufgaben, one Pflegetagebuch-Eintrag) and bumps `demo_reset_at`. The check/reset call happens in two places — `api_auth_login()` (fresh state at the start of every demo session) and `global_auth_check()` (catches a session that's still open when the 6h boundary passes mid-visit) — both call the same `_demo_reset_faellig()`/`_demo_reset()` pair, so there's one source of truth for "is a reset due."
+1 -87
View File
@@ -3,11 +3,7 @@ Pflegepilot Alpha – Flask + SQLite Web-App
Vollständig mit 82-Leistungen-Katalog integriert
"""
import sqlite3, os, json, re, base64, urllib.request, urllib.error, time, functools
import smtplib, io, zipfile
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders
import io, zipfile
from datetime import datetime, date, timedelta
from flask import Flask, render_template, request, jsonify, g, session, Response
from werkzeug.security import generate_password_hash, check_password_hash
@@ -1228,88 +1224,6 @@ def api_tagebuch_delete(tid):
db.commit()
return jsonify({"ok": True})
# ─── API: E-Mail-Versand ─────────────────────────────────────────────────────
def _smtp_cfg():
db = get_db()
rows = db.execute("SELECT schluessel, wert FROM einstellungen").fetchall()
cfg = {r["schluessel"]: r["wert"] for r in rows}
return {
"server": cfg.get("email_smtp", "").strip(),
"port": int(cfg.get("email_port", 587) or 587),
"user": cfg.get("email_user", "").strip(),
"password": cfg.get("email_pass", ""),
"absender": (cfg.get("email_absender") or cfg.get("email_user", "")).strip(),
"firma": cfg.get("firmenname", "Pflegepilot"),
}
def _smtp_send(to, subject, body, pdf_base64="", pdf_dateiname="Dokument.pdf"):
c = _smtp_cfg()
if not c["server"] or not c["user"]:
raise ValueError("SMTP nicht konfiguriert – bitte unter Backend → E-Mail einrichten.")
msg = MIMEMultipart()
msg["From"] = f"{c['firma']} <{c['absender']}>" if c["firma"] else c["absender"]
msg["To"] = to
msg["Subject"] = subject
msg.attach(MIMEText(body + f"\n\n-- \n{c['firma']}", "plain", "utf-8"))
if pdf_base64:
data = base64.b64decode(pdf_base64)
part = MIMEBase("application", "pdf")
part.set_payload(data)
encoders.encode_base64(part)
safe = re.sub(r'[^\w\-.]', '_', pdf_dateiname)
part.add_header("Content-Disposition", f'attachment; filename="{safe}"')
msg.attach(part)
with smtplib.SMTP(c["server"], c["port"], timeout=15) as srv:
srv.ehlo()
srv.starttls()
srv.login(c["user"], c["password"])
srv.send_message(msg)
@app.route("/api/email/senden", methods=["POST"])
def api_email_senden():
u = _get_current_user()
if u and u["ist_demo"]:
return jsonify({"error": "E-Mail-Versand ist im Demo-Konto nicht verfügbar."}), 403
d = request.json or {}
empfaenger = (d.get("empfaenger") or "").strip()
betreff = d.get("betreff") or "Nachricht von Pflegepilot"
nachricht = d.get("nachricht") or ""
pdf_base64 = d.get("pdf_base64") or ""
pdf_name = d.get("pdf_dateiname") or "Dokument.pdf"
if not empfaenger:
return jsonify({"error": "Empfänger-Adresse fehlt"}), 400
try:
_smtp_send(empfaenger, betreff, nachricht, pdf_base64, pdf_name)
return jsonify({"ok": True})
except ValueError as e:
return jsonify({"error": str(e)}), 400
except smtplib.SMTPAuthenticationError:
return jsonify({"error": "SMTP-Authentifizierung fehlgeschlagen – Benutzername/Passwort prüfen."}), 500
except smtplib.SMTPConnectError as e:
return jsonify({"error": f"Verbindung fehlgeschlagen: {e}"}), 500
except Exception as e:
return jsonify({"error": f"Fehler beim Versand: {e}"}), 500
@app.route("/api/email/test", methods=["POST"])
def api_email_test():
u = _get_current_user()
if u and u["ist_demo"]:
return jsonify({"error": "E-Mail-Versand ist im Demo-Konto nicht verfügbar."}), 403
try:
c = _smtp_cfg()
if not c["server"] or not c["user"]:
return jsonify({"error": "SMTP nicht konfiguriert"}), 400
_smtp_send(
c["absender"],
"Pflegepilot – Test-E-Mail",
"Die SMTP-Konfiguration funktioniert korrekt.\n\nPflegepilot Alpha"
)
return jsonify({"ok": True})
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
# ─── API: Kalender / Fristenüberwachung ──────────────────────────────────────
@app.route("/api/kalender", methods=["GET"])
def api_kalender():
@@ -1282,21 +1282,6 @@ async function pgBackend(){
// ── Zeile 2 ──
'<div class="g2 mt3">'+
// E-Mail SMTP
'<div class="card">'+
'<div class="ch"><span class="ct">E-Mail (SMTP)</span><span class="badge b-gr">Beta</span></div>'+
'<div class="fg mb4">'+
'<div class="fgrp"><label>Absender-Adresse</label><input id="e-email_absender" value="'+(s.email_absender||'')+'" placeholder="pflege@muster.de"></div>'+
'<div class="fg c2"><div class="fgrp"><label>SMTP-Server</label><input id="e-email_smtp" value="'+(s.email_smtp||'')+'" placeholder="smtp.gmail.com"></div>'+
'<div class="fgrp"><label>Port</label><input id="e-email_port" value="'+(s.email_port||'')+'" placeholder="587"></div></div>'+
'<div class="fgrp"><label>SMTP-Benutzer</label><input id="e-email_user" value="'+(s.email_user||'')+'" placeholder="user@muster.de"></div>'+
'<div class="fgrp"><label>SMTP-Passwort</label><input id="e-email_pass" type="password" value="'+(s.email_pass||'')+'" placeholder="••••••"></div>'+
'</div>'+
'<div class="fl g8" style="justify-content:flex-end">'+
'<button class="btn" onclick="testEmail()"><i class="ti ti-send"></i>Test-E-Mail</button>'+
'<button class="btn btn-p" onclick="saveEinst([\'email_absender\',\'email_smtp\',\'email_port\',\'email_user\',\'email_pass\'])"><i class="ti ti-check"></i>Speichern</button></div>'+
'</div>'+
// Nutzerverwaltung
'<div class="card">'+
'<div class="ch"><span class="ct">Nutzerverwaltung</span>'+
@@ -1313,8 +1298,6 @@ async function pgBackend(){
'</div></div>'+
'</div>'+
// ── Zeile 3 ──
'<div class="g2 mt3">'+
// Abo & Zahlung
'<div class="card">'+
'<div class="ch"><span class="ct">Abo &amp; Zahlung</span>'+
@@ -1332,14 +1315,6 @@ async function pgBackend(){
'<span class="badge b-b" style="padding:6px 12px"><i class="ti ti-settings-2" style="margin-right:4px"></i>Admin</span>');
}
async function adminLogout(){await doLogout();}
async function testEmail(){
const btn=event?.target;
if(btn){btn.disabled=true;btn.innerHTML='<i class="ti ti-loader-2"></i>Sende…';}
const r=await api('/api/email/test',{method:'POST'});
if(btn){btn.disabled=false;btn.innerHTML='<i class="ti ti-send"></i>Test-E-Mail';}
if(r.ok)toast('Test-E-Mail erfolgreich gesendet ✓ (an Absender-Adresse)');
else toast(r.error||'Fehler beim Senden',false);
}
function newNutzerModal(){
OM('Neuer Nutzer',
'<div class="fg"><div class="fgrp"><label>Benutzername *</label><input id="nu-user" placeholder="max.muster"></div>'+
@@ -2357,6 +2332,15 @@ function genW(){
}
function cpW(){navigator.clipboard.writeText(document.getElementById('wpreview')?.textContent||'');toast('Kopiert');}
// E-Mail-Versand läuft komplett über mailto: (öffnet das auf dem Gerät
// hinterlegte Standard-Mailprogramm - Mail/iCloud auf iPhone, Gmail/Outlook
// auf Android, Outlook/Mail/Thunderbird auf dem PC, je nach System-Einstellung
// des Nutzers). Bewusst kein serverseitiger Versand mehr: erspart SMTP-
// Zugangsdaten im Docker-Setup und funktioniert für jedes Konto identisch,
// ohne Sonderrolle für den Admin. Einzige Einschränkung: mailto: kann aus
// Sicherheitsgründen (Browser/Betriebssystem) keine Dateianhänge mitschicken
// - das PDF wird daher wie gewohnt heruntergeladen, Anhängen bleibt ein
// manueller Schritt für den Nutzer im geöffneten Mailprogramm.
async function sendMailModal(typ){
const pid=typ==='widerspruch'
?document.getElementById('wpid')?.value
@@ -2385,14 +2369,12 @@ async function sendMailModal(typ){
'<div class="fgrp"><label>Betreff</label><input id="ml-subj" value="'+escH(betreff)+'"></div>'+
'<div class="fgrp"><label>Anschreiben (E-Mail-Text)</label>'+
'<textarea id="ml-body" rows="5" class="inp">Sehr geehrte Damen und Herren,\n\nanbei übersende ich das beigefügte Dokument zu Ihrer Kenntnisnahme.\n\nFür Rückfragen stehe ich Ihnen gerne zur Verfügung.\n\nMit freundlichen Grüßen</textarea></div>'+
'<label class="fl ic g8" style="font-size:13px;cursor:pointer;margin-top:6px">'+
'<input type="checkbox" id="ml-pdf" checked style="width:auto;cursor:pointer"> Dokument als PDF-Anhang beifügen</label>'+
'</div>'+
'<div class="alert a-info mt3" style="padding:8px 11px"><i class="ti ti-info-circle"></i>'+
'<p class="sm">SMTP-Einstellungen unter <strong>Backend → E-Mail (SMTP)</strong> konfigurieren. Versand erfolgt über Ihren eigenen Mailserver.</p></div>'+
'<p class="sm">Das PDF wird jetzt heruntergeladen und Ihr <strong>eigenes E-Mail-Programm</strong> öffnet sich mit vorausgefülltem Empfänger. Bitte hängen Sie die heruntergeladene Datei dort <strong>manuell an</strong>, bevor Sie senden – ein automatischer Anhang ist aus Sicherheitsgründen technisch nicht möglich.</p></div>'+
'<div class="fl g8 mt3" style="justify-content:flex-end">'+
'<button class="btn" onclick="CM()">Abbrechen</button>'+
'<button class="btn btn-p" id="ml-send-btn" onclick="sendMail(\''+typ+'\')"><i class="ti ti-send"></i>Senden</button>'+
'<button class="btn btn-p" id="ml-send-btn" onclick="sendMail(\''+typ+'\')"><i class="ti ti-mail"></i>PDF laden &amp; E-Mail öffnen</button>'+
'</div>'
);
}
@@ -2401,41 +2383,48 @@ async function sendMail(typ){
const to=(document.getElementById('ml-to')?.value||'').trim();
const subj=document.getElementById('ml-subj')?.value||'';
const body=document.getElementById('ml-body')?.value||'';
const withPdf=document.getElementById('ml-pdf')?.checked;
if(!to){toast('Empfänger-Adresse eingeben',false);return;}
const btn=document.getElementById('ml-send-btn');
if(btn){btn.disabled=true;btn.innerHTML='<i class="ti ti-loader-2"></i>Wird gesendet…';}
if(btn){btn.disabled=true;btn.innerHTML='<i class="ti ti-loader-2"></i>PDF wird erstellt…';}
const previewId=typ==='widerspruch'?'wpreview':'mpreview';
const t=document.getElementById(previewId)?.textContent||'';
let pdf_base64='',pdf_dateiname='';
if(withPdf){
const rawName=typ==='widerspruch'
?(document.getElementById('wname')?.value||'Widerspruch')
:(document.getElementById('mn')?.value||'Brief');
const namePart=toFilename(rawName.trim().split(' ').pop()||rawName);
pdf_dateiname=(typ==='widerspruch'?'Widerspruch_':'Brief_')+namePart+'_'+isoDate()+'.pdf';
const el=document.createElement('div');
el.style.cssText='font-family:"Courier New",Courier,monospace;font-size:10.5pt;line-height:1.9;padding:0;white-space:pre-wrap;color:#111;background:#fff;word-break:break-word;';
el.textContent=t;
try{
await new Promise((res,rej)=>{
html2pdf().set({margin:[25,20,28,25],filename:pdf_dateiname,html2canvas:{scale:2,useCORS:true},jsPDF:{unit:'mm',format:'a4',orientation:'portrait'}})
.from(el).toPdf().get('pdf').then(pdf=>{addPageNums(pdf);pdf_base64=pdf.output('datauristring').split(',')[1];res();}).catch(rej);
});
}catch(e){
toast('PDF-Generierung fehlgeschlagen',false);
if(btn){btn.disabled=false;btn.innerHTML='<i class="ti ti-send"></i>Senden';}
return;
}
}
const r=await api('/api/email/senden',{method:'POST',body:JSON.stringify({
empfaenger:to,betreff:subj,nachricht:body,pdf_base64,pdf_dateiname
})});
if(r.ok){CM();toast('E-Mail erfolgreich gesendet ✓');}
else{
toast(r.error||'Fehler beim Senden',false);
if(btn){btn.disabled=false;btn.innerHTML='<i class="ti ti-send"></i>Senden';}
const pid=typ==='widerspruch'?document.getElementById('wpid')?.value:mPatId;
const rawName=typ==='widerspruch'
?(document.getElementById('wname')?.value||'Widerspruch')
:(document.getElementById('mn')?.value||'Brief');
const namePart=toFilename(rawName.trim().split(' ').pop()||rawName);
const pdf_dateiname=(typ==='widerspruch'?'Widerspruch_':'Brief_')+namePart+'_'+isoDate()+'.pdf';
const el=document.createElement('div');
el.style.cssText='font-family:"Courier New",Courier,monospace;font-size:10.5pt;line-height:1.9;padding:0;white-space:pre-wrap;color:#111;background:#fff;word-break:break-word;';
el.textContent=t;
try{
await new Promise((res,rej)=>{
html2pdf().set({margin:[25,20,28,25],filename:pdf_dateiname,html2canvas:{scale:2,useCORS:true},jsPDF:{unit:'mm',format:'a4',orientation:'portrait'}})
.from(el).toPdf().get('pdf').then(pdf=>{
addPageNums(pdf);
const blob=pdf.output('blob');
const url=URL.createObjectURL(blob);
const a=document.createElement('a');a.href=url;a.download=pdf_dateiname;
document.body.appendChild(a);a.click();document.body.removeChild(a);
setTimeout(()=>URL.revokeObjectURL(url),2000);
if(pid){
const b64=pdf.output('datauristring').split(',')[1];
api('/api/patienten/'+pid+'/briefe',{method:'POST',body:JSON.stringify({
typ:typ==='widerspruch'?'widerspruch':'musterbrief',notizen:'PDF: '+pdf_dateiname,brief_text:t,pdf_base64:b64,pdf_dateiname
})}).then(rr=>{if(rr.ok)toast('PDF dauerhaft in Patientenakte gespeichert ✓');});
}
res();
}).catch(rej);
});
}catch(e){
toast('PDF-Generierung fehlgeschlagen',false);
if(btn){btn.disabled=false;btn.innerHTML='<i class="ti ti-mail"></i>PDF laden &amp; E-Mail öffnen';}
return;
}
const bodyMitHinweis=body+'\n\n\n[Bitte die heruntergeladene Datei „'+pdf_dateiname+'" anhängen, dann diese Zeile löschen]';
location.href='mailto:'+to+'?subject='+encodeURIComponent(subj)+'&body='+encodeURIComponent(bodyMitHinweis);
toast('PDF heruntergeladen ✓ – E-Mail-Programm öffnet sich, bitte Datei anhängen');
CM();
}
function dlW(){