Entwurf: E-Mail-Versand über Microsoft Graph¶
Status: umgesetzt im Pull Request „E-Mail über Microsoft 365“; die Entscheidungen unten gelten, Abweichungen vom ersten
Entwurf stehen im Abschnitt „Entschieden und offen“. Stand: main bei 93124e3, Microsoft-Doku am 10. November 2026
gelesen.
Bezug: Roadmap S17 („E-Mail-Versand über Google oder Microsoft“, ohne Issue),
E-Mail einrichten.
Ausgangslage¶
Alle Mails gehen durch eine Funktion: send() in apps/web/src/server/modules/mail/mail.ts. Sie lädt die
Zeile mail_settings, baut ein nodemailer-Transport (transportFactory), ruft sendMail und schreibt jeden Versuch
nach mail_log (auch Fehler; Fehler kommen als Ergebnis zurück, nicht als Ausnahme). Aufrufer sind die Rechnungs-,
Mahnungs- und Angebotsmails (rechnungen/invoice-mail.ts), die Fristen-Erinnerung, die Prüfwert-Mail und die
Test-Mail. Das Passwort liegt AES-256-GCM-verschlüsselt in mail_settings.ciphertext, die Audit-Funktion
haben_audit lässt diese Spalte aus. Sieben Testdateien ersetzen den Transport über setMailTransportForTests.
Abweichungen von der Aufgabenstellung, die ich im Code gefunden habe:
- Die Dateien heißen
modules/mail/mail.tsundmodules/rechnungen/invoice-mail.ts, nichtserver/mail.ts. - Eine Antwortadresse (
replyTo) gibt es im SMTP-Weg heute nicht. „Analog zum SMTP-Weg“ heißt hier: nicht setzen. Der Transport-Vertrag bekommt das Feld trotzdem, siehe offene Fragen. - Haben läuft unter Bun, nicht unter Node 22; Prüfungen mit
bun run lint|typecheck|test, nichtpnpm. mail.tshat 390 Zeilen, die Grenze ist 400 (scripts/dateigroesse). Neuer Code kommt in neue Dateien, der SMTP-Teil wird dafür ausgelagert.
Annahmen (bitte bestätigen)¶
- A1 Ziel sind Exchange Online und Shared Mailboxes im eigenen Tenant des Betreibers; Google, sovereign Clouds (US Gov, China) und reine Microsoft-Konten sind nicht Teil dieses PRs.
- A2 Ein Versandweg ist zur Zeit aktiv (SMTP oder Graph), kein Ausweichen vom einen auf den anderen.
- A3 Es gibt für den PR keinen echten Tenant in der CI und auch sonst keinen Testtenant; das Verhalten gegen Exchange Online (Absendername, Shared Mailbox, Fehlercodes) ist bis dahin aus der Doku abgeleitet und im ADR als „zu prüfen“ markiert.
Entscheidungen¶
1. Abstraktion: MailTransport¶
send() bleibt der einzige Einstieg und behält Protokollierung und Rückgabewert. Neu ist nur, woher der Transport kommt:
/** Was `send()` einem Versandweg übergibt; Form wie bei nodemailer, damit der SMTP-Weg unverändert bleibt */
interface OutgoingMail {
from: { name: string; address: string };
to: string; // wie heute: eine oder mehrere Adressen, durch Komma getrennt
bcc?: string;
replyTo?: string; // heute von keinem Aufrufer gesetzt
subject: string;
text: string;
html: string;
attachments?: { filename: string; content: Buffer | string; contentType: string }[];
}
interface MailTransport { sendMail(mail: OutgoingMail): Promise<unknown>; } // wirft bei Fehler
smtp: das bestehende nodemailer-Transport, Code unverändert, nur intransport-smtp.tsverschoben.graph: neue Implementierung (siehe unten).- Auswahl: neue Spalte
mail_settings.transport(smtp|graph, Vorgabesmtp),transportFor(settings)intransport.ts.setMailTransportForTests(factory)bleibt mit gleicher Signatur und ersetzt beide Wege; die sieben bestehenden Testdateien ändern sich nicht. - Der Vertrag ist absichtlich
sendMail(options)wie bei nodemailer: Der erste Commit ist dann ein reines Verschieben ohne Verhaltensänderung, und ein Test beweist es (die bestehenden Tests laufen unverändert grün).
2. Anmeldung: App-only (Client Credentials)¶
Token über @azure/msal-node (ConfidentialClientApplication.acquireTokenByClientCredential,
Scope https://graph.microsoft.com/.default, Authority https://login.microsoftonline.com/<Tenant-ID>).
msal cached das Token im Speicher und erneuert es vor Ablauf selbst; wir halten eine Instanz je Einstellungsstand und
verwerfen sie beim Speichern oder Löschen der Einstellungen.
| Mittel | Wann | Speicherung |
|---|---|---|
| Zertifikat (bevorzugt) | Vorgabe in der Doku | Privater Schlüssel (PEM, PKCS#8) verschlüsselt in ciphertext; SHA-256-Fingerabdruck des Zertifikats im Klartext in graph_thumbprint (kein Geheimnis, wird aus dem hochgeladenen Zertifikat berechnet) |
| Client Secret | Alternative | Secret verschlüsselt in ciphertext |
- Nur ein Geheimnis je Zeile, immer in
ciphertext. Damit greift die vorhandene Regel „Geheimnisse nur in einer Spalteciphertext“ undhaben_auditlässt es ohne Änderung des Audit-Triggers aus (ein Trigger, der Audit-Daten filtert, wird nicht angefasst). - Tenant-ID, Client-ID, Authentifizierungsart, Fingerabdruck und Absender sind keine Geheimnisse und stehen im Klartext (und damit im Audit-Log).
- Wechselt der Versandweg oder die Authentifizierungsart, muss das Geheimnis neu eingegeben werden; ein SMTP-Passwort wird nie als Graph-Geheimnis weiterverwendet.
- Zertifikat hochladen statt in Haben erzeugen: Node bringt keine X.509-Erzeugung mit, eine Bibliothek dafür wäre
eine weitere Abhängigkeit. Die Doku zeigt
openssl; ein Erzeugen in der Oberfläche kann später folgen.
msal-node oder selbst mit fetch und node:crypto? Ich empfehle msal-node (wie vorgegeben): Die Anmeldung mit
Zertifikat (signierte JWT-Assertion, Header x5t#S256), Token-Cache, Zeitabweichung und die Übersetzung der
AADSTS-Fehler sind sicherheitsrelevanter Code, den man nicht neu schreiben will. Gegenargumente: Bun ist nicht
verifiziert (ich habe msal-node 7.2.1, engines: node >=20, 8,3 MB mit jsonwebtoken und Co., unter Node 24
geladen und einen Tokenabruf bis zur Antwort von Entra ausgelöst; Bun ist auf meinem Rechner nicht installiert). Gate
im zweiten Commit: läuft msal-node im Dev-Container unter Bun (Tokenabruf gegen einen gemockten Endpunkt), bleibt es
dabei; sonst schreibe ich TokenProvider mit fetch/node:crypto (rund 80 Zeilen) hinter derselben Schnittstelle.
Den Graph-Client @microsoft/microsoft-graph-client nehme ich nicht: Es sind vier Endpunkte, und fetch gibt
volle Kontrolle über Retry-After und lässt sich in Tests einfach ersetzen.
3. Versand: POST /users/{absender}/sendMail¶
- Endpunkt und Form laut user: sendMail:
POST /users/{id | userPrincipalName}/sendMail, Antwort202 Acceptedohne Inhalt.saveToSentItemsist laut Doku schontrue, wenn es fehlt; Haben setzt es trotzdem ausdrücklich, damit die Absicht im Code steht. - Application-Berechtigung
Mail.Sendist die niedrigste. Bei App-only läuft der Versand als das Postfach in{absender}, also auch aus einer Shared Mailbox; die Mail landet in deren „Gesendeten Elementen“ (zu prüfen im Testtenant, siehe A3). - Absender frei einstellbar:
fromAddress(wie heute, steht imFromund als BCC bei „Kopie an mich“) und optionalgraph_mailbox(UPN oder Objekt-ID, wenn sie von der Adresse abweicht, etwa bei Alias-Adressen). Ohnegraph_mailboxgiltfromAddress. - Abbildung:
to/bcc(Komma-Liste) →toRecipients/bccRecipients;html→body.contentType = "HTML"; den Textteil kannsendMailim JSON nicht neben HTML tragen (ein Body hat genau einencontentType). Der Text entfällt also im Graph-Weg; die HTML-Mails von Haben sind schlicht aufgebaut, das ist die Abweichung zu SMTP (multipart/alternative) und kommt in die Doku.replyTo→replyTo.fromwird gesetzt (Name und Adresse des Postfachs). Zu prüfen: ob Exchange den Anzeigenamen ausfrom.emailAddress.nameübernimmt oder den des Postfachs nimmt (senderNamewäre dann wirkungslos, die Doku sagt es). - MIME statt JSON (
Content-Type: text/plain, Base64-MIME) wäre die Alternative: nodemailersMailComposererzeugt dieselben Bytes wie der SMTP-Weg, mit Text- und HTML-Teil und beliebigen Anhängen. Ich habe es nicht gewählt, weil die Größengrenze für diesen Weg in der Doku nicht steht und ich sie ohne Testtenant nicht prüfen kann. Reicht sie für größere Anhänge, wäre MIME der Weg dafür (kein Entwurf, keinMail.ReadWrite); das lässt sich erst mit einem Testtenant entscheiden und ist nicht Teil dieses PRs.
4. Delegierter Modus: nicht umsetzen¶
Nur App-only. Begründung:
- Haben sendet unbeaufsichtigt (wiederkehrende Rechnungen, Erinnerungen, die Prüfwert-Mail nachts). Ein delegierter Weg bräuchte ein Refresh-Token einer Person, das bei Passwortwechsel, Conditional Access oder Inaktivität ablaufen kann; dann stehen Rechnungen still.
- Er bräuchte einen OAuth-Ablauf in der App (Weiterleitung, Callback,
state, Zustimmung je Nutzer) und ein weiteres Benutzer-gebundenes Geheimnis; die Angriffsfläche wäre größer als ein Zertifikat auf dem Server. - Shared-Mailbox-Versand wäre zusätzlich von SendAs- oder SendOnBehalf-Rechten der angemeldeten Person abhängig.
- Der einzige echte Vorteil, dass der Admin keinen tenantweiten Anwendungszugriff vergeben will, ist mit der Einschränkung auf bestimmte Postfächer (nächster Abschnitt) abgedeckt.
5. Rechte begrenzen¶
Wichtig für die Doku: Mail.Send (Application) mit Admin-Zustimmung in Entra erlaubt der App, als jedes
Postfach des Tenants zu senden. Die Einrichtung muss das deshalb einschränken. Stand der Microsoft-Doku
(Role Based Access Control for Applications in Exchange Online):
- RBAC for Applications ersetzt die Application Access Policies („It replaces Application Access Policies“). Haben dokumentiert RBAC; die Access Policy bekommt nur einen Hinweis für Bestandstenants.
- Schritte:
New-ServicePrincipal(AppId und ObjectId der Unternehmensanwendung, nicht die Werte der App-Registrierung),New-ManagementScopemit-RecipientRestrictionFilterfür die erlaubten Postfächer (oder eine Administrative Unit),New-ManagementRoleAssignment -App … -Role "Application Mail.Send" -CustomResourceScope …, prüfen mitTest-ServicePrincipalAuthorization -Resource <Postfach>. - Falle: Die Rechte aus Entra und aus Exchange-RBAC werden vereinigt. Bleibt
Mail.Sendin Entra mit Admin-Zustimmung erteilt, ist die Einschränkung wirkungslos. Für den eingeschränkten Weg wird die Zustimmung in Entra deshalb nicht erteilt bzw. entfernt. Ob dann ein Token ohneroles-Claim trotzdem sendet, ist laut Doku so gedacht, aber im Testtenant zu prüfen (A3); die Einrichtung beschreibt beide Wege (schnell und tenantweit, oder eingeschränkt). - Änderungen wirken laut Doku nach 30 Minuten bis 2 Stunden (Cache);
Test-ServicePrincipalAuthorizationumgeht ihn. Das gehört in die Fehlerbilder („gerade geändert, noch nicht wirksam“).
6. Anhänge¶
Grenzen aus Attach large files: unter 3 MB ein
einzelner Anhang direkt; ab 3 MB braucht es eine Upload-Session, und die verlangt laut Doku Mail.ReadWrite
(Application), nicht nur Mail.Send. Zu Shared Mailboxes und großen Anhängen nennt die Doku ein bekanntes Problem
(Known Issue 13644). Die ungefähre Grenze von 4 MB für den sendMail-Request habe ich in der Doku nicht bestätigt
gefunden; ich rechne vorsichtig.
Entscheidung: nur direkter Versand, keine Upload-Session. Die Summe der Anhänge darf höchstens 2,5 MB roh betragen
(Base64 macht daraus rund 3,3 MB); sendMail bekommt sie als fileAttachment mit contentBytes. Darüber bricht der
Versand ab, bevor etwas gesendet wird (kein Teilversand), mit der Meldung: „Die Anhänge (x MB) sind für den Versand über
Microsoft 365 zu groß (höchstens 2,5 MB). Verkleinere die Anhänge oder verwende SMTP.“
Gründe:
- Geringstes Recht:
Mail.Sendallein genügt.Mail.ReadWritehieße, die App könnte alle Mails der erlaubten Postfächer lesen und ändern; für Rechnungs-PDF plus E-Rechnung (meist unter 1 MB) ist das zu viel. - Nicht prüfbar: Der Entwurf-und-Upload-Weg, besonders in einer Shared Mailbox (Known Issue 13644), lässt sich ohne Testtenant nicht verifizieren; ungeprüftes Verhalten baue ich nicht ein.
- Später möglich: Der Schwellwert steht als Konstante in einer Datei. Eine Stufe B (Entwurf,
createUploadSession,/send, Entwurf bei Fehler löschen) oder der MIME-Weg (siehe 3.) lassen sich nachrüsten, sobald ein Testtenant da ist, ohne die Schnittstelle zu ändern.
7. Fehlerbehandlung¶
- Wiederholen nur bei
429und503mitRetry-After(Sekunden oder Datum), höchstens 3 Versuche und in Summe höchstens 30 Sekunden Wartezeit (der Nutzer wartet in der Oberfläche; der Scheduler bekommt dann den Fehler und versucht es im nächsten Lauf). OhneRetry-After: Backoff 1 s, 2 s, 4 s. - Nicht wiederholen bei Zeitüberschreitung, Netzfehlern und anderen 5xx: Ein
202trägt keine Kennung, ein erneuter Versuch nach einer Zeitüberschreitung könnte dieselbe Rechnung doppelt zustellen. Besser ein protokollierter Fehler, den der Nutzer bewusst wiederholt. - Limits (Outlook je App und Postfach, aus Throttling-Limits): 10 000 Anfragen je 10 Minuten, 4 gleichzeitige, 150 MB Upload je 5 Minuten. Haben sendet seriell, das reicht; die tägliche Empfängergrenze von Exchange Online ist ein Dienstlimit und kommt als Fehler zurück.
- Meldungen auf Deutsch statt der rohen Graph-Antwort. Zuordnung nach HTTP-Status und
error.code, mit demrequest-idaus dem Antwort-Header im Fehlertext (für den Microsoft-Support). Die genauen Fehlercodes der Anmeldung (AADSTS …) und von Exchange für „Postfach nicht erlaubt“, „Absender existiert nicht“ und „Berechtigung fehlt“ sind im Testtenant zu verifizieren; ich lege sie nicht aus dem Gedächtnis fest, sondern gruppiere vorerst nach Status: 401 → Anmeldung (Tenant, Client, Geheimnis, Zertifikat), 403 → Berechtigung oder Postfach nicht erlaubt, 404 → Postfach unbekannt, 400 → Empfänger oder Inhalt, 413 → zu groß, 429/503 → überlastet, später versuchen. - Protokoll: Jeder Versuch steht wie bisher in
mail_log(ok,error), vom Versandweg unabhängig, weilsend()protokolliert. Der Fehlertext nennt nie Secret, Token oder Zertifikat. Jede Zeile trägt den Versandweg in der neuen Spaltetransport(siehe 8.).
8. Schema und Einstellungsseite¶
Migration (erzeugt mit bun run db:generate, danach Trigger-Prüfung):
mail_settings.transport text not null default 'smtp'mit CHECKin ('smtp','graph');host,port,secure,username,ciphertextwerden nullable, dazu ein CHECK: beismtpsind sie alle gesetzt, beigraphsindgraph_tenant_id,graph_client_id,graph_auth(secret|certificate) undciphertextgesetzt (beicertificateauchgraph_thumbprint);- neue Spalten
graph_tenant_id,graph_client_id,graph_auth,graph_thumbprint,graph_mailbox; mail_log.transport text not null default 'smtp'(smtp|graph): zeigt je Versuch den Versandweg. Die Tabelle ist nur anhängbar und nicht versiegelt, eine neue Spalte ändert keinen Trigger; vorhandene Zeilen sind SMTP.send()schreibt sie.
Bestehende Zeilen bekommen transport = 'smtp' und bleiben gültig; kein Trigger ändert sich, mail_settings ist weder
versiegelt noch festgeschrieben. Das Lockern von NOT NULL ist ein neuer Schritt in einer neuen Migration, keine Änderung
einer bestehenden.
Die Seite (MailCard.tsx, 307 Zeilen, wird dafür in zwei Komponenten geteilt) bekommt eine Auswahl „SMTP“ / „Microsoft
365 (Graph)“. Graph: Tenant-ID, Client-ID, Anmeldung (Zertifikat: PEM-Datei und Zertifikat hochladen; Secret: Feld),
Absender und optional Postfach (UPN), Test-Mail senden (vorhanden). Die Oberfläche
bekommt das Geheimnis nie zurück, nur „gespeichert“ (wie beim SMTP-Passwort).
9. Ausblick, nicht Teil des PRs¶
Belegeingang per Graph statt IMAP (Mail.Read auf ein Postfach, inbox_settings), Google OAuth, sovereign Clouds,
Zertifikat in Haben erzeugen, Ablaufwarnung für Secret und Zertifikat.
Betroffene Stellen¶
| Bereich | Änderung |
|---|---|
modules/mail/ |
mail.ts schrumpft (SMTP-Transport und Schema ausgelagert); neu transport.ts, transport-smtp.ts, graph/ (Token, Anfrage, Versand, Fehler) |
index.ts des Moduls |
nur Exporte; andere Module bleiben unberührt (Modulgrenzen) |
db/schema.ts |
mail_settings und mail_log (nur Spalten, kein Trigger) |
functions/mail.ts, MailCard.tsx |
Eingabe als z.discriminatedUnion("transport", …), Oberfläche |
rechnungen/invoice-mail.ts |
keine; es ruft weiter send() und liest settings.fromAddress |
docs/ |
einrichtung.md (neuer Abschnitt), architektur.md (Tabelle, Verschlüsselung), installation.md (Liste der Geheimnisse), roadmap.md (S17), rechnungen.md (SMTP-Hinweis) |
| Abhängigkeiten | @azure/msal-node (Begründung oben), sonst keine |
Sicherheit¶
- Geheimnisse nur in
ciphertext, nie im Audit-Log, nie in Antworten an den Browser, nie in Fehlertexten oder Logs. - Rechte des Tokens: Die Doku erklärt, wie der Zugriff auf erlaubte Postfächer begrenzt wird, und dass ohne Begrenzung jedes Postfach des Tenants als Absender möglich ist. Die Oberfläche weist darauf hin.
- Nur der Inhaber darf die Einstellungen ändern (
authMiddleware, wie bisher); die Steuerberatung sieht sie nicht. - HTTP: feste Hosts (
login.microsoftonline.com,graph.microsoft.com); die Upload-URL aus der Session wird nur genutzt, wenn siehttpsist und aufoutlook.office.combzw.outlook.office365.comzeigt (kein Weiterreichen von Anfragen an beliebige Adressen); Zeitlimits an jedem Aufruf. - Lizenz:
@azure/msal-nodesteht unter MIT (vor dem Eintragen inpackage.jsonam Paket zu bestätigen); AGPL-3.0 von Haben bleibt unberührt.
Tests (Umsetzung)¶
- Unit (Vitest, mit gemocktem
fetchund gemocktemTokenProvider): Auswahl des Transports nachtransport; Aufbau des Requests (Shared Mailbox als{absender},from, mehrere Empfänger, BCC, HTML,replyTo); Anhänge im Limit (fileAttachmentmit Base64), knapp darüber (Fehler, nichts gesendet, kein Request); Retry mitRetry-After(Sekunden und Datum), kein Retry bei Zeitüberschreitung; deutsche Meldungen je Status. - Postgres (
TEST_DATABASE_URL):mail_log.transportstimmt je Versuch; Geheimnis liegt nur verschlüsselt in der Zeile und nicht im Audit-Log (wedernew_valuenochold_value), weder bei Secret noch bei Zertifikat; Wechsel von SMTP auf Graph verlangt neues Geheimnis; CHECK der Spalten; bestehende SMTP-Zeile bleibt nach der Migration gültig. - SMTP unverändert: die sieben bestehenden Testdateien laufen ohne Änderung.
- Kein echter Tenant in der CI. Ein Test gegen einen echten Tenant bleibt eine lokale Skript-Hilfe, die ohne Umgebungsvariablen nichts tut und in der Beschreibung des PRs als nicht gelaufen steht.
Entschieden und offen¶
Entschieden (10. November 2026, mit dem Auftraggeber):
- Es gibt keinen Testtenant. Alles, was nur dort prüfbar ist, wird nicht eingebaut (Upload-Session, MIME) oder als „zu prüfen“ in der Doku und im PR genannt.
- Große Anhänge: keine Upload-Session, kein
Mail.ReadWrite, stattdessen eine klare Fehlermeldung (6.). - MIME statt JSON: nicht in diesem PR.
mail_log.transport: kommt (8.).
Offen:
replyTojetzt schon verdrahten (es gibt kein Eingabefeld dafür) oder nur im Vertrag vorsehen? Vorschlag: nur im Vertrag.- Passt es zu Haben, dass
@azure/msal-nodeals Abhängigkeit dazukommt? Das Bun-Gate im zweiten Commit entscheidet, ob stattdessenfetchundnode:cryptogenügen. - Ob Exchange den Anzeigenamen aus
fromübernimmt, bleibt ungeprüft; die Doku sagt es offen.
Umgesetzt wie vorgeschlagen: replyTo nur im Vertrag (OutgoingMail) und im Request, ohne Eingabefeld; @azure/msal-node läuft
unter Bun (Test mit gemocktem Entra-ID-Endpunkt in graph/token.test.ts), die Alternative mit fetch und node:crypto wurde
deshalb nicht gebraucht.
Reihenfolge der Commits¶
- Vertrag
MailTransport, SMTP intransport-smtp.ts,mail.tsunter 400 Zeilen, keine Verhaltensänderung. - Schema und Migration (
transport, Graph-Spalten), Speichern und Verschlüsseln. - Graph-Transport: Token,
sendMail, Anhänge bis 2,5 MB, Fehler, Tests. - Einstellungsseite.
- Doku: Einrichtung Schritt für Schritt (App registrieren, Zertifikat, Postfächer einschränken, Shared Mailbox, Fehlerbilder, Grenze für Anhänge), Architektur, Roadmap.
Letztes Update: