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
+65
-1
@@ -70,6 +70,7 @@ Alle unter `router.use(requireAuth)`.
|
||||
| `connections.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | jeder Angemeldete |
|
||||
| `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; aendern/loeschen nur Ersteller oder Admin |
|
||||
| `opportunities.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | aendern/loeschen nur Ersteller, Zustaendiger oder Admin |
|
||||
| `mail.js` | `GET /status`, `/messages`, `/messages/:id`, `/candidates`; `POST /messages/:id/links`, `/candidates/:id/adopt`, `/candidates/:id/reject`; `DELETE /links/:id`; `requireAdmin`: `GET/POST /settings`, `POST /test`, `POST /sync` | lesen und zuordnen jeder; einrichten Admin |
|
||||
|
||||
Wiederkehrende Muster in diesen Routern:
|
||||
|
||||
@@ -86,6 +87,46 @@ Wiederkehrende Muster in diesen Routern:
|
||||
* Der letzte aktive Administrator kann nicht degradiert, deaktiviert oder
|
||||
geloescht werden.
|
||||
|
||||
### `server/src/settings.js`
|
||||
|
||||
`getSetting`, `getSettings`, `setSetting` auf `crm_settings`, dazu `asBool` und
|
||||
`asInt`. Alle Werte liegen als Text in der Datenbank. `getSettings(defaults)`
|
||||
nimmt die Vorgaben gleichzeitig als Positivliste der erlaubten Schluessel - ein
|
||||
unbekannter Name kommt so nie in die Datenbank.
|
||||
|
||||
Bewusst getrennt vom Setting-Modell des Scanners (andere Datenbank) und bewusst
|
||||
**ohne Geheimnisse**: Zugangsdaten stehen in `server/.env`.
|
||||
|
||||
### `server/src/mail/`
|
||||
|
||||
Postfachanbindung, fachlich in [11 Postfachanbindung](11-postfach.md).
|
||||
|
||||
| Datei | Inhalt |
|
||||
| --- | --- |
|
||||
| `config.js` | `.env` lesen, `MAIL_CONFIGURED`, `missingCredentials()`, verstellbare Einstellungen mit Vorgaben (`mailSettings()`) |
|
||||
| `graph.js` | Token mit Zwischenspeicher, `graphRequest()` mit Wiederholung bei 429/5xx und Beachtung von `Retry-After`, `testConnection()` |
|
||||
| `html.js` | `htmlToText()` (Zeilen bleiben erhalten, Linkziele werden hinter den Linktext gesetzt), `sanitizeHtml()` |
|
||||
| `extract.js` | `extractCandidates()`, `parsePersonName()`, `parseSignature()`, `AUTO_ADOPT_MIN_CONFIDENCE` |
|
||||
| `store.js` | Zustandszeile, `upsertMessage()`, `insertCandidates()`, `linkMessage()` |
|
||||
| `contacts.js` | `adoptCandidate()`: anlegen oder vorhandenen Kontakt ergaenzen, `markCandidate()` |
|
||||
| `sync.js` | ein Abgleichslauf, Delta-Seiten, Einzelsperre, Fehlerbehandlung |
|
||||
| `scheduler.js` | Minutentakt, Faelligkeit aus `last_run_at`, kein Zeitfenster |
|
||||
|
||||
Eigenheiten, die man kennen muss:
|
||||
|
||||
* `graphRequest()` nimmt Pfade **und** vollstaendige URLs - Delta-Links kommen
|
||||
absolut von Graph zurueck.
|
||||
* Ein `401` verwirft das Token genau einmal und wiederholt; so ueberlebt ein Lauf
|
||||
ein serverseitig zurueckgezogenes Token.
|
||||
* `upsertMessage()` laesst die Textspalten unberuehrt, wenn `bodyText` im
|
||||
uebergebenen Objekt `undefined` ist. Ein reines Metadaten-Update darf den schon
|
||||
geholten Mailtext nicht loeschen.
|
||||
* `extractCandidates()` verlangt im Fliesstext eine Anrede vor dem Namen.
|
||||
Ohne diese Bedingung liest der Musterabgleich jede Folge grossgeschriebener
|
||||
Woerter als Namen.
|
||||
* Der Lauf ist **einfach gesperrt** (`running` in `sync.js`) - ein zweiter Aufruf
|
||||
kommt sofort mit `skipped` zurueck, statt parallel zu laufen.
|
||||
|
||||
### `server/src/routes/scanner.js`
|
||||
|
||||
Die portierte Scanner-API unter `/api/scanner`, vollstaendig in
|
||||
@@ -160,13 +201,25 @@ damit bleibt der Vergleich mit der alten App moeglich. Nur `ScannerProject`
|
||||
traegt beide Schreibweisen fuer `created_at`, weil das Modell die Spalte selbst
|
||||
umbenennt.
|
||||
|
||||
### `src/lib/mailApi.ts`
|
||||
|
||||
Typen (`MailMessageSummary`, `MailMessageDetail`, `MailCandidate`, `MailLink`,
|
||||
`MailStatus`, `MailSyncResult`, `MailConnectionTest`, `MailSettingsValues`), das
|
||||
Objekt `mailApi`, `MAIL_SETTINGS_DEFAULTS` und die Datumshelfer.
|
||||
|
||||
`parseMailDate()` haengt ein `Z` an den MariaDB-Zeitstempel, bevor daraus ein
|
||||
`Date` wird. Ohne das liest der Browser den Wert als Ortszeit, und die Anzeige
|
||||
waere je Zeitzone um Stunden verschoben - die Mailzeiten stehen als UTC in der
|
||||
Datenbank, anders als die Kalendertermine. `formatMailDate()` zeigt heute nur die
|
||||
Uhrzeit, dieses Jahr Tag und Monat, sonst mit Jahr.
|
||||
|
||||
### Kontexte
|
||||
|
||||
| Datei | Haelt | Eigenheit |
|
||||
| --- | --- | --- |
|
||||
| `auth.tsx` | Benutzer, `isAdmin`, `login`, `logout`, `updateUser` | prueft beim Start das gespeicherte Token gegen `/auth/me` |
|
||||
| `theme.tsx` | hell/dunkel/System | Wahl in `localStorage`, Doppelklick loest sie wieder |
|
||||
| `navigation.tsx` | `currentView`, `goTo(view, { tab })` | erspart es, Callbacks durch vier Ebenen zu reichen |
|
||||
| `navigation.tsx` | `currentView`, `goTo(view, { tab, leadId })` | erspart es, Callbacks durch vier Ebenen zu reichen; `leadId` oeffnet den Kontakt direkt |
|
||||
| `whisper.tsx` | Modellstatus, Worker, Fortschritt, `transcribe` | ein Worker fuer die ganze App |
|
||||
|
||||
### Whisper
|
||||
@@ -188,6 +241,8 @@ Warum genau diese Konstanten festgelegt sind, steht in
|
||||
| --- | --- |
|
||||
| `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `StatusBadge`, `Avatar`, Statusfarben |
|
||||
| `components/scanner/scannerUi.tsx` | `cardClass`, Buttonklassen, `MatchBadge`, `MatchStatusBadge`, `PortalStatusPill`, `formatDateTime`, `projectTimestamp` |
|
||||
| `components/mail/mailUi.tsx` | `Badge`, `ConfidenceBar`, Herkunfts- und Statusbeschriftungen, `formatAddresses`, `avatarColor` |
|
||||
| `components/mail/MailBody.tsx` | Mailinhalt im `<iframe sandbox="">`, externe Bilder per CSP gesperrt |
|
||||
| `components/ui/Modal.tsx` | `Modal` (Escape schliesst) und `ConfirmDialog` |
|
||||
| `components/Toast.tsx` | `useToast().addToast(text, kind)` mit `success`, `error` oder `info` |
|
||||
| `components/ui/DictationField.tsx` | Mikrofon-Knopf, Hinweisbox, Textfeld mit Diktat |
|
||||
@@ -201,6 +256,7 @@ nicht auseinanderlaufen:
|
||||
| Export | Zweck |
|
||||
| --- | --- |
|
||||
| `ViewState`, `SettingsTab`, `ADMIN_SETTINGS_TABS`, `SCANNER_VIEWS` | Ansichten und Reiter |
|
||||
| `MAIL_LEAD_SOURCE`, `LeadOriginFilter` | Herkunft „Mail" in `Lead.source` und ihr Filter |
|
||||
| `USER_COLORS`, `userColor()` | Farbe gesetzt, sonst stabil aus der Benutzer-Id |
|
||||
| `opportunityVolume()`, `weightedVolume()`, `formatEuro()` | Tagessatz x Personentage, gewichtet, formatiert |
|
||||
| `OPPORTUNITY_STAGES`, `OPEN_STAGES`, `LEAD_STATUSES`, `CONNECTION_TYPES` | die Aufzaehlungen der Datenbank |
|
||||
@@ -218,8 +274,16 @@ flowchart TD
|
||||
F -->|"still, ohne Ladeanzeige"| C
|
||||
|
||||
G["Scanner-Ansichten"] -->|"eigener 30-s-Takt"| H["/api/scanner"]
|
||||
I["Mailansicht"] -->|"blaettert, sucht, filtert"| J["/api/mail"]
|
||||
D --> I
|
||||
I -->|"nach Uebernahme eines Vorschlags"| F
|
||||
```
|
||||
|
||||
Die Mailansicht laedt ihre Mails selbst, bekommt Kontakte und Opportunities aber
|
||||
als Props - der Zuordnungsdialog braucht beide Listen zum Vorschlagen. Nach einer
|
||||
Kontaktuebernahme ruft sie `reload()`, damit der neue Kontakt in der Kontaktliste
|
||||
der App auftaucht.
|
||||
|
||||
Die fuenf CRM-Endpunkte laufen **parallel**, nicht nacheinander - sonst addieren
|
||||
sich fuenf Rundreisen beim Anmelden. Die Scanner-Ansichten haengen bewusst nicht
|
||||
an `loadData`: andere Datenbank, andere Auffrischung.
|
||||
|
||||
Reference in New Issue
Block a user