Ghost-Blog sendet Mails über Microsoft 365 — der Weg ohne App-Passwort

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:

  1. 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.
  2. 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.
  3. 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-RegistrierungenNeue 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-IDAchtung: 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.SendAsApp anhaken
  • 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__service aus deiner alten Config löschen, falls
    vorhanden. Ein Nodemailer-Preset wie O365 ü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__pass muss 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:

  1. Security Defaults müssen aus. Andernfalls scheitert jeder
    SMTP-AUTH-Versuch mit 5.7.139, egal was du sonst konfigurierst.
  2. Service-Principal-Object-ID in New-ServicePrincipal und
    Add-MailboxPermission ist die der Enterprise Application,
    nicht die der App Registration. Häufigste Fehlerquelle.
  3. FullAccess bei einem SP ≠ FullAccess bei einem User. Es
    ist eine SMTP/IMAP/POP-Allowlist, kein freier Postfachzugriff —
    solange du keine entsprechenden Entra-Permissions vergibst.
  4. DKIM-CNAMEs wirklich als CNAME, nicht TXT eintragen. Sonst
    meldet das Defender-Portal „CNAME nicht gefunden" beim Aktivieren.
  5. Ghost mail__options__service in der Compose-Env entfernen,
    sonst überschreibt es Host/Port mit einem Nodemailer-Preset.
  6. 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.