Zum Hauptinhalt springen

Mail Microsoft Graph-Sync (bidirektional)

Seit Welle 149 hält SpeamCore den Mail-State (isRead, Flag, Folder, Trash) bidirektional mit Microsoft 365 synchron — wenn ein User im Outlook Web eine Mail liest, wird sie sofort in SpeamCore als gelesen markiert. Und wenn ein User in SpeamCore eine Mail in einen anderen Folder verschiebt, kommt diese Bewegung in Outlook Desktop auch sofort an.

Diese Konzept-Seite erklärt, wie das technisch funktioniert: Microsoft-Graph-Webhook-Subscriptions, der Sync-Mechanismus in beide Richtungen, Loop-Prevention und automatische Renewal.

Architektur-Überblick

Drei Komponenten arbeiten zusammen:

  1. Microsoft Graph Subscriptions — pro Mailbox eine Subscription für Mail-Events + eine für Calendar-Events
  2. Webhook-Endpoint in SpeamCore empfängt Push-Notifications und triggert sofortigen Sync
  3. Push-Service schickt eigene SC-Änderungen zurück an Microsoft

MicrosoftSubscription-Modell

Neu in Welle 149: das MicrosoftSubscription-Modell mit 7 Kernfeldern:

FeldWirkung
mailboxIdFK zum Postfach
resourceTypemail oder calendar — pro Mailbox je eine Subscription je Typ
changeTypecreated,updated,deleted (kommasepariert)
notificationUrlunser Webhook-Endpoint (z. B. https://app.speamcore.com/api/webhooks/microsoft/mail)
clientStateRandom-Secret pro Subscription — bei jedem Webhook geprüft (Schutz vor Spoofing)
expiresAtwann die Subscription bei Microsoft abläuft (max. ~72 h, je nach Resource)
microsoftSubscriptionIdID, die Microsoft uns vergibt — Pflicht für Renewal + Delete

Pro Mailbox 2 Subscriptions (1 für Mail, 1 für Calendar). Bei 10 angebundenen Mailboxen also 20 lebende Subscriptions, die alle <72 h vor Ablauf erneuert werden müssen.

Webhook-Handshake (validationToken)

Wenn SpeamCore eine neue Subscription erstellt, ruft Microsoft sofort unseren Webhook-Endpoint mit einem validationToken auf und erwartet diesen unverändert zurück (HTTP 200, Content-Type text/plain):

POST /api/webhooks/microsoft/mail?validationToken=abc123def456

Unser Endpoint antwortet mit dem Token im Response-Body. Erst wenn dieser Handshake erfolgreich war, beginnt Microsoft mit dem eigentlichen Push.

Lokal in der Entwicklung brauchst du einen öffentlichen Webhook-Endpoint — über cloudflared tunnel --url localhost:3001 einen ngrok-ähnlichen Tunnel aufbauen, die URL in MICROSOFT_WEBHOOK_BASE_URL env setzen.

clientState — Schutz vor Spoofing

Jede Subscription bekommt einen Random-Secret als clientState. Microsoft schickt diesen bei jedem Push mit. Unser Webhook prüft:

if (notification.clientState !== subscription.clientState) {
return 401 // Ignorieren, nicht echt
}

So kann niemand von außen einen gefakten Webhook-Push an unsere URL feuern — der clientState ist nur Microsoft + uns bekannt.

MS→SC Sync (Microsoft schickt Push, wir reagieren)

Sobald Microsoft eine Änderung an einer Mail registriert (User hat in Outlook Web gelesen, verschoben, gelöscht), pusht Graph eine Notification an unseren Webhook. Was wir daraufhin tun:

Microsoft-ChangeWas wir lesenSpeamCore-Update
updated mit neuer isReadMessage.isRead via GET /me/messages/:idMailEmployeeState.isRead
updated mit neuem flagMessage.flag.flagStatusMailEmployeeState.flagStatus
updated mit neuer parentFolderIdMapping zur SC-MailFolder.idMail.mailFolderId
deleted (Mail in Trash gelöscht)Mail.isTrashed = true
createdKomplette Mail ladenMail-Anlage + Recipient-Resolve + KI-Trigger

Performance: Pro Push wird ein dedupliziertes BullMQ-Job angelegt — wenn 10 Pushes binnen 2 Sekunden zur selben Mail kommen, wird der Sync nur einmal ausgeführt. Vorher (vor Welle 149) lief der Sync alle 60 Sekunden als Polling — jetzt nahezu Echtzeit.

SC→MS Sync (User ändert in SpeamCore, wir pushen zurück)

Wenn ein User in SpeamCore eine Mail liest oder verschiebt, pusht der microsoftMailPush.service die Änderung an Microsoft Graph:

SC-ChangeWer triggert?Graph-API-Call
MailEmployeeState.isReadPrimary-Owner der MailboxPATCH /me/messages/:id mit { isRead }
MailEmployeeState.flagStatusPrimary-OwnerPATCH /me/messages/:id mit { flag: {...} }
Mail.mailFolderId (Move)Primary-OwnerPOST /me/messages/:id/move mit { destinationId }
Mail.isTrashed = truePrimary-OwnerPOST /me/messages/:id/move mit Trash-ID

Wer ist Primary-Owner? Pro Mailbox hat genau ein Mitarbeiter den MailboxEmployee.isPrimaryOwner = true. Nur dessen Aktionen werden zurück gepusht — bei Shared-Mailboxen mit mehreren Lesern verhindert das, dass jedes Read-Event 5× gepusht wird.

Loop-Prevention

Naiver Sync wäre eine Endlos-Schleife:

SC ← MS (push): isRead=true
SC → MS (push back): isRead=true
MS ← SC (push back): isRead=true
MS → SC (push): isRead=true

SpeamCore hat zwei Mechanismen, das zu verhindern:

1. Wert-Vergleich (nicht Zeitstempel)

Vor dem Push prüft der Service: Ist der neue Wert anders als der bisher gespeicherte? Wenn nicht, wird nichts gepusht.

if (currentMailEmployeeState.isRead === newIsRead) {
return; // kein Push, kein Loop
}

Damit endet die Schleife sofort beim ersten Push-Back — der Wert ist identisch.

2. Primary-Owner-Check

Beim MS→SC-Sync setzen wir den Wert für alle Mitarbeiter im MailboxEmployee-Team. Aber beim SC→MS-Push schickt nur der Primary-Owner. Wenn jemand anders in SpeamCore liest, ändert das nichts in Microsoft — bei Shared-Mailboxen ist das gewollt.

Folder-Tree-Sync

Wenn ein User in Outlook einen neuen Folder anlegt oder umbenennt, kriegt unser Webhook auch das mit (resourceType = mail umfasst Folder-Änderungen):

  • mailFolderSync.service zieht den kompletten Folder-Tree der Mailbox
  • Upserts in MailFolder mit microsoftFolderId als Lookup-Key
  • Umbenennungen werden in SpeamCore übernommen
  • Folder-Reihenfolge wird nicht synchronisiert (SpeamCore-Reihenfolge bleibt eigen)

Subscription-Renewal-Worker

Microsoft-Subscriptions haben eine maximale Lebensdauer (für mail ca. 72 Stunden, für calendar ähnlich). Vor Ablauf müssen wir renewen, sonst stoppen die Pushes — und der bidirektionale Sync wäre tot.

Der microsoft-subscription-renewal.worker.ts läuft:

  • Cron: alle 12 Stunden
  • Boot-Run: beim Server-Start, damit nach Restart-Lücken nichts ausläuft
  • Logik: Lädt alle Subscriptions mit expiresAt < (now + 24 h)PATCH /subscriptions/:id mit neuem expirationDateTime
  • Recreate-Fallback: Wenn Renewal fehlschlägt (z. B. weil die Subscription bei Microsoft schon weg ist) → komplett neu anlegen und in unsere DB upserten

Fehler-Szenarien

ProblemAuswirkungBehebung
cloudflared tunnel lokal abgebrochenWebhook-URL erreichbar nicht mehr → kein PushTunnel neu starten + MICROSOFT_WEBHOOK_BASE_URL env neu setzen + Subscriptions neu anlegen
Microsoft-Subscription läuft ab (>72 h ohne Renewal)Push stoppt, Sync nur noch über Polling-FallbackRenewal-Worker manuell triggern, ggf. neu anlegen
Spoofing-Versuch auf Webhook-Endpoint401-ResponseclientState-Check schützt, kein Schaden
Mailbox-OAuth-Token abgelaufenPush-Back-Calls bekommen 401OAuth-Renewal nötig (separater Mechanismus)
Endlos-Loop trotz Wert-Vergleichsollte unmöglich seinLogs prüfen — falls doch: Sync-Job temporär deaktivieren

Verknüpfungen

Versionshinweise

  • 2026-05-26 (Welle 149): Initiale Veröffentlichung. Quelle: BE-Commit 8d8968d8 (1535 LOC diff, 16 Dateien) — Neue Models MicrosoftSubscription + Services microsoftSubscription.service (313 LOC), microsoftMailPush.service (189 LOC), mailFolderSync.service (162 LOC). Worker microsoft-subscription-renewal.worker.ts (Cron 12 h + Boot-Run). Webhook-Router /api/webhooks/microsoft/{mail,calendar} (103 LOC). Bidirektionaler Sync ersetzt das vorherige 60-Sekunden-Polling — nahezu Echtzeit.