Das Problem
Ich betreibe mehrere Ghost-Blogs als Docker-Container auf einer Linux-VM
(läuft als Hyper-V-Gast auf einem Windows Server). Ghost braucht für
Login-Links, Member-Signup-Bestätigungen und ähnliche Transaktionsmails
einen SMTP-Server. Bisher lief das über SendGrid — Free-Tier ausgelaufen,
gleichzeitig hatte ich genug von der Drittanbieter-Abhängigkeit.
Naheliegender Gedanke: Ich habe ja Microsoft 365 für meine Domain. Soll
Exchange Online das doch machen. Die naive Lösung — Username/Passwort
oder App-Passwort in die docker-compose.yml — kommt aus mehreren
Gründen nicht in Frage:
- Microsoft schaltet SMTP Basic Auth in Exchange Online ab —
aktuell angekündigt für späte 2027, vorher schon mehrfach verschoben,
aber das Damoklesschwert ist real. - App-Passwörter umgehen MFA, geben Vollzugriff auf das Postfach
(auch IMAP/POP zum Lesen), und liegen dann irgendwo im Klartext. - Mein Hauptkonto soll kein SMTP-Sender für irgendwelche Blog-Container
sein.
Die saubere Antwort 2026 heißt OAuth2 mit Client Credentials Grant
und der Application Permission SMTP.SendAsApp. Diese Berechtigung kann
gezielt an eine App-Registrierung vergeben werden, die dann mit einem
Service-Principal als eine bestimmte Shared Mailbox senden darf — ohne
User, ohne dauerhaftes Passwort, ohne Postfach-Lesezugriff.
Ghost selbst spricht das natürlich nicht direkt. Ghost spricht SMTP.
Brücke dazwischen: ein kleiner Proxy-Container, der lokal SMTP
entgegennimmt und upstream OAuth2-XOAUTH2 gegen smtp.office365.com:587
spricht.
[Ghost-Container] ──plain SMTP──▶ [oauth2-proxy] ──XOAUTH2──▶ smtp.office365.com:587
(kein User, nur App-Token)
Architektur klingt schick. Der Weg dahin hatte allerdings ein paar
Stolperstellen, die in der Microsoft-Doku nicht zusammenhängend an
einer Stelle stehen. Deshalb diese Anleitung.
Was du brauchst
- Einen Microsoft-365-Tenant, in dem deine Sender-Domain
als Custom Domain verifiziert ist. - Eine Shared Mailbox in diesem Tenant für die Absender-Adresse
(z.B.no-reply@example.com). Lizenz: keine. - Global-Admin-Rechte für die einmalige Einrichtung.
- Einen Docker-Host (Linux-VM, Podman, was auch immer).
Wenn deine Domain noch in einem anderen Tenant liegt: erst dort
alle Objekte mit dieser Domain entfernen, Domain rauswerfen, dann im
Ziel-Tenant neu verifizieren (TXT-Record, MX, SPF, DKIM, DMARC, das
volle Programm). Microsoft erlaubt eine Domain pro Tenant. Dauer:
in der Theorie bis 24 Stunden, in der Praxis bei meinem Setup ein
paar Stunden.
Schritt 1: Azure App-Registrierung
In Entra Admin Center → App-Registrierungen → Neue Registrierung:
- Name: z.B.
SMTP Relay - Kontotyp: Nur Konten in diesem Organisationsverzeichnis
- Redirect-URI: leer lassen — bei Client Credentials wird nichts
redirected.
Nach dem Anlegen die drei wichtigen IDs notieren:
- Application (Client) ID — auf der Übersicht
- Directory (Tenant) ID — auf der Übersicht
- Service Principal Object-ID — Achtung: das ist eine andere
ID als die der App-Registrierung. Findest du unter
Enterprise Applications → deine App → Object ID. Diese Verwechslung
hat mir später eine Viertelstunde Debugging beschert.
Dann API permissions:
- + Add a permission
- Tab APIs my organization uses
- Suchen nach
Office 365 Exchange Online - Application permissions (nicht Delegated!)
SMTP.SendAsAppanhaken- Add → Grant admin consent klicken
- Status muss auf grünes „Granted" wechseln
Certificates & secrets → + New client secret → 24 Monate →
Wert sofort kopieren und sicher ablegen. Das ist das einzige Mal, dass
Microsoft dir den Klartext zeigt.
Schritt 2: Service Principal in Exchange registrieren
Azure hat jetzt einen Service Principal. Exchange Online weiß davon
aber noch nichts. Diese Brücke schlägt PowerShell:
Connect-ExchangeOnline -UserPrincipalName admin@example.com
New-ServicePrincipal `
-AppId "<APP-CLIENT-ID>" `
-ObjectId "<ENTERPRISE-APP-OBJECT-ID>" `
-DisplayName "SMTP Relay"
Achtung Verwechslungsgefahr (siehe oben): ObjectId ist die der
Enterprise Application, nicht die der App Registration.
Schritt 3: Shared Mailbox + Berechtigungen
Im Admin Center → Teams & Gruppen → Freigegebene Postfächer eine
neue Mailbox anlegen, z.B. no-reply@example.com.
Dann dem Service Principal explizit Send-Rechte geben:
Add-MailboxPermission -Identity "no-reply@example.com" `
-User "<ENTERPRISE-APP-OBJECT-ID>" `
-AccessRights FullAccess -AutoMapping $false
Add-RecipientPermission -Identity "no-reply@example.com" `
-Trustee "<ENTERPRISE-APP-OBJECT-ID>" `
-AccessRights SendAs -Confirm:$false
FullAccess erlaubt dem Service Principal den Zugriff auf die Mailbox,
SendAs erlaubt, dass der From:-Header die Mailbox-Adresse
zeigt — und nicht etwa „Service Principal im Auftrag von".
Schritt 4: Der große Stolperstein — Security Defaults
Ab hier wird's spannend. Nach Schritt 1–3 dachte ich „fertig" und
schickte einen Test-Send durch den Proxy. Exchange antwortete:
535 5.7.139 Authentication unsuccessful,
SmtpClientAuthentication is disabled for the Tenant.
Ich habe daraufhin alle relevanten Settings durchgecheckt:
# Tenant-weit:
Get-TransportConfig | Select SmtpClientAuthenticationDisabled
# → True (Default)
# Pro Mailbox überschrieben:
Set-CASMailbox -Identity "no-reply@example.com" `
-SmtpClientAuthenticationDisabled $false
Get-CASMailbox "no-reply@example.com" | Select SmtpClientAuthenticationDisabled
# → False ✓
Microsofts Doku ist eindeutig: das Mailbox-Setting überschreibt das
Tenant-Setting. Sollte funktionieren. Funktioniert aber nicht,
solange Security Defaults aktiv sind. Im selben Doku-Artikel steht
das im Note-Block, leicht zu überlesen:
If security defaults is enabled in your organization, SMTP AUTH is
already disabled in Exchange Online. To use SMTP AUTH, you need
to disable security defaults.
Security Defaults sind eine Pauschal-Policy von Microsoft, die u.a.
SMTP AUTH komplett dichtmacht — und keinen Override zulässt, weder auf
Tenant- noch auf Mailbox-Ebene.
Lösung: Security Defaults aus, MFA-Schutz anders nachbauen. Im
Entra Admin Center → Properties → Manage security defaults →
auf Disabled. Microsoft fragt nach einem Grund („My organization is
planning to use Conditional Access" passt am ehesten).
Als MFA-Ersatz gibt es zwei Wege:
- Conditional Access Policies (granular, sauberster Weg) —
braucht aber Microsoft Entra ID P1 Lizenz. Habe ich nicht. - Per-User MFA (alte Methode, gratis in jedem Tenant) — funktional
identisch, weniger schick, völlig okay für kleine Tenants.
Per-User MFA aktivierst du unter
https://account.activedirectory.windowsazure.com/UserManagement/MultifactorVerification.aspx
oder Admin Center → Users → Active Users → Multi-factor authentication.
Für jeden echten User-Account das Häkchen setzen → Enable, dann
→ Enforce. Bei meinem Single-User-Tenant ein Klick.
Schritt 5: OAuth2-Proxy als Container
Es gibt ein hervorragendes Open-Source-Projekt:
simonrob/email-oauth2-proxy.
Python-basiert, aktiv gepflegt, unterstützt explizit den Client
Credentials Grant für Office 365. Es gibt ein gut gepflegtes
Community-Docker-Image
blacktirion/email-oauth2-proxy-docker,
das im Upstream-README direkt verlinkt ist.
Auf der VM Verzeichnis anlegen und Config-Datei schreiben:
sudo mkdir -p /data/smtp-relay
sudo chmod 700 /data/smtp-relay
/data/smtp-relay/emailproxy.config:
[SMTP-1587]
server_address = smtp.office365.com
server_port = 587
server_starttls = True
local_address = 0.0.0.0
local_port = 1587
[no-reply@example.com]
token_url = https://login.microsoftonline.com/<TENANT-ID>/oauth2/v2.0/token
oauth2_scope = https://outlook.office365.com/.default
oauth2_flow = client_credentials
client_id = <APP-CLIENT-ID>
client_secret = <DEIN-CLIENT-SECRET>
encrypt_client_secret_on_first_use = True
sudo chmod 600 /data/smtp-relay/emailproxy.config
encrypt_client_secret_on_first_use = True sorgt dafür, dass das
Plaintext-Secret nach dem ersten Start durch einen verschlüsselten Blob
ersetzt wird. Maschinengebundener Schlüssel, nur im Container
entschlüsselbar.
Dann der Portainer-Stack smtp-relay:
version: '3.8'
services:
smtp-relay:
image: blacktirion/email-oauth2-proxy-docker:latest
container_name: smtp-relay
restart: unless-stopped
environment:
LOGFILE: "true"
DEBUG: "false"
volumes:
- smtp-relay-config:/config
networks:
- smtp-relay
networks:
smtp-relay:
name: smtp-relay
volumes:
smtp-relay-config:
driver: local
driver_opts:
type: none
device: /data/smtp-relay
o: bind
Deploy → Logs prüfen. Erwartet:
Starting SMTP server at 0.0.0.0:1587 (unsecured)
proxying smtp.office365.com:587 (STARTTLS)
Initialised Email OAuth 2.0 Proxy
Schritt 6: Ghost anbinden
Im Ghost-Stack das Network einbinden und die Mail-Settings ändern:
services:
myblog:
image: ghost:latest
environment:
url: "https://www.example.com"
mail__from: "'My Blog' <no-reply@example.com>"
mail__transport: SMTP
mail__options__host: smtp-relay
mail__options__port: 1587
mail__options__secure: "false"
mail__options__requireTLS: "false"
mail__options__auth__user: no-reply@example.com
mail__options__auth__pass: "irrelevant-aber-pflicht"
networks:
- default
- smtp-relay
networks:
default:
smtp-relay:
name: smtp-relay
external: true
Drei Eigenheiten zu beachten:
mail__options__serviceaus deiner alten Config löschen, falls
vorhanden. Ein Nodemailer-Preset wieO365überschreibt sonst
Host/Port-Settings.requireTLS: false, weil die Verbindung Ghost → Proxy plain läuft
(innerhalb des Docker-Netzes). Der Proxy macht das TLS upstream zu
M365.auth__passmuss irgendetwas enthalten — Nodemailer verlangt
einen Wert, der Proxy ignoriert ihn. Authentifizierung gegen M365
passiert über das App-Token.
Stack updaten, Test-Mail aus Ghost (z.B. Member-Invite an dich selbst).
Im Proxy-Log mit DEBUG: "true" solltest du den vollständigen
SMTP-Dialog sehen, der mit
235 2.7.0 Authentication successful
250 2.0.0 OK <message-id@example.com>
endet. Bei mir kam die Mail im Outlook-Posteingang mit allen
Authentication-Headern auf grün an: spf=pass, dkim=pass,
dmarc=pass, compauth=pass reason=100, SCL:1 (kein Spam).
Aufräumen
- SMTP AUTH am Hauptkonto wieder ausschalten — wir hatten es zur
Diagnose geöffnet, brauchen es nicht mehr:Set-CASMailbox -Identity admin@example.com ` -SmtpClientAuthenticationDisabled $true DEBUG: "false"im Proxy-Stack — die Debug-Logs sind dauerhaft zu
geschwätzig.- Client Secret in einen Passwort-Manager. Du brauchst ihn, falls du
den Container je neu aufsetzt. - Calendar-Reminder für Secret-Rotation in 22 Monaten (Microsoft
erlaubt maximal 24-Monats-Secrets).
Was hat das gekostet?
- Microsoft 365: 0 € extra (Shared Mailbox ist gratis, App-Registrierung
ist gratis, ich nutze meinen bestehenden Tenant). - Docker-Resourcen: ein ~47 MB Image, ~30 MB RAM im Idle.
- Setup-Zeit beim ersten Mal: ein halber Nachmittag, weil ich die
Security-Defaults-Falle live debuggen musste. Beim zweiten Tenant
schaffe ich es in 20 Minuten.
Was ist jetzt besser als vorher?
- Kein Drittanbieter-Account (SendGrid, Mailgun & Co.).
- Kein App-Passwort das vergessen werden und in falsche Backups
rutschen kann. - Granulare Trennung: das App-Token kann nur als
no-reply@example.comsenden, sonst nichts. Kein IMAP-Zugriff, kein
Postfach-Lesen, kein „als Admin senden". - Zukunftssicher gegen Microsofts Basic-Auth-Abschaltung. Wenn die
endgültig kommt, ändert sich für mich nichts. - Wiederverwendbar: jeder andere Container kann den
smtp-relay
nutzen — Vaultwarden, Gitea, Monitoring-Scripts, was auch immer.
Stolpersteine in der Zusammenfassung
Damit du sie nicht alle einzeln aufdecken musst:
- Service-Principal-Object-ID in
New-ServicePrincipalist die
der Enterprise Application, nicht die der App Registration. - DKIM-CNAMEs wirklich als CNAME, nicht TXT eintragen. Sonst
meldet das Defender-Portal „CNAME nicht gefunden" beim Aktivieren. - Security Defaults überschreiben tenant- und mailbox-weite
SMTP-AUTH-Einstellungen lautlos. Müssen aus. Per-User MFA oder
Conditional Access als Ersatz. - Tenant-Setting
SmtpClientAuthenticationDisabled = Trueist
völlig okay zu lassen, solange das Mailbox-Override greift —
was es nur tut, wenn Security Defaults aus sind. - Ghost
mail__options__servicein der Compose-Env entfernen,
sonst überschreibt es den Host/Port. - Im Proxy-Log nach Token-Cache-Problemen: ein Container-Restart
räumt den OAuth-Token-Cache und holt einen frischen, falls man
während der Diagnose Berechtigungen geändert hat.
Wenn ich nochmal vor demselben Problem stünde, wäre der ganze
Aufwand auf der M365-Seite zwei Stunden. Den Proxy-Container deploye
ich in fünf Minuten. Hat sich gelohnt — das Setup steht jetzt, läuft
quasi wartungsfrei, und ich habe keinen externen Mail-Sender mehr im
Stack.
Tooling: simonrob/email-oauth2-proxy
und blacktirion/email-oauth2-proxy-docker.
Beiden Projekten ein Dankeschön — ohne die wäre der Weg deutlich
holpriger gewesen.