# 11 Postfachanbindung (Microsoft 365) Das gemeinsame Vertriebspostfach `crm@perinco.de` wird **lesend** in das CRM gespiegelt. Aus jeder Mail entstehen Kontakt-Vorschläge, und jede Mail lässt sich an eine Opportunity und an Kontakte hängen. Vorbild ist die gleiche Funktion im Altsystem KeepTrack. Übernommen wurde die Idee, nicht die Umsetzung - die Unterschiede stehen unten unter [Was anders ist als im Altsystem](#was-anders-ist-als-im-altsystem) und sind der Grund für mehrere Entwurfsentscheidungen. ## Überblick ```mermaid flowchart LR subgraph M365["Microsoft 365"] ENT["Entra ID
App-Registrierung"] GR["Microsoft Graph
graph.microsoft.com"] MB[("Postfach
crm@perinco.de")] GR --- MB end subgraph Server["perinco CRM Backend"] SCH["mail/scheduler.js
node-cron, Minutentakt"] SY["mail/sync.js
Delta Query"] EX["mail/extract.js
Kontakte lesen"] ST["mail/store.js"] CO["mail/contacts.js
Übernahme"] API["routes/mail.js
/api/mail"] end DB[("perinco_CRM
crm_mail_*")] SPA["Ansicht Mails"] SCH --> SY SY -->|"Token: client_credentials"| ENT SY -->|"GET .../messages/delta"| GR SY --> EX EX --> ST ST --> DB EX --> CO CO --> DB SPA -->|"/api/mail/..."| API API --> DB API --> SY ``` ## Was in Microsoft 365 eingerichtet sein muss Genau **eine** Berechtigung, **ein** Zustimmungsklick, **eine** Zugriffsrichtlinie. Kein Schreibrecht, kein Redirect-URI, kein interaktiver Login. > Dieselbe App-Registrierung trägt seit dem 21.09.2026 zusätzlich die > [Anmeldung über Microsoft](13-anmeldung-sso.md). Die braucht einen > Redirect-URI und delegierte Berechtigungen - beides betrifft nur den > Anmeldeweg, die hier beschriebene App-only-Anbindung bleibt unberührt. 1. **App-Registrierung** in Entra (Name z. B. `perinco CRM`), Kontotyp „nur dieses Verzeichnis", Redirect-URI leer lassen. Anwendungs-ID und Verzeichnis-ID notieren. 2. **Client-Geheimnis** anlegen und den Wert sofort kopieren. 3. **API-Berechtigung** Microsoft Graph → *Anwendungsberechtigungen* → `Mail.Read`, danach Administratorzustimmung erteilen. `Mail.ReadBasic` genügt nicht, es liefert keinen Mailtext. 4. **Zugriff auf dieses eine Postfach begrenzen.** Anwendungsberechtigungen gelten tenant-weit; ohne Richtlinie darf die App jedes Postfach lesen: ```powershell Connect-ExchangeOnline -UserPrincipalName adm.sascha.wolf@perinco.onmicrosoft.com New-ApplicationAccessPolicy -AppId ` -PolicyScopeGroupId crm@perinco.de ` -AccessRight RestrictAccess ` -Description "perinco CRM: nur crm@perinco.de" Test-ApplicationAccessPolicy -Identity crm@perinco.de -AppId # Granted Test-ApplicationAccessPolicy -Identity sascha.wolf@perinco.de -AppId # Denied ``` `Add-MailboxPermission` ist hier **wirkungslos** - das ist der Weg für delegierten Zugriff bzw. EWS-Impersonation. Bei App-only zählt allein die Zugriffsrichtlinie. Der Server muss ausgehend `login.microsoftonline.com` und `graph.microsoft.com` auf 443 erreichen. ## Umgebungsvariablen Zugangsdaten stehen ausschließlich in `server/.env` - nie in der Datenbank und nie in einer API-Antwort. Ein Datenbank-Dump enthält damit keine Geheimnisse. | Variable | Pflicht | Bedeutung | | --- | --- | --- | | `MS_TENANT_ID` | ja | Verzeichnis-ID des Tenants | | `MS_CLIENT_ID` | ja | Anwendungs-ID der App-Registrierung | | `MS_CLIENT_SECRET` | ja | Client-Geheimnis | | `MS_MAILBOX` | ja | abzugleichendes Postfach, Vorgabe `crm@perinco.de` | | `MS_MAIL_FOLDER` | nein | Ordner, Vorgabe `inbox` | | `MAIL_SYNC` | nein | `false` schaltet den Hintergrundlauf hart ab | | `MAIL_MAX_PAGES` | nein | Delta-Seiten je Lauf, Vorgabe 20 | | `MAIL_MAX_BODY_CHARS` | nein | Obergrenze je Mailtext, Vorgabe 400 000 | Fehlt einer der Pflichtwerte, **startet das CRM normal**. Der Hintergrundlauf bleibt aus, und die Einstellungen nennen die fehlende Variable beim Namen. Genau wie beim Scanner soll eine unfertige Teilfunktion den Betrieb nicht verhindern. Verstellbares liegt dagegen in `crm_settings` und ist unter *Einstellungen → Postfach* erreichbar: `mail_sync_active`, `mail_sync_interval`, `mail_auto_contacts`, `mail_since_days`, `mail_internal_domains`. ## Abgleich per Delta Query ```mermaid sequenceDiagram participant S as scheduler.js participant Y as sync.js participant G as Graph participant D as MariaDB S->>D: last_run_at, has_more Note over S: fällig? sonst nichts tun S->>Y: runMailSync() Y->>D: delta_link lesen alt erster Lauf Y->>G: /mailFolders/inbox/messages/delta?$filter=receivedDateTime ge ... else spaeter Y->>G: gespeicherter delta_link end G-->>Y: Seite mit Metadaten + nextLink/deltaLink loop je Nachricht Y->>D: bekannt? Text vorhanden? im CRM gelöscht? Note over Y: im Papierkorb oder endgültig gelöscht? dann weiter Note over Y: unbekannt und älter als die Grenze? dann weiter opt Text fehlt Y->>G: /messages/{id}?$select=body end Y->>D: upsert crm_mail_messages opt neue Mail Y->>D: Kontakt-Vorschläge anlegen opt gesichert und Automatik an Y->>D: Kontakt anlegen bzw. ergänzen end end end Y->>D: delta_link + has_more sichern ``` Wesentliche Eigenschaften: * **Kein Schreibzugriff.** Der Fortschritt steht im `delta_link` in der Datenbank, nicht im `isRead`-Kennzeichen des Postfachs. Deshalb genügt `Mail.Read`, und deshalb geht keine Mail verloren, wenn ein Mensch sie vorher in Outlook öffnet. * **Fortsetzbar.** Nach jeder Seite wird der Verweis gesichert. Ein Absturz oder ein Deployment mitten im Erstabgleich kostet höchstens eine Seite. * **Gedeckelt.** `MAIL_MAX_PAGES` begrenzt einen Lauf. Ist danach noch etwas offen, setzt der nächste Lauf nach 15 Sekunden fort (`has_more`), statt das normale Intervall abzuwarten. * **Selbstheilend.** Antwortet Graph mit `410 Gone` bzw. `resyncRequired`, wird der Verweis verworfen und beim nächsten Lauf neu aufgebaut. Vorhandene Mails werden am SHA-256 der Graph-ID wiedererkannt, es entstehen keine Doppelten. * **Nur Metadaten in der Delta-Abfrage.** Der Mailtext wird einzeln nachgeholt und nur, wenn er fehlt. Ein reines Metadaten-Update (etwa ein geändertes Gelesen-Kennzeichen) löscht den gespeicherten Text nicht. * **Zeitgrenze doppelt.** Der Erstabgleich versucht `$filter=receivedDateTime ge ...`; die Filterunterstützung in Delta-Abfragen ist eng begrenzt, bei einem 400 läuft er ohne Filter weiter. Der Filter ist deshalb nur eine Ersparnis - geprüft wird die Grenze **immer** beim Speichern, und zwar nur für noch unbekannte Mails. Ein an den ersten Lauf gebundener Merker wäre hier falsch: ein ungefilterter Erstabgleich zieht sich über mehrere Läufe, und in den folgenden käme der Rest des Postfachs trotz Grenze herein. Im Postfach gelöschte Mails werden mit `is_deleted = 1` gekennzeichnet, nicht entfernt: eine an eine Opportunity gehängte Mail soll nicht aus dem CRM verschwinden, weil jemand sein Postfach aufräumt. ## Kontakte aus Mails Die Extraktion arbeitet **ohne Sprachmodell**. Mailinhalte verlassen den Server nicht. ```mermaid flowchart TD M["Mail"] --> A["Absender
from (Graph)"] M --> E["Empfänger
to, cc, bcc (Graph)"] M --> S["Signaturblock
Text nach der Grussformel"] M --> T["Fliesstext
Anrede + Name + Rufnummer"] A --> F{"Filter"} E --> F S --> F T --> F F -->|"eigene Domain"| X1["verworfen"] F -->|"info@, no-reply@, ..."| X2["verworfen"] F -->|"Person"| C["Vorschlag mit Zuversicht"] C --> Z{"E-Mail vorhanden
und Zuversicht ≥ 70?"} Z -->|ja, Automatik an| N["Eintrag in 'Aus Mails'
mail_pool = 1"] Z -->|"Adresse dort schon"| G["Eintrag ergänzen"] Z -->|nein| R["bleibt zur Durchsicht"] ``` Zuversicht je Herkunft: Absender 90 (95, wenn die Signatur die Adresse bestätigt), Empfänger 78, weitere Adresse aus der Signatur 55, im Text genannte Person 35. Aus dem Signaturblock kommen Telefon und Mobil (nach Beschriftung unterschieden), Firma (an der Rechtsform erkannt), Position, Straße, PLZ, Ort, Website und LinkedIn-Profil. Zwei Entscheidungen, die die Trefferqualität tragen: * **Im Fliesstext ist eine Anrede Pflicht** („Herrn Max Mustermann Tel. …"). Ohne diese Bedingung liest der Musterabgleich jede Folge großgeschriebener Wörter als Namen; „Zweiter Ansprechpartner" landete so als Kontakt. Ein verpasster Treffer ist harmloser als ein falscher - eine Durchsichtsliste voll Unsinn schaut niemand an. * **Der Anzeigename schlägt die Adresse.** Bei „Müller" plus `k.mueller@…` wird der Vorname aus der Adresse genommen, der Nachname aber aus dem Anzeigenamen - dort stehen die Umlaute richtig. ## Vorbereich „Aus Mails" Der Hintergrundlauf schreibt **nicht** in den Kontaktbestand. Was er findet, landet in einem Vorbereich: `crm_leads.mail_pool = 1`. Die Kontaktansicht zeigt nur `mail_pool = 0`, der Vorbereich steht als Unterpunkt *Aus Mails* unter dem Menüpunkt *Kontakte*. Der Grund ist Pflegbarkeit: die Kontaktliste ist eine gepflegte Liste, an der Opportunities, Interviews und Netzwerk hängen. Ein Abgleich, der ungefragt jede Adresse aus jeder Mail hineinschreibt, macht sie unbrauchbar - genau daran ist das Altsystem gescheitert. Deshalb sind Funde aus Mails Rohmaterial, bis jemand entscheidet. Eine Spalte statt einer zweiten Tabelle, weil `crm_mail_links` und `crm_mail_contact_candidates` per Fremdschlüssel auf `crm_leads` zeigen: der Vorbereich behält damit seine Mailbezüge, ohne dass es zwei Wege gibt, eine Person mit einer Mail zu verbinden. ```mermaid flowchart LR P["Aus Mails
mail_pool = 1"] --> D{"Person schon
in den Kontakten?"} D -->|nein| NEU["In Kontakte übernehmen
Kopie mit mail_pool = 0"] D -->|"ja - Adresse oder Name"| GOLD["gold markiert
Abweichungen hervorgehoben"] GOLD --> M["Felder einzeln in den
vorhandenen Kontakt übernehmen"] GOLD --> N2["trotzdem neu anlegen"] P --> W["entfernen"] ``` Der Abgleich gegen die Kontakte läuft im Browser (`mailPool.ts`): erst über die E-Mail-Adresse, sonst über den vollen Namen. Telefonnummern werden dabei über ihre Ziffern verglichen, damit „0221 123-4" und „02211234" nicht als Abweichung erscheinen. Beim Übernehmen sind nur die **Lücken** vorausgewählt - Felder, in denen der Kontakt noch nichts hat. Werte, die einen gepflegten Eintrag ersetzen würden, sind als *überschreibt* gekennzeichnet und müssen bewusst angehakt werden: der Mailfund kann aus einer alten Signatur stammen. Übernommene Kontakte tragen weiterhin `crm_leads.source = 'Mail'` - die Kontaktliste hat dafür den Herkunftsfilter *Aus Mails übernommen*. Bestandsumstellung: `db.js` verschiebt beim ersten Start alle vorhandenen Kontakte mit `source = 'Mail'` in den Vorbereich. Das läuft genau einmal, festgehalten im Merker `leads_mail_pool_migrated` in `crm_settings` - sonst wanderten übernommene Kontakte bei jedem Neustart zurück. ## Anzeige des Mailtexts Fremdes HTML läuft in einem `