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:
- Microsoft Graph Subscriptions — pro Mailbox eine Subscription für Mail-Events + eine für Calendar-Events
- Webhook-Endpoint in SpeamCore empfängt Push-Notifications und triggert sofortigen Sync
- Push-Service schickt eigene SC-Änderungen zurück an Microsoft
MicrosoftSubscription-Modell
Neu in Welle 149: das MicrosoftSubscription-Modell mit 7 Kernfeldern:
| Feld | Wirkung |
|---|---|
mailboxId | FK zum Postfach |
resourceType | mail oder calendar — pro Mailbox je eine Subscription je Typ |
changeType | created,updated,deleted (kommasepariert) |
notificationUrl | unser Webhook-Endpoint (z. B. https://app.speamcore.com/api/webhooks/microsoft/mail) |
clientState | Random-Secret pro Subscription — bei jedem Webhook geprüft (Schutz vor Spoofing) |
expiresAt | wann die Subscription bei Microsoft abläuft (max. ~72 h, je nach Resource) |
microsoftSubscriptionId | ID, 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-Change | Was wir lesen | SpeamCore-Update |
|---|---|---|
updated mit neuer isRead | Message.isRead via GET /me/messages/:id | MailEmployeeState.isRead |
updated mit neuem flag | Message.flag.flagStatus | MailEmployeeState.flagStatus |
updated mit neuer parentFolderId | Mapping zur SC-MailFolder.id | Mail.mailFolderId |
deleted (Mail in Trash gelöscht) | — | Mail.isTrashed = true |
created | Komplette Mail laden | Mail-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-Change | Wer triggert? | Graph-API-Call |
|---|---|---|
MailEmployeeState.isRead | Primary-Owner der Mailbox | PATCH /me/messages/:id mit { isRead } |
MailEmployeeState.flagStatus | Primary-Owner | PATCH /me/messages/:id mit { flag: {...} } |
Mail.mailFolderId (Move) | Primary-Owner | POST /me/messages/:id/move mit { destinationId } |
Mail.isTrashed = true | Primary-Owner | POST /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.servicezieht den kompletten Folder-Tree der Mailbox- Upserts in
MailFoldermitmicrosoftFolderIdals 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/:idmit neuemexpirationDateTime - 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
| Problem | Auswirkung | Behebung |
|---|---|---|
cloudflared tunnel lokal abgebrochen | Webhook-URL erreichbar nicht mehr → kein Push | Tunnel 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-Fallback | Renewal-Worker manuell triggern, ggf. neu anlegen |
| Spoofing-Versuch auf Webhook-Endpoint | 401-Response | clientState-Check schützt, kein Schaden |
| Mailbox-OAuth-Token abgelaufen | Push-Back-Calls bekommen 401 | OAuth-Renewal nötig (separater Mechanismus) |
| Endlos-Loop trotz Wert-Vergleich | sollte unmöglich sein | Logs prüfen — falls doch: Sync-Job temporär deaktivieren |
Verknüpfungen
- Mail (Modul) — Hauptmodul mit allen User-sichtbaren Workflows
- Mail-Konten — Mailbox-Setup mit OAuth + Subscription-Aktivierung
- Brief vs. Mail vs. ePost (Konzept) — Abgrenzung zum Brief-Modul
Versionshinweise
- 2026-05-26 (Welle 149): Initiale Veröffentlichung. Quelle: BE-Commit
8d8968d8(1535 LOC diff, 16 Dateien) — Neue ModelsMicrosoftSubscription+ ServicesmicrosoftSubscription.service(313 LOC),microsoftMailPush.service(189 LOC),mailFolderSync.service(162 LOC). Workermicrosoft-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.