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
+25 -3
View File
@@ -34,7 +34,25 @@ flowchart LR
```
Der Scanner im CRM haengt unter `/api/scanner` und braucht damit **keine eigene
Apache-Regel**: alles laeuft ueber den bestehenden `/api`-Proxy.
Apache-Regel**: alles laeuft ueber den bestehenden `/api`-Proxy. Dasselbe gilt fuer
die Postfachanbindung unter `/api/mail` (siehe
[11 Postfachanbindung](11-postfach.md)).
## Ausgehende Verbindungen
Das Backend spricht von sich aus mit drei fremden Diensten. Alle drei sind
optional - fehlen die Zugangsdaten, laeuft die Anwendung ohne den jeweiligen
Teil weiter.
| Ziel | Wofuer | Ausgeloest von |
| --- | --- | --- |
| Projektboersen | Scraping der Projektangebote | Scanner-Scheduler |
| `api.openai.com` | Bewertung Lebenslauf gegen Projekt | Scanner-Pipeline |
| `login.microsoftonline.com`, `graph.microsoft.com` | Postfach `crm@perinco.de` lesen | Mail-Scheduler |
Die Postfachanbindung schickt **keine** Mailinhalte an Dritte: Kontaktdaten
werden auf dem Server aus Graph-Feldern und Signaturen gelesen, ohne
Sprachmodell.
## Anfrageweg mit Anmeldung
@@ -72,10 +90,14 @@ sofort draussen, ohne auf den Ablauf des Tokens zu warten.
```mermaid
flowchart TD
L["Anmeldung"] --> R{"Rolle"}
R -->|admin| A["Opportunities, Kalender, Kontakte, Scanner<br/>Einstellungen: Nutzerverwaltung, Mein Konto, Scanner, Whisper"]
R -->|user| U["Opportunities, Kalender, Kontakte, Scanner<br/>Einstellungen: Mein Konto, Whisper"]
R -->|admin| A["Opportunities, Kalender, Kontakte, Mails, Scanner<br/>Einstellungen: Nutzerverwaltung, Mein Konto, Postfach, Scanner, Whisper"]
R -->|user| U["Opportunities, Kalender, Kontakte, Mails, Scanner<br/>Einstellungen: Mein Konto, Whisper"]
```
Bei den Mails darf jede Rolle lesen und zuordnen - `crm@perinco.de` ist ein
gemeinsames Vertriebspostfach. Einrichten, Verbindungstest und manueller Abgleich
sind Administratoren vorbehalten.
Die Rollenpruefung sitzt **doppelt**: das Menue blendet aus, was nicht erlaubt
ist, und das Backend weist Zugriffe zusaetzlich mit `403` ab (`requireAdmin`).
Ausnahme ist `GET /api/users/directory` - die Benutzerliste brauchen alle Rollen
+83 -1
View File
@@ -4,7 +4,7 @@ Zwei Datenbanken, absichtlich getrennt:
| Datenbank | Inhalt | Zugriff |
| --- | --- | --- |
| `perinco_CRM` | Benutzer, Kontakte, Opportunities, Termine | `mysql2` direkt, Schema aus `server/src/schema.sql` |
| `perinco_CRM` | Benutzer, Kontakte, Opportunities, Termine, Postfach, Einstellungen | `mysql2` direkt, Schema aus `server/src/schema.sql` |
| `perinco_scanner` | Suchagenten, Portale, Projekte, Treffer, Scanner-Einstellungen | Sequelize, Schema aus den Modellen (`sequelize.sync()`) |
Die Trennung ist kein Zufall: die Scanner-Tabellen sind eine 1:1-Uebernahme aus
@@ -95,6 +95,88 @@ Drei Eigenheiten, die man kennen muss:
Bild vor dem Upload auf 256 px (`src/lib/imageCompress.ts`), aus 2,9 MB werden
wenige Kilobyte.
### Postfach crm@perinco.de
Diese Tabellen liegen in derselben Datenbank, haengen aber nur ueber
`crm_mail_links` und `crm_mail_contact_candidates.lead_id` am Rest. Fachliche
Beschreibung in [11 Postfachanbindung](11-postfach.md).
```mermaid
erDiagram
crm_mail_messages ||--o{ crm_mail_links : hat
crm_mail_messages ||--o{ crm_mail_contact_candidates : liefert
crm_leads ||--o{ crm_mail_links : "lead_id"
crm_opportunities ||--o{ crm_mail_links : "opportunity_id"
crm_leads ||--o{ crm_mail_contact_candidates : "lead_id, nach Uebernahme"
crm_mail_messages {
int id PK
varchar graph_id "Id aus Microsoft Graph"
char graph_id_hash UK "SHA-256 hex, traegt die Eindeutigkeit"
varchar conversation_id "Unterhaltung"
varchar subject
varchar from_email
longtext to_json "[{name,email}] als Text"
datetime received_at "UTC"
longtext body_text "aus HTML gewandelt, Zeilen erhalten"
longtext body_html "entschaerft fuer die Anzeige"
tinyint has_attachments
tinyint is_deleted "im Postfach geloescht"
}
crm_mail_links {
int id PK
int message_id FK
int lead_id FK "NULL erlaubt"
int opportunity_id FK "NULL erlaubt"
enum origin "manuell | automatisch"
varchar note
int created_by FK
}
crm_mail_contact_candidates {
int id PK
int message_id FK
char dedupe_hash "aus E-Mail bzw. Name+Telefon"
varchar first_name
varchar last_name
varchar email
varchar company
enum origin "absender | empfaenger | signatur | text"
tinyint confidence "0-100"
enum status "offen | uebernommen | verworfen | dublette"
int lead_id FK "gesetzt ab uebernommen"
}
crm_mail_sync_state {
tinyint id PK "immer 1"
varchar mailbox
text delta_link "naechster Graph-Aufruf"
tinyint has_more "nextLink offen"
datetime last_run_at
datetime last_success_at
text last_error
}
crm_settings {
varchar key_name PK
text value "alles als Text, auch Zahlen und Schalter"
}
```
Vier Eigenheiten:
- **Die Graph-ID hat einen Hash daneben.** Sie ist mit ueber 150 Zeichen zu lang
fuer einen Unique-Index unter `utf8mb4`; die Eindeutigkeit traegt deshalb
`graph_id_hash` (SHA-256 als Hex, `CHAR(64)`).
- **`received_at` steht in UTC**, anders als `crm_events.starts_at` (lokale
Wandzeit). Graph liefert UTC, und eine stille Umrechnung beim Speichern waere
eine Fehlerquelle. Das Frontend rechnet um (`parseMailDate` in
`src/lib/mailApi.ts`).
- **`crm_mail_links` erlaubt NULL in beiden Fremdschluesseln.** MariaDB laesst in
einem Unique-Index mehrere NULL zu - die beiden Schluessel sperren damit genau
die Doppelung desselben Paares, und eine Mail kann an mehreren Kontakten und
mehreren Opportunities haengen.
- **`crm_settings` enthaelt keine Geheimnisse.** Zugangsdaten fuer Microsoft 365
stehen ausschliesslich in `server/.env`, damit ein Datenbank-Dump keine Secrets
enthaelt.
## perinco_scanner
```mermaid
+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.
+22
View File
@@ -60,11 +60,25 @@ mitten im Scrapen beenden.
| `SCANNER_SCHEDULER` | Hintergrund-Scraper, Standard an, nur `false` schaltet ab |
| `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz fuer den ersten Lauf, Standard 30 |
| `LEGACY_*` | Quelle fuer `npm run scanner:import` |
| `MS_TENANT_ID`, `MS_CLIENT_ID`, `MS_CLIENT_SECRET` | App-only-Zugriff auf Microsoft Graph |
| `MS_MAILBOX` | abzugleichendes Postfach, Standard `crm@perinco.de` |
| `MS_MAIL_FOLDER` | Ordner, Standard `inbox` |
| `MAIL_SYNC` | Postfachabgleich, Standard an, nur `false` schaltet ab |
| `MAIL_MAX_PAGES`, `MAIL_MAX_BODY_CHARS` | Bremsen fuer Erstabgleich und Textgroesse |
Die Scanner-Datenbank wird beim Start automatisch angelegt
(`CREATE DATABASE IF NOT EXISTS`), Tabellen kommen aus `sequelize.sync()`, Portale
und Standardeinstellungen werden geseedet.
Fehlen die `MS_*`-Werte, startet das Backend normal und protokolliert
`[Mail] Zugangsdaten fehlen`. Die Ansicht *Mails* und der Einstellungsreiter
*Postfach* nennen die fehlende Variable beim Namen. Einrichtung in Entra und
Exchange Online: [11 Postfachanbindung](11-postfach.md).
> Die `MS_*`-Werte werden **beim Start** gelesen. Nach einer Aenderung an der
> `.env` muss der Dienst neu starten, ein Speichern in der Oberflaeche genuegt
> nicht - dort liegen nur Intervall, Schalter und Zeitraum (`crm_settings`).
## Playwright
`playwright` ist auf **exakt 1.60.0** gepinnt - dieselbe Version wie die alte App.
@@ -105,6 +119,8 @@ systemctl restart perinco-crm-backend
| `Target page ... has been closed` | Dienst wurde mitten im Scrapen neu gestartet |
| Backend stirbt beim Scrapen | `MemoryMax` zu klein, `systemctl show ... -p MemoryCurrent` |
| Ton bleibt aus | Schwellenwert, Dashboard-Ansicht offen, einmal auf der Seite geklickt? |
| Keine Mails im Postfachreiter | `journalctl -u perinco-crm-backend \| grep '\[Mail\]'`, dann Verbindungstest in den Einstellungen |
| Mail-Abgleich meldet `ErrorAccessDenied` | Administratorzustimmung fuer `Mail.Read` fehlt, oder die ApplicationAccessPolicy erlaubt das Postfach nicht |
Nuetzliche Abfragen:
@@ -116,6 +132,12 @@ curl -s http://127.0.0.1:5006/api/health
set -a; . /var/www/perinco_CRM/server/.env; set +a
mysql -u"$DB_USER" -p"$DB_PASSWORD" "$SCANNER_DB_NAME" -e \
"SELECT COUNT(*) FROM matches; SELECT key_name, value FROM settings"
# Zustand des Postfachabgleichs
mysql -u"$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" -e \
"SELECT mailbox, has_more, last_run_at, last_success_at, last_error FROM crm_mail_sync_state;
SELECT COUNT(*) AS mails FROM crm_mail_messages;
SELECT status, COUNT(*) FROM crm_mail_contact_candidates GROUP BY status"
```
## Sicherung
+82
View File
@@ -34,6 +34,7 @@ die Scanner-Einstellungen. Alles andere ist fuer beide Rollen gleich.
│ Opportunities├──────────────────────────────────────────┤
│ Kalender │ │
│ Kontakte │ Inhalt │
│ Mails │ │
│ Scanner ▾ │ │
│ Dashboard │ │
│ Unpassend │ │
@@ -126,6 +127,72 @@ setzt ihn fest (gruener Punkt), Doppelklick loest ihn wieder. Ueber *Verbindung
erstellen* zwei Kontakte anklicken, um eine Verbindung anzulegen; `Esc` bricht ab.
Suche und Statusfilter wirken auch hier.
Sobald Kontakte aus dem Postfach uebernommen wurden, erscheint neben dem
Statusfilter ein **Herkunftsfilter**: *Aus Mails* zeigt nur die automatisch
entstandenen, *Selbst angelegt* nur die von Hand erfassten.
## Mails
Das gemeinsame Postfach `crm@perinco.de` liegt hier im CRM. Der Zugriff ist
**nur lesend** - im Postfach selbst wird nichts veraendert, keine Mail als gelesen
markiert, nichts geloescht. Antworten geht weiter in Outlook; der Link *In Outlook
oeffnen* springt direkt zur Mail.
Zwei Reiter oben:
### Postfach
Links die Mailliste, rechts die gewaehlte Mail. Die Kennzeichen an einem
Listeneintrag sagen, was noch zu tun ist:
| Kennzeichen | Bedeutung |
| --- | --- |
| **1x zugeordnet** | haengt an einer Opportunity oder einem Kontakt |
| **2 Kontakte** | so viele Kontakt-Vorschlaege warten noch auf eine Entscheidung |
| 📎 | Anhaenge vorhanden (im Postfach, nicht im CRM) |
Das Auswahlfeld filtert die Liste. **Nicht zugeordnet** ist die eigentliche
Arbeitsliste: alles, was noch nirgends haengt. Die Suche geht ueber Betreff,
Absender und den kompletten Mailtext; mehrere Woerter wirken als ein Suchbegriff.
**Zuordnen** haengt die Mail an eine Opportunity und/oder einen Kontakt. Der
Dialog schlaegt oben vor, was zu den Mailadressen passt - ein Klick auf den
Vorschlag genuegt. Beides gleichzeitig ist moeglich, jede Zuordnung laesst sich
einzeln wieder mit dem **x** loesen.
Beim Mailinhalt sind externe Bilder zunaechst gesperrt. Das ist Absicht: geladene
Bilder melden dem Absender, dass die Mail geoeffnet wurde. Ueber *Externe Bilder
laden* geht es pro Mail. *Groesser* klappt den Inhalt auf, *Nur Text anzeigen*
zeigt die Rohfassung ohne Formatierung.
Steht an einer Mail **Im Postfach geloescht**, wurde sie in Outlook entfernt. Im
CRM bleibt sie erhalten, damit Zuordnungen nicht verloren gehen.
### Kontakt-Vorschlaege
Aus Absender, Empfaengerliste und Signatur jeder Mail liest die Anwendung
Kontaktdaten. Wer eine echte Mailadresse hat und aus Absender oder
Empfaengerliste stammt, wird **automatisch** Kontakt. Alles Unsichere landet
hier - typischerweise Personen, die nur mit Telefonnummer im Text genannt werden.
Der Balken rechts ist die Zuversicht: je voller und gruener, je verlaesslicher die
Angaben. Das Kennzeichen darunter sagt, woher sie stammen (*Absender*,
*Empfaenger*, *Signatur*, *Im Text genannt*).
Zwei Knoepfe:
* **Als Kontakt anlegen** - es entsteht ein neuer Kontakt mit Herkunft „Mail".
* **Ergaenzen** (statt „anlegen", wenn **Bereits vorhanden** dransteht) - der
vorhandene Kontakt wird um die fehlenden Angaben erweitert. Was schon gepflegt
ist, bleibt unangetastet.
* **Verwerfen** - der Vorschlag verschwindet aus der Liste.
Eigene Kollegen (`@perinco.de`) und Funktionsadressen wie `info@` oder
`no-reply@` werden nie vorgeschlagen.
Ist die Liste leer und die automatische Uebernahme an, ist das der Normalzustand:
es gab nichts zu entscheiden.
## Scanner
Durchsucht Projektboersen und bewertet jeden Fund mit KI gegen die Lebenslaeufe
@@ -223,6 +290,7 @@ Datenbank.
| --- | --- | --- |
| **Nutzerverwaltung** | Admin | Benutzer anlegen, bearbeiten, deaktivieren, loeschen |
| **Mein Konto** | alle | Foto, Kalenderfarbe, Passwort aendern |
| **Postfach** | Admin | Verbindung zu Microsoft 365, Abgleich, Kontaktuebernahme |
| **Scanner** | Admin | Suchintervall, Schwellenwerte, KI-Schluessel, Ton, E-Mail |
| **Whisper** | alle | Spracherkennung fuer das Diktat einrichten |
@@ -234,6 +302,16 @@ nicht mehr: kein Lauf im Hintergrund, und *Jetzt scannen* antwortet mit einem
Hinweis statt zu starten. Der Zustand steht auch oben im Scanner-Dashboard, damit
man nicht raetselt, warum keine neuen Treffer kommen.
Im Reiter **Postfach** steht oben, ob die Verbindung zu Microsoft 365 steht.
**Verbindung testen** prueft beides: die Anmeldung *und* den tatsaechlichen
Zugriff auf `crm@perinco.de`. Schlaegt etwas fehl, nennt die Meldung den
wahrscheinlichen Grund. Darunter lassen sich Intervall, Zeitraum des ersten
Durchlaufs und die automatische Kontaktuebernahme einstellen.
Die Zugangsdaten selbst stehen **nicht** in der Oberflaeche, sondern in der
Serverkonfiguration - sie werden beim Start gelesen. Fehlt einer, sagt die Seite
welcher.
Foto und Kalenderfarbe werden **sofort** gespeichert, ohne Speichern-Knopf. Der
letzte aktive Administrator kann sich nicht selbst degradieren oder loeschen -
das verhindert der Server.
@@ -261,3 +339,7 @@ nicht zum Benutzerkonto.
| Boerse zeigt „Abgelaufen" | Cookies neu importieren |
| „Ein Suchlauf laeuft bereits" | kurz warten, es laeuft nur einer zur Zeit |
| Aendern nicht erlaubt | bei Opportunities darf nur Ersteller, Zustaendiger oder Admin |
| Keine Mails sichtbar | steht oben ein Hinweisbalken? Sonst Admin bitten, in *Einstellungen &rarr; Postfach* die Verbindung zu testen |
| „Erstabgleich laeuft noch" | aeltere Mails werden in mehreren Durchlaeufen nachgeholt, die Liste waechst noch |
| Bilder in einer Mail fehlen | *Externe Bilder laden* klicken - sie sind absichtlich gesperrt |
| Kontakt-Vorschlaege bleiben leer | Normalfall: sichere Vorschlaege werden automatisch uebernommen und tauchen hier nicht auf |
+32 -2
View File
@@ -53,10 +53,18 @@ ansehen - siehe *Oberflaeche pruefen ohne Datenbank* weiter unten.
```bash
npm run lint # = tsc --noEmit, das ist die Wahrheit
npm run build # muss ohne Fehler durchlaufen
cd server && npm test # Pruefungen der Postfachanbindung
```
Es gibt **keine automatischen Tests**. Wer etwas aendert, prueft mit `tsc`, einem
Build und einem Blick in die Oberflaeche.
Automatische Tests gibt es nur fuer **einen** Bereich: `server/test/mail.test.mjs`
prueft die Kontaktextraktion aus Mails und den Graph-Client. Beides laeuft
unbeaufsichtigt im Hintergrund und laesst sich von Hand kaum nachstellen - ein
falsch erkannter Name landet still in der Kontaktliste, und ein 429 vom echten
Dienst kommt genau dann, wenn niemand hinsieht. Kein Test-Framework, reines Node,
keine Datenbank.
Fuer alles andere gilt: `tsc`, ein Build und ein Blick in die Oberflaeche - notfalls
mit dem Stub aus [Oberflaeche pruefen ohne Datenbank](#oberflaeche-pruefen-ohne-datenbank).
## Aufbau
@@ -69,8 +77,10 @@ src/ Frontend
server/src/ Backend
server.js Express-App, Reihenfolge der Routen
db.js Verbindungspool, Schema-Init, Admin-Seed
settings.js crm_settings lesen und schreiben (ohne Geheimnisse)
routes/ ein Router je Ressource
middleware/auth.js JWT pruefen, Adminrechte
mail/ Postfach crm@perinco.de ueber Microsoft Graph
scanner/ portierter Projekt Scanner (Sequelize, Playwright, OpenAI)
docs/ diese Dokumentation
deploy/ Apache-Vhosts, systemd-Unit, Deployskript
@@ -141,6 +151,22 @@ Ein Sprung dorthin aus einer beliebigen Tiefe:
2. In `server/src/server.js` einhaengen - **vor** dem `/api`-404-Fallback.
3. In `src/lib/api.ts` eine typisierte Funktion ergaenzen.
### Neue Einstellung im CRM
Nur fuer Werte, die im Betrieb verstellbar sein sollen. **Zugangsdaten gehoeren
nicht dazu** - die stehen in `server/.env` und werden beim Start gelesen, damit ein
Datenbank-Dump keine Geheimnisse enthaelt.
1. Schluessel samt Vorgabe in das Vorgabenobjekt des Bereichs eintragen (Muster:
`MAIL_SETTING_DEFAULTS` in `server/src/mail/config.js`). Es ist gleichzeitig die
Positivliste des Speicher-Endpunkts.
2. Lesen mit `getSettings(defaults)` aus `server/src/settings.js`, schreiben mit
`setSetting()`. Zahlen und Schalter mit `asInt`/`asBool` wandeln.
3. Feld im passenden Einstellungsreiter ergaenzen.
Die Tabelle `crm_settings` legt `schema.sql` an; ein neuer Schluessel braucht keine
Migration.
## Fallen
Diese Punkte haben real Zeit gekostet:
@@ -157,6 +183,10 @@ Diese Punkte haben real Zeit gekostet:
| **`sequelize.sync({ alter: true })`** | Legt bei jedem Start Unique-Indizes neu an und sprengt irgendwann MySQLs Grenze von 64 Keys. Nur `sync()` benutzen. |
| **Kaskaden beim Loeschen** | Einen Suchagenten loeschen loescht alle seine Treffer mit. Inaktiv setzen ist meist gemeint. |
| **Doppelte Scanner-Laeufe** | Scheduler und *Jetzt scannen* teilen eine Sperre. Ohne sie zahlt man jede KI-Bewertung zweimal. |
| **`allow-same-origin` bei `srcDoc`** | Ein so eingebettetes Dokument erbt die Origin der Anwendung und kann `localStorage` lesen - also das Anmeldetoken. Fremdes HTML gehoert in `sandbox=""` ohne jedes Flag. Der Preis: die Inhaltshoehe ist nicht auslesbar, deshalb hat der Mailrahmen eine feste Hoehe. |
| **`/me` unter App-only** | Es gibt keinen Benutzer im Token, also auch kein `/me` - jeder Graph-Aufruf muss `/users/{postfach}/…` lauten. Genau daran ist das Altsystem gescheitert, still, weil die Antwort nicht geprueft wurde. |
| **UTC neben Wandzeit** | `crm_mail_messages.received_at` steht in UTC (so liefert Graph es), `crm_events.starts_at` in lokaler Wandzeit. Beim Lesen im Frontend `parseMailDate()` benutzen, sonst ist die Anzeige um Stunden verschoben. |
| **Aufzaehlungen in der Datenbank sind deutsch** | `crm_mail_links.origin` (`manuell`/`automatisch`), Vorschlagsstatus (`offen`, `uebernommen`, `verworfen`, `dublette`). Wer englische Werte schickt, bekommt einen Datenbankfehler statt einer Validierungsmeldung. |
## Oberflaeche pruefen ohne Datenbank
+65 -1
View File
@@ -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.
+23
View File
@@ -10,8 +10,11 @@ Quelltext.
| **Ausschluss-Begriff** | Wort, das ein Projekt fuer diesen Agenten sofort aussortiert - noch **vor** der KI-Bewertung, spart also Kosten. Typisch: „Arbeitnehmerueberlassung". |
| **Benutzer** | Konto fuer die Anmeldung, Rolle `admin` oder `user`. Tabelle `crm_users`. |
| **Boerse** / Portal | Projektplattform, die der Scanner durchsucht. Oeffentlich (Hays, Etengo) oder geschuetzt (Freelance.de, Freelancermap.de). Tabelle `portals`. |
| **App-only** | Zugriff auf Microsoft 365 im Namen der Anwendung, nicht eines Benutzers (OAuth-Ablauf *Client Credentials*). Braucht Anwendungsberechtigungen und Administratorzustimmung, aber keinen Login. |
| **Cookie-Import** | Uebertragen einer angemeldeten Browsersitzung auf den Scraper, per JSON-Export der Erweiterung *Cookie-Editor*. Landet als `session_state` am Portal. |
| **Delta Query** | Graph-Abfrage, die beim naechsten Aufruf nur noch die Aenderungen liefert. Der Fortschritt steckt in einem Verweis (`delta_link`), der in `crm_mail_sync_state` liegt. |
| **Kontakt** / Lead | Person im Netzwerk: Firma, Position, Status, Verbindungen. Tabelle `crm_leads`. Im Code durchgaengig `lead`. |
| **Kontakt-Vorschlag** | Aus einer Mail gelesene Person, die noch kein Kontakt ist. Tabelle `crm_mail_contact_candidates`. Wird uebernommen, ergaenzt oder verworfen. |
| **Match** / Treffer | Die Bewertung **eines** Projekts fuer **einen** Agenten: Prozentwert, Pro-Liste, Contra-Liste, Status. Tabelle `matches`. Ein Projekt erzeugt so viele Treffer, wie es aktive Agenten gibt. |
| **Opportunity** | Geschaeft in Arbeit: Phase, Tagessatz, Laufzeit, Wahrscheinlichkeit, Ansprechpartner. Tabelle `crm_opportunities`. |
| **Phase** / Stage | Fortschritt einer Opportunity: Neu, Qualifizierung, Angebot, Verhandlung, Gewonnen, Verloren. |
@@ -26,7 +29,27 @@ Quelltext.
| **Verbindung** | Kante zwischen zwei Kontakten im Netzwerkdiagramm, mit Art und Staerke. Tabelle `crm_lead_connections`. |
| **Volumen** | Tagessatz x Personentage. **Gewichtet** heisst zusaetzlich mit der Abschlusswahrscheinlichkeit multipliziert. |
| **Whisper** | Das Spracherkennungsmodell fuer das Diktat. Laeuft im Browser, nicht auf dem Server. |
| **Zugriffsrichtlinie** | `ApplicationAccessPolicy` in Exchange Online. Begrenzt eine App-only-Berechtigung auf einzelne Postfaecher - ohne sie gilt sie tenant-weit. |
| **Zustaendiger** / Owner | Benutzer, dem ein Kontakt oder eine Opportunity zugeordnet ist. Darf sie aendern, auch wenn er sie nicht angelegt hat. |
| **Zuversicht** / Confidence | Wert von 0 bis 100 an einem Kontakt-Vorschlag. Ab 70 und mit echter Mailadresse wird automatisch uebernommen. |
## Herkunft eines Kontakt-Vorschlags
| Wert in der DB | in der Oberflaeche | Zuversicht |
| --- | --- | --- |
| `absender` | Absender | 90, mit bestaetigender Signatur 95 |
| `empfaenger` | Empfaenger | 78 |
| `signatur` | Signatur | 55 - weitere Adresse in der Signatur, etwa eine Assistenz |
| `text` | Im Text genannt | 35 - nur mit Anrede und Rufnummer, nie automatisch uebernommen |
## Status eines Kontakt-Vorschlags
| Wert | in der Oberflaeche | Bedeutung |
| --- | --- | --- |
| `offen` | Offen | wartet auf eine Entscheidung |
| `uebernommen` | Uebernommen | es wurde ein Kontakt angelegt |
| `dublette` | Ergaenzt | Adresse war vorhanden, der Kontakt wurde erweitert |
| `verworfen` | Verworfen | von Hand abgelehnt |
## Statuswerte im Scanner
+305
View File
@@ -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 &rarr; *Anwendungsberechtigungen* &rarr;
`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 &rarr;
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 &ge; 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 |
+6
View File
@@ -20,6 +20,7 @@ keine Vorkenntnisse voraus.
| [08 Anleitung fuer Entwickler](08-anleitung-entwickler.md) | Einrichten, erweitern, bekannte Fallen |
| [09 Quellcode-Referenz](09-quellcode.md) | jede Datei, ihre Exporte und Zusagen |
| [10 Glossar](10-glossar.md) | Agent, Match, Lead, Opportunity, Status- und Portalwerte |
| [11 Postfachanbindung](11-postfach.md) | crm@perinco.de ueber Microsoft Graph, Kontakte aus Mails |
Die Designentscheidungen und die Messwerte zur Spracherkennung stehen im
[README des Projekts](../README.md).
@@ -37,6 +38,7 @@ flowchart TB
AP["Apache<br/>:443"]
API["Express-Backend<br/>127.0.0.1:5006"]
SCH["Scanner-Scheduler<br/>node-cron"]
MSCH["Mail-Scheduler<br/>node-cron"]
PW["Playwright<br/>Chromium"]
DB1[("MariaDB<br/>perinco_CRM")]
DB2[("MariaDB<br/>perinco_scanner")]
@@ -46,6 +48,7 @@ flowchart TB
OAI["OpenAI<br/>gpt-4o-mini"]
BOERSE["Projektboersen<br/>freelancermap, hays, ..."]
GRAPH["Microsoft Graph<br/>crm@perinco.de"]
SPA -->|"HTTPS /api"| AP
AP --> API
@@ -56,6 +59,9 @@ flowchart TB
SCH --> OAI
SCH --> DB2
API -.-> SCH
API -.-> MSCH
MSCH -->|"Delta Query, nur lesend"| GRAPH
MSCH --> DB1
ALT --> DB3
ALT --> BOERSE
SPA -.->|"Audio bleibt im Browser"| WSP