Lehnt Graph den Serverfilter ab, laeuft der Erstabgleich ohne Filter - und zieht sich dann ueber mehrere Laeufe. Der Merker dafuer galt nur im ersten Lauf, in allen folgenden waere der Rest des Postfachs trotz eingestellter Grenze hereingekommen. Die Grenze wird jetzt immer geprueft, und zwar nur fuer noch unbekannte Mails: eine bereits gespiegelte Mail soll auch dann aktualisiert werden, wenn sie aus dem Zeitfenster gelaufen ist. insertCandidates() gibt die Zeilen jetzt in der Form der Datenbank zurueck. Die Umformung an der Aufrufstelle waere bei jedem neuen Feld still auseinandergelaufen und haette es bei der automatischen Uebernahme verloren. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
14 KiB
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 und sind der Grund fuer mehrere Entwurfsentscheidungen.
Ueberblick
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.
-
App-Registrierung in Entra (Name z. B.
perinco CRM), Kontotyp „nur dieses Verzeichnis", Redirect-URI leer lassen. Anwendungs-ID und Verzeichnis-ID notieren. -
Client-Geheimnis anlegen und den Wert sofort kopieren.
-
API-Berechtigung Microsoft Graph → Anwendungsberechtigungen →
Mail.Read, danach Administratorzustimmung erteilen.Mail.ReadBasicgenuegt nicht, es liefert keinen Mailtext. -
Zugriff auf dieses eine Postfach begrenzen. Anwendungsberechtigungen gelten tenant-weit; ohne Richtlinie darf die App jedes Postfach lesen:
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
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_linkin der Datenbank, nicht imisRead-Kennzeichen des Postfachs. Deshalb genuegtMail.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_PAGESbegrenzt 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 Gonebzw.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.
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.ReadWriteundMail.Sendund damit eine erneute Administratorzustimmung. - Anhaenge.
has_attachmentswird 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-syncfehlte. - 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 |