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, geben Vollzugriff auf das Postfach
    (auch IMAP/POP zum Lesen), und liegen dann irgendwo im Klartext.
  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 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-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 die drei wichtigen 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
    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.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"

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__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" 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.com senden, 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:

  1. Service-Principal-Object-ID in New-ServicePrincipal ist die
    der Enterprise Application, nicht die der App Registration.
  2. DKIM-CNAMEs wirklich als CNAME, nicht TXT eintragen. Sonst
    meldet das Defender-Portal „CNAME nicht gefunden" beim Aktivieren.
  3. Security Defaults überschreiben tenant- und mailbox-weite
    SMTP-AUTH-Einstellungen lautlos. Müssen aus. Per-User MFA oder
    Conditional Access als Ersatz.
  4. Tenant-Setting SmtpClientAuthenticationDisabled = True ist
    völlig okay zu lassen, solange das Mailbox-Override greift —
    was es nur tut, wenn Security Defaults aus sind.
  5. Ghost mail__options__service in der Compose-Env entfernen,
    sonst überschreibt es den Host/Port.
  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.