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 und liegen als Klartext irgendwo
in einer Config-Datei. Bei einem Leak hat ein Angreifer effektiv
Vollzugriff auf das Konto — Mails versenden im fremden Namen, je
nach Setup auch Postfach-Lesen via IMAP. - 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
wird an eine eigene App-Registrierung vergeben, die dann mit einem
Service-Principal als eine bestimmte Shared Mailbox senden darf — ohne
User-Identität, ohne dauerhaftes Passwort, und mit einem Token-Scope,
der genau eines kann: SMTP-Versand. Lesezugriff aufs Postfach
braucht andere Permissions (IMAP.AccessAsApp, Mail.Read etc.) und
einen entsprechend anderen Token — und die haben wir bewusst nicht
vergeben.
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).
- Security Defaults im Tenant deaktiviert (siehe Schritt 0).
Eine Domain kann zur gleichen Zeit nur in einem Tenant verifiziert
sein. Wenn sie noch in einem anderen Tenant liegt, musst du sie dort
erst sauber rauslösen (alle Objekte mit dieser Domain entfernen, dann
Domain rauswerfen) und im Ziel-Tenant neu verifizieren. Ein Tenant
kann umgekehrt beliebig viele Domains haben — du musst also nicht
für jede Domain einen eigenen Tenant aufmachen.
Schritt 0: Security Defaults deaktivieren
Microsofts „Security Defaults" sind eine pauschale Schutz-Policy, die
unter anderem SMTP AUTH komplett dichtmacht — und zwar so, dass
weder das Tenant-Setting noch ein Per-Mailbox-Override etwas dagegen
ausrichten kann. Solange Security Defaults aktiv sind, scheitert unser
gesamtes Setup mit dem berühmten:
535 5.7.139 Authentication unsuccessful,
SmtpClientAuthentication is disabled for the Tenant.
Microsoft selbst sagt das in einem leicht überlesbaren Note-Block:
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.
Lösung: 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.
Da Security Defaults auch MFA-Pflicht erzwungen haben, brauchst du
einen Ersatz dafür. Zwei Wege:
- Conditional Access Policies (granular, sauberster Weg) —
braucht aber Microsoft Entra ID P1. 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 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 drei 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
ist die häufigste Fehlerquelle bei diesem Setup.
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"
Erinnerung: ObjectId ist die der Enterprise Application, nicht
die der App Registration.
Schritt 3: Shared Mailbox + Berechtigung
Im Admin Center → Teams & Gruppen → Freigegebene Postfächer eine
neue Mailbox anlegen, z.B. no-reply@example.com.
Dann dem Service Principal Zugriff auf diese Mailbox geben:
Add-MailboxPermission -Identity "no-reply@example.com" `
-User "<ENTERPRISE-APP-OBJECT-ID>" `
-AccessRights FullAccess -AutoMapping $false
Kurzer Exkurs: Warum heißt das FullAccess?
Bei normalen User-Delegierten (User A öffnet die Mailbox von User B
in Outlook) bedeutet FullAccess tatsächlich Lesen/Schreiben/Löschen.
Bei einem Exchange-Service-Principal ist die Semantik eine andere:
FullAccess auf einem Exchange-SP = "Dieses SP darf sich via SMTP/IMAP/POP
an dieser spezifischen Mailbox authentifizieren"
Es ist im SP-Kontext effektiv eine Allowlist — Exchange prüft beim
XOAUTH2-Handshake, ob der Service Principal explizit für diese Mailbox
freigeschaltet ist. Ohne diesen Eintrag schlägt der OAuth-Versand fehl,
egal welche Entra-Permissions gesetzt sind.
Was das SP damit trotzdem nicht kann, ist Lesezugriff auf die
Mailbox: Microsoft erzwingt für jeden Zugriffstyp eine eigene
Entra-Permission. Unsere App hat nur SMTP.SendAsApp. Tokens werden
deshalb nur mit einem SMTP-Scope ausgestellt — Versuche, denselben
Token für IMAP, Graph oder EWS zu nutzen, würde Exchange ablehnen, weil
die nötige Permission (IMAP.AccessAsApp, Mail.Read, full_access_as_app)
in der App nicht vergeben wurde.
Kurz: Entra-Permission + Exchange-Permission müssen beide passen,
sonst geht gar nichts. FullAccess allein (ohne SMTP.SendAsApp) tut
nichts. SMTP.SendAsApp allein (ohne den Mailbox-Eintrag) auch nicht.
Schritt 4: 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 5: 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" siehst du den vollständigen SMTP-Dialog,
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
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: die App kann nur als
no-reply@example.comüber SMTP senden. Kein IMAP-Zugriff, kein
Postfach-Lesen, kein „als Admin senden" — der Token-Scope deckelt das. - 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:
- Security Defaults müssen aus. Andernfalls scheitert jeder
SMTP-AUTH-Versuch mit5.7.139, egal was du sonst konfigurierst. - Service-Principal-Object-ID in
New-ServicePrincipalund
Add-MailboxPermissionist die der Enterprise Application,
nicht die der App Registration. Häufigste Fehlerquelle. FullAccessbei einem SP ≠FullAccessbei einem User. Es
ist eine SMTP/IMAP/POP-Allowlist, kein freier Postfachzugriff —
solange du keine entsprechenden Entra-Permissions vergibst.- DKIM-CNAMEs wirklich als CNAME, nicht TXT eintragen. Sonst
meldet das Defender-Portal „CNAME nicht gefunden" beim Aktivieren. - Ghost
mail__options__servicein der Compose-Env entfernen,
sonst überschreibt es Host/Port mit einem Nodemailer-Preset. - 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.