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:
Sascha Wolf
2026-08-11 00:06:01 +02:00
co-authored by Claude Opus 5
parent 3647201111
commit 6fd713497d
42 changed files with 5926 additions and 32 deletions
+32 -7
View File
@@ -12,6 +12,9 @@ flowchart TD
K --> O["Opportunities<br/>(Startansicht)"]
K --> C["Kalender"]
K --> L["Kontakte"]
K --> M["Mails"]
M --> M1["Postfach"]
M --> M2["Kontakt-Vorschlaege"]
K --> SC["Scanner"]
SC --> SC1["Dashboard"]
SC --> SC2["Unpassend"]
@@ -20,10 +23,14 @@ flowchart TD
SB --> E["Einstellungen"]
E --> E1["Nutzerverwaltung (admin)"]
E --> E2["Mein Konto"]
E --> E3["Scanner (admin)"]
E --> E4["Whisper"]
E --> E3["Postfach (admin)"]
E --> E4["Scanner (admin)"]
E --> E5["Whisper"]
```
*Postfach* und *Kontakt-Vorschlaege* sind Reiter **innerhalb** der Ansicht
*Mails*, keine Menuepunkte - sie gehoeren zusammen und wechseln oft hin und her.
Der Menuepunkt *Scanner* klappt seine Unterpunkte auf; *Scanner* selbst ist das
Dashboard. Bei eingeklappter Leiste (Symbolbreite) erscheinen nur die Symbole,
die Unterpunkte kommen mit den Beschriftungen zurueck.
@@ -39,8 +46,8 @@ flowchart TB
WH["WhisperProvider<br/>Modell, Worker"]
end
APP["App.tsx<br/>currentView, settingsTab"]
NAV["NavigationProvider<br/>goTo(view, {tab})"]
APP["App.tsx<br/>currentView, settingsTab, focusLeadId"]
NAV["NavigationProvider<br/>goTo(view, {tab, leadId})"]
DATA["loadData()<br/>leads, connections, events, users, opportunities"]
Kontexte --> APP
@@ -49,14 +56,25 @@ flowchart TB
DATA --> V1["Opportunities"]
DATA --> V2["Kalender"]
DATA --> V3["Kontakte"]
DATA --> V4["Mails<br/>leads + opportunities fuer den Zuordnungsdialog"]
NAV --> DF["DictationField<br/>springt nach Whisper"]
NAV --> ML["Mails<br/>springt zum Kontakt einer Zuordnung"]
SCV["Scanner-Ansichten"] -->|"laden selbst"| API["/api/scanner"]
MV["Mailansicht"] -->|"laedt selbst"| MAPI["/api/mail"]
```
Die CRM-Daten werden **einmal zentral** geladen und als Props durchgereicht; die
Scanner-Ansichten laden dagegen selbst, weil sie eine andere Datenbank und eine
eigene Auffrischung im 30-Sekunden-Takt haben.
Scanner- und Mailansichten laden ihre eigenen Daten selbst - der Scanner, weil er
eine andere Datenbank und eine Auffrischung im 30-Sekunden-Takt hat; die Mails,
weil sie blaettern, suchen und filtern und dabei nicht die ganze Liste im Speicher
halten sollen. Kontakte und Opportunities bekommt die Mailansicht dagegen als
Props: der Zuordnungsdialog braucht sie beide zum Vorschlagen.
`goTo('Kontakte', { leadId })` ist der Sprung aus einer Mailzuordnung zum
Kontakt. `App.tsx` haelt die Id in `focusLeadId`, die Kontaktansicht oeffnet den
Kontakt und meldet mit `onFocusHandled()` zurueck - der Effekt haengt an `leads`
und wartet damit auf einen gerade erst angelegten Kontakt.
`reload={() => loadData(true)}` laedt still nach - ohne Ladeanzeige, damit ein
Speichern im Dialog nicht die ganze Liste flackern laesst.
@@ -70,6 +88,7 @@ src/
lib/
api.ts Fetch-Wrapper (JWT, 401-Behandlung) + CRM-Endpunkte
scannerApi.ts Typisierte Endpunkte unter /api/scanner
mailApi.ts Endpunkte unter /api/mail, UTC-Datumshelfer
auth.tsx Anmeldung, Benutzer, Rolle
theme.tsx Hell/Dunkel/System
navigation.tsx Ansichtswechsel fuer tiefe Komponenten
@@ -83,8 +102,9 @@ src/
opportunities/ Kachel, Liste, Pipeline (Drag & Drop), Formular
calendar/ Monat/Woche/Tag, Termine (Drag & Drop), @-Mentions
leads/ Kachel, Liste, Netzwerkdiagramm (d3-force)
mail/ Postfachliste, Detail, Zuordnung, Kontakt-Vorschlaege
scanner/ Dashboard, Unpassend, Suchagenten, Boersen-Konten
settings/ Reiter: Nutzerverwaltung, Konto, Scanner, Whisper
settings/ Reiter: Nutzerverwaltung, Konto, Postfach, Scanner, Whisper
whisper/ Einrichtung der Spracherkennung
ui/ Modal, Avatare, Diktatfeld
```
@@ -109,3 +129,8 @@ Ein Inline-Skript in `index.html` setzt die Klasse vor dem ersten Frame, sonst
blitzt beim Laden die helle Variante auf. Drei Werte bleiben in beiden Modi
gleich: `ink-950` (liegt nur unter Dialog-Overlays), `text-white` (steht immer auf
farbigen Flaechen) und `brand-500` (das CI-Gruen der Buttons).
Eine bewusste Ausnahme: der **Mailinhalt** bleibt auch im Dunkelmodus auf weissem
Grund. Fremdes Mail-HTML bringt seine eigenen Farben mit; ein dunkler Hintergrund
darunter macht schwarze Schrift auf schwarzer Flaeche. Der Rahmen liest sich damit
wie ein Blatt Papier im dunklen Fenster - gewollt.