# 11 Postfachanbindung (Microsoft 365)
Das gemeinsame Vertriebspostfach `crm@perinco.de` wird **lesend** in das CRM
gespiegelt. Aus jeder Mail entstehen Kontakt-Vorschlaege, und jede Mail laesst
sich an eine Opportunity und an Kontakte haengen.
Vorbild ist die gleiche Funktion im Altsystem KeepTrack. Uebernommen 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 fuer mehrere Entwurfsentscheidungen.
## Ueberblick
```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
Uebernahme"]
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.
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` genuegt 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 fuer
delegierten Zugriff bzw. EWS-Impersonation. Bei App-only zaehlt allein die
Zugriffsrichtlinie.
Der Server muss ausgehend `login.microsoftonline.com` und `graph.microsoft.com`
auf 443 erreichen.
## Umgebungsvariablen
Zugangsdaten stehen ausschliesslich in `server/.env` - nie in der Datenbank und
nie in einer API-Antwort. Ein Datenbank-Dump enthaelt 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: faellig? 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?
Note over Y: unbekannt und aelter 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-Vorschlaege anlegen
opt gesichert und Automatik an
Y->>D: Kontakt anlegen bzw. ergaenzen
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 genuegt
`Mail.Read`, und deshalb geht keine Mail verloren, wenn ein Mensch sie vorher
in Outlook oeffnet.
* **Fortsetzbar.** Nach jeder Seite wird der Verweis gesichert. Ein Absturz oder
ein Deployment mitten im Erstabgleich kostet hoechstens eine Seite.
* **Gedeckelt.** `MAIL_MAX_PAGES` begrenzt einen Lauf. Ist danach noch etwas
offen, setzt der naechste 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 naechsten 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 geaendertes
Gelesen-Kennzeichen) loescht den gespeicherten Text nicht.
* **Zeitgrenze doppelt.** Der Erstabgleich versucht
`$filter=receivedDateTime ge ...`; die Filterunterstuetzung in Delta-Abfragen
ist eng begrenzt, bei einem 400 laeuft er ohne Filter weiter. Der Filter ist
deshalb nur eine Ersparnis - geprueft wird die Grenze **immer** beim Speichern,
und zwar nur fuer noch unbekannte Mails. Ein an den ersten Lauf gebundener
Merker waere hier falsch: ein ungefilterter Erstabgleich zieht sich ueber
mehrere Laeufe, und in den folgenden kaeme der Rest des Postfachs trotz Grenze
herein.
Im Postfach geloeschte Mails werden mit `is_deleted = 1` gekennzeichnet, nicht
entfernt: eine an eine Opportunity gehaengte Mail soll nicht aus dem CRM
verschwinden, weil jemand sein Postfach aufraeumt.
## Kontakte aus Mails
Die Extraktion arbeitet **ohne Sprachmodell**. Mailinhalte verlassen den Server
nicht.
```mermaid
flowchart TD
M["Mail"] --> A["Absender
from (Graph)"]
M --> E["Empfaenger
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["Kontakt anlegen
source = 'Mail'"]
Z -->|Adresse existiert| G["vorhandenen Kontakt ergaenzen"]
Z -->|nein| R["bleibt zur Durchsicht"]
```
Zuversicht je Herkunft: Absender 90 (95, wenn die Signatur die Adresse
bestaetigt), Empfaenger 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, Strasse, PLZ, Ort,
Website und LinkedIn-Profil.
Zwei Entscheidungen, die die Trefferqualitaet tragen:
* **Im Fliesstext ist eine Anrede Pflicht** („Herrn Max Mustermann Tel. …").
Ohne diese Bedingung liest der Musterabgleich jede Folge grossgeschriebener
Woerter 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 schlaegt die Adresse.** Bei „Mueller" plus
`k.mueller@…` wird der Vorname aus der Adresse genommen, der Nachname aber aus
dem Anzeigenamen - dort stehen die Umlaute richtig.
Uebernommene Kontakte tragen `crm_leads.source = 'Mail'`. Das ist die
„Unterkategorie": die Kontaktliste hat dafuer einen Herkunftsfilter, ein eigenes
Feld war nicht noetig.
## Anzeige des Mailtexts
Fremdes HTML laeuft in einem `