feat(postfach): crm@perinco.de lesend anbinden, Kontakte aus Mails
Spiegelt das Vertriebspostfach ueber Microsoft Graph ins CRM. Aus jeder Mail entstehen Kontakt-Vorschlaege, und jede Mail laesst sich an Opportunities und Kontakte haengen. Uebernommen ist die Idee aus dem Altsystem KeepTrack, nicht die Umsetzung: - Delta Query statt "$filter=isRead eq false" mit anschliessendem PATCH. Damit genuegt die Anwendungsberechtigung Mail.Read, es braucht keinen Schreibzugriff auf das Postfach, und eine vorher in Outlook geoeffnete Mail geht nicht verloren. Der Fortschritt liegt als delta_link in der Datenbank und ueberlebt Neustarts; ein Lauf ist gedeckelt und fortsetzbar. - Tenant- und Client-ID kommen aus der .env, nicht aus dem Browser. Im Altsystem konnte jeder Angemeldete das Server-Secret gegen einen fremden Tenant verwenden. - Kontaktdaten werden aus den Graph-Feldern und der Signatur gelesen, ohne Sprachmodell. Mailinhalte verlassen den Server nicht. - Ein Vorschlag ohne E-Mail-Adresse wird nicht mit einer erfundenen Adresse angelegt, sondern bleibt zur Durchsicht liegen. Ist die Adresse schon als Kontakt vorhanden, werden nur leere Felder ergaenzt. - Mails haengen als eigene Verknuepfung an Opportunity und Kontakt, nicht als Text im Notizfeld - loesbar und mehrfach moeglich. - Fremdes Mail-HTML wird serverseitig entschaerft und in einem iframe mit sandbox="" gezeigt. Externe Bilder sind per CSP gesperrt, damit das Oeffnen dem Absender nichts meldet. - Der Verbindungstest prueft Anmeldung und tatsaechlichen Postfachzugriff und benennt den wahrscheinlichen Grund. Der alte Test holte nur ein Token. Fehlen die MS_*-Werte, startet das CRM normal; die Einstellungen nennen die fehlende Variable. Neue Tabellen: crm_mail_messages, crm_mail_links, crm_mail_contact_candidates, crm_mail_sync_state, crm_settings. Uebernommene Kontakte tragen source = 'Mail'; die Kontaktliste hat dafuer einen Herkunftsfilter. Nicht enthalten: Senden, Antworten, Weiterleiten, Anhaenge und die automatische Zuordnung zu Opportunities. npm test im Verzeichnis server prueft Kontaktextraktion und Graph-Client (47 Pruefungen, ohne Framework und ohne Datenbank). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3647201111
commit
6fd713497d
@@ -0,0 +1,305 @@
|
||||
# 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<br/>App-Registrierung"]
|
||||
GR["Microsoft Graph<br/>graph.microsoft.com"]
|
||||
MB[("Postfach<br/>crm@perinco.de")]
|
||||
GR --- MB
|
||||
end
|
||||
|
||||
subgraph Server["perinco CRM Backend"]
|
||||
SCH["mail/scheduler.js<br/>node-cron, Minutentakt"]
|
||||
SY["mail/sync.js<br/>Delta Query"]
|
||||
EX["mail/extract.js<br/>Kontakte lesen"]
|
||||
ST["mail/store.js"]
|
||||
CO["mail/contacts.js<br/>Uebernahme"]
|
||||
API["routes/mail.js<br/>/api/mail"]
|
||||
end
|
||||
|
||||
DB[("perinco_CRM<br/>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 <APP-ID> `
|
||||
-PolicyScopeGroupId crm@perinco.de `
|
||||
-AccessRight RestrictAccess `
|
||||
-Description "perinco CRM: nur crm@perinco.de"
|
||||
|
||||
Test-ApplicationAccessPolicy -Identity crm@perinco.de -AppId <APP-ID> # Granted
|
||||
Test-ApplicationAccessPolicy -Identity sascha.wolf@perinco.de -AppId <APP-ID> # 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?
|
||||
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.
|
||||
* **Zeitfilter mit Ersatzweg.** Der Erstabgleich versucht
|
||||
`$filter=receivedDateTime ge ...`. Die Filterunterstuetzung in Delta-Abfragen
|
||||
ist eng begrenzt; lehnt Graph ab (400), laeuft der Abgleich ohne Filter und die
|
||||
Zeitgrenze greift beim Speichern.
|
||||
|
||||
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<br/>from (Graph)"]
|
||||
M --> E["Empfaenger<br/>to, cc, bcc (Graph)"]
|
||||
M --> S["Signaturblock<br/>Text nach der Grussformel"]
|
||||
M --> T["Fliesstext<br/>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<br/>und Zuversicht ≥ 70?"}
|
||||
Z -->|ja, Automatik an| N["Kontakt anlegen<br/>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 `<iframe sandbox="">` **ohne jedes allow-Flag**. Das
|
||||
Dokument bekommt eine eigene, undurchsichtige Origin und kann weder Skripte
|
||||
ausfuehren noch auf die Anwendung zugreifen.
|
||||
|
||||
`allow-same-origin` waere hier ein Fehler: ein `srcDoc`-Dokument erbt damit die
|
||||
Origin der Anwendung und koennte `localStorage` lesen - also das Anmeldetoken.
|
||||
Weil die Hoehe des Inhalts ohne dieses Flag nicht auslesbar ist, hat der Rahmen
|
||||
eine feste Hoehe mit eigenem Rollbalken und einen Schalter „Groesser".
|
||||
|
||||
Zusaetzlich entfernt das Backend beim Speichern Skripte, eingebettete Rahmen,
|
||||
Formulare, Ereignis-Attribute und gefaehrliche URL-Schemata
|
||||
(`mail/html.js`, `sanitizeHtml`).
|
||||
|
||||
Externe Bilder sind per Content-Security-Policy im eingebetteten Dokument
|
||||
gesperrt und lassen sich pro Mail freigeben - andernfalls meldet jedes Oeffnen
|
||||
dem Absender, dass die Mail gelesen wurde.
|
||||
|
||||
## Endpunkte
|
||||
|
||||
Alle unter `/api/mail`, alle hinter `requireAuth`.
|
||||
|
||||
| Methode | Pfad | Rolle | Zweck |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/status` | alle | Betriebszustand, Zaehler, letzter Fehler |
|
||||
| GET | `/messages` | alle | Liste; `search`, `filter`, `limit`, `offset` |
|
||||
| GET | `/messages/:id` | alle | Mail mit Text, Zuordnungen, Vorschlaegen |
|
||||
| POST | `/messages/:id/links` | alle | an Kontakt und/oder Opportunity haengen |
|
||||
| DELETE | `/links/:id` | alle | Zuordnung loesen |
|
||||
| GET | `/candidates` | alle | Kontakt-Vorschlaege, `status` waehlbar |
|
||||
| POST | `/candidates/:id/adopt` | alle | Kontakt anlegen bzw. ergaenzen |
|
||||
| POST | `/candidates/:id/reject` | alle | Vorschlag verwerfen |
|
||||
| GET | `/settings` | admin | verstellbare Einstellungen |
|
||||
| POST | `/settings` | admin | Einstellungen speichern |
|
||||
| POST | `/test` | admin | Anmeldung **und** Postfachzugriff pruefen |
|
||||
| POST | `/sync` | admin | Abgleich anstossen, `{"reset":true}` von vorn |
|
||||
|
||||
Lesen ist fuer jeden Angemeldeten frei - `crm@perinco.de` ist ein gemeinsames
|
||||
Vertriebspostfach. Konfigurieren, Testen und Anstossen sind Administratoren
|
||||
vorbehalten.
|
||||
|
||||
`filter` in der Liste: `unlinked` (noch nirgends zugeordnet - die Arbeitsliste),
|
||||
`candidates` (offene Kontakt-Vorschlaege), `linked` (bereits zugeordnet).
|
||||
|
||||
## Was anders ist als im Altsystem
|
||||
|
||||
| Thema | KeepTrack | perinco CRM |
|
||||
| --- | --- | --- |
|
||||
| Fortschritt | `$filter=isRead eq false`, danach `PATCH isRead` | Delta Query mit gespeichertem `delta_link` |
|
||||
| Berechtigung | Schreibzugriff noetig | `Mail.Read` genuegt |
|
||||
| Der Haken daran | `PATCH` ging auf `/me/…` - unter App-only gibt es kein `/me`. Der Aufruf lief in einen 400, die Antwort wurde nicht geprueft. Mails blieben ungelesen und wurden bei **jedem** Lauf erneut durch das Sprachmodell geschickt. | entfaellt |
|
||||
| Tenant-/Client-ID | kamen vom Browser mit; jeder Angemeldete konnte das Server-Secret gegen einen fremden Tenant verwenden | serverseitig aus der `.env`, nie im Browser |
|
||||
| Kontakte ohne Adresse | Platzhalter `name@kontakt-aus-email.local` erfunden, um eine Datenbankbedingung zu erfuellen | Kontakt ohne Adresse; Vorschlag bleibt zur Durchsicht liegen |
|
||||
| Vorhandener Kontakt | wurde uebersprungen | leere Felder werden ergaenzt, gepflegte bleiben |
|
||||
| Kontaktdaten lesen | kompletter Mailtext an OpenAI, auch fuer Daten, die Graph strukturiert liefert | Graph-Felder plus Signaturauswertung, kein externer Dienst |
|
||||
| Mail am Deal | Text in das Freitextfeld `notizen` vorangestellt | eigene Tabelle `crm_mail_links`, loesbar, mehrfach moeglich |
|
||||
| Verbindungstest | prueft nur, ob ein Token kommt | prueft Token **und** Postfachzugriff, mit Hinweis je Fehlercode |
|
||||
| Paging | `@odata.nextLink` ignoriert, `$top=50` fest | Seiten werden gefolgt, je Lauf gedeckelt, fortsetzbar |
|
||||
| HTML-Anzeige | ungefiltert | serverseitig entschaerft, `sandbox=""`, externe Bilder gesperrt |
|
||||
| Zeitangaben | gemischt | durchgehend UTC in der Datenbank, Umrechnung im Frontend |
|
||||
|
||||
## Was noch nicht drin ist
|
||||
|
||||
* **Senden, Antworten, Weiterleiten.** Braucht `Mail.ReadWrite` und `Mail.Send`
|
||||
und damit eine erneute Administratorzustimmung.
|
||||
* **Anhaenge.** `has_attachments` wird gefuehrt und angezeigt, die Dateien
|
||||
selbst werden nicht geholt.
|
||||
* **Automatische Zuordnung zu Opportunities.** Die Zuordnung erfolgt per Hand;
|
||||
der Dialog schlaegt anhand der Mailadressen Kontakt und Opportunity vor. Eine
|
||||
Bewertung durch ein Sprachmodell waere der naechste Schritt - dann bitte mit
|
||||
dem Schutz gegen Prompt-Injection, der im Altsystem in `mail-sync` fehlte.
|
||||
* **Echtzeit statt Intervall.** Graph Change Notifications brauchen einen
|
||||
oeffentlich erreichbaren Endpunkt (haben wir) und eine Erneuerung der
|
||||
Subscription etwa alle drei Tage.
|
||||
|
||||
## Stoerungssuche
|
||||
|
||||
| Beobachtung | Ursache und Abhilfe |
|
||||
| --- | --- |
|
||||
| Einstellungen melden fehlende Variablen | `server/.env` ergaenzen, Dienst neu starten - die Werte werden beim Start gelesen |
|
||||
| Test scheitert „bei der Anmeldung" | Tenant-ID, Client-ID oder Geheimnis falsch, oder das Geheimnis ist abgelaufen |
|
||||
| Test scheitert „beim Postfachzugriff", `ErrorAccessDenied` | Administratorzustimmung fehlt, oder die ApplicationAccessPolicy erlaubt dieses Postfach nicht |
|
||||
| Test scheitert mit `ResourceNotFound` | `MS_MAILBOX` zeigt auf ein Postfach, das es nicht gibt |
|
||||
| „Erstabgleich laeuft noch" bleibt stehen | Log auf `[Mail]` pruefen; `MAIL_MAX_PAGES` erhoehen, wenn es zu langsam vorangeht |
|
||||
| Keine Kontakt-Vorschlaege | stehen alle Absender auf einer eigenen Domain? `mail_internal_domains` pruefen |
|
||||
| Doppelte Kontakte | tritt nicht auf: Abgleich erfolgt ueber die Adresse in Kleinschreibung; bei Treffer wird ergaenzt statt angelegt |
|
||||
| Zaehler und Liste weichen ab | die Liste blendet im Postfach geloeschte Mails aus, der Zaehler in den Einstellungen ebenfalls - Zuordnungen bleiben trotzdem erhalten |
|
||||
Reference in New Issue
Block a user