From 6fd713497d7726471220496c8abc37ff967bc1f6 Mon Sep 17 00:00:00 2001 From: Sascha Wolf Date: Tue, 11 Aug 2026 00:06:01 +0200 Subject: [PATCH] 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) --- .gitignore | 3 + README.md | 15 +- docs/01-architektur.md | 28 +- docs/02-datenmodell.md | 84 ++- docs/05-frontend.md | 39 +- docs/06-betrieb.md | 22 + docs/07-anleitung-benutzer.md | 82 +++ docs/08-anleitung-entwickler.md | 34 +- docs/09-quellcode.md | 66 +- docs/10-glossar.md | 23 + docs/11-postfach.md | 305 +++++++++ docs/README.md | 6 + server/.env.example | 36 ++ server/package.json | 3 +- server/src/mail/config.js | 90 +++ server/src/mail/contacts.js | 126 ++++ server/src/mail/extract.js | 781 +++++++++++++++++++++++ server/src/mail/graph.js | 218 +++++++ server/src/mail/html.js | 141 ++++ server/src/mail/scheduler.js | 72 +++ server/src/mail/store.js | 217 +++++++ server/src/mail/sync.js | 355 +++++++++++ server/src/routes/mail.js | 504 +++++++++++++++ server/src/schema.sql | 145 +++++ server/src/server.js | 13 + server/src/settings.js | 62 ++ server/test/mail.test.mjs | 339 ++++++++++ src/App.tsx | 17 +- src/components/Sidebar.tsx | 4 + src/components/leads/Leads.tsx | 67 +- src/components/mail/CandidateRow.tsx | 163 +++++ src/components/mail/MailBody.tsx | 125 ++++ src/components/mail/MailDetail.tsx | 220 +++++++ src/components/mail/MailLinkDialog.tsx | 240 +++++++ src/components/mail/MailList.tsx | 84 +++ src/components/mail/Mails.tsx | 497 +++++++++++++++ src/components/mail/mailUi.tsx | 92 +++ src/components/settings/MailSettings.tsx | 346 ++++++++++ src/components/settings/Settings.tsx | 3 + src/lib/mailApi.ts | 264 ++++++++ src/lib/navigation.tsx | 6 +- src/types.ts | 21 +- 42 files changed, 5926 insertions(+), 32 deletions(-) create mode 100644 docs/11-postfach.md create mode 100644 server/src/mail/config.js create mode 100644 server/src/mail/contacts.js create mode 100644 server/src/mail/extract.js create mode 100644 server/src/mail/graph.js create mode 100644 server/src/mail/html.js create mode 100644 server/src/mail/scheduler.js create mode 100644 server/src/mail/store.js create mode 100644 server/src/mail/sync.js create mode 100644 server/src/routes/mail.js create mode 100644 server/src/settings.js create mode 100644 server/test/mail.test.mjs create mode 100644 src/components/mail/CandidateRow.tsx create mode 100644 src/components/mail/MailBody.tsx create mode 100644 src/components/mail/MailDetail.tsx create mode 100644 src/components/mail/MailLinkDialog.tsx create mode 100644 src/components/mail/MailList.tsx create mode 100644 src/components/mail/Mails.tsx create mode 100644 src/components/mail/mailUi.tsx create mode 100644 src/components/settings/MailSettings.tsx create mode 100644 src/lib/mailApi.ts diff --git a/.gitignore b/.gitignore index 08ff838..ad6b7c9 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,8 @@ server/node_modules/ server/uploads/ # Urspruengliche Aufgabenstellung - enthaelt Server- und Admin-Passwoerter im Klartext prompt.txt +# Erzeugte Analysedokumente (aus HTML gedruckt, nicht die Quelle der Wahrheit). +# Fuer die Postfachanbindung gilt docs/11-postfach.md. +M365-Mailanbindung.pdf *.log .DS_Store diff --git a/README.md b/README.md index 895b29e..13d5662 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # perinco CRM CRM im perinco Corporate Design. Vite/React-Frontend, Express-Backend, MariaDB. -Enthaelt den portierten *Projekt Scanner*. +Enthaelt den portierten *Projekt Scanner* und die lesende Anbindung des +Vertriebspostfachs `crm@perinco.de` ueber Microsoft Graph. > **Dokumentation mit Funktionszeichnungen:** [`docs/`](docs/README.md) -> — Architektur, Datenmodell, Scanner-Ablauf, Diktat, Frontend, Betrieb. +> — Architektur, Datenmodell, Scanner-Ablauf, Diktat, Frontend, Betrieb, +> [Postfachanbindung](docs/11-postfach.md). > Zum Einstieg: [Anleitung fuer Benutzer](docs/07-anleitung-benutzer.md) · > [Anleitung fuer Entwickler](docs/08-anleitung-entwickler.md) · > [Quellcode-Referenz](docs/09-quellcode.md) @@ -55,12 +57,13 @@ blitzt beim Laden die helle Variante auf. Browser ──► Apache (:443/:80, pcrm.perinco.de) ├── / → /var/www/perinco_CRM/dist (SPA, FallbackResource) └── /api → 127.0.0.1:5006 (Express) - ├──► MariaDB perinco_CRM (CRM) - └──► MariaDB project_scanner (Scanner) + ├──► MariaDB perinco_CRM (CRM + Postfach) + ├──► MariaDB perinco_scanner (Scanner) + └──► Microsoft Graph (crm@perinco.de, nur lesend) ``` -Der Scanner haengt unter `/api/scanner` und braucht damit **keine** eigene -Apache-Regel. +Der Scanner haengt unter `/api/scanner`, die Postfachanbindung unter `/api/mail` - +beide brauchen damit **keine** eigene Apache-Regel. Der Browser spricht **nie** direkt mit MariaDB - deshalb das Express-Backend. Das Backend bindet ausschliesslich auf `127.0.0.1` und ist von aussen nur ueber diff --git a/docs/01-architektur.md b/docs/01-architektur.md index 671205d..fddcfe0 100644 --- a/docs/01-architektur.md +++ b/docs/01-architektur.md @@ -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
Einstellungen: Nutzerverwaltung, Mein Konto, Scanner, Whisper"] - R -->|user| U["Opportunities, Kalender, Kontakte, Scanner
Einstellungen: Mein Konto, Whisper"] + R -->|admin| A["Opportunities, Kalender, Kontakte, Mails, Scanner
Einstellungen: Nutzerverwaltung, Mein Konto, Postfach, Scanner, Whisper"] + R -->|user| U["Opportunities, Kalender, Kontakte, Mails, Scanner
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 diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md index ca3b993..8c23c83 100644 --- a/docs/02-datenmodell.md +++ b/docs/02-datenmodell.md @@ -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 diff --git a/docs/05-frontend.md b/docs/05-frontend.md index 4882093..b952fb1 100644 --- a/docs/05-frontend.md +++ b/docs/05-frontend.md @@ -12,6 +12,9 @@ flowchart TD K --> O["Opportunities
(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
Modell, Worker"] end - APP["App.tsx
currentView, settingsTab"] - NAV["NavigationProvider
goTo(view, {tab})"] + APP["App.tsx
currentView, settingsTab, focusLeadId"] + NAV["NavigationProvider
goTo(view, {tab, leadId})"] DATA["loadData()
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
leads + opportunities fuer den Zuordnungsdialog"] NAV --> DF["DictationField
springt nach Whisper"] + NAV --> ML["Mails
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. diff --git a/docs/06-betrieb.md b/docs/06-betrieb.md index c88265b..580a824 100644 --- a/docs/06-betrieb.md +++ b/docs/06-betrieb.md @@ -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 diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index 62cfcaa..d2513ee 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -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 → 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 | diff --git a/docs/08-anleitung-entwickler.md b/docs/08-anleitung-entwickler.md index 95e56dd..48c77a1 100644 --- a/docs/08-anleitung-entwickler.md +++ b/docs/08-anleitung-entwickler.md @@ -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 diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index 7761e42..bba7d92 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -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 `Klick`; +const clean = sanitizeHtml(dirty); +check('Skript ist entfernt', !clean.includes(' entry.confidence >= 70) +); + +// Automat: darf nichts liefern. +const automated = extractCandidates( + { + fromName: 'freelancermap Newsletter', + fromEmail: 'no-reply@news.freelancermap.de', + to: [{ name: null, email: 'crm@perinco.de' }], + cc: [], + bcc: [], + bodyText: 'Neue Projekte für Sie.\n\nAbmelden: https://news.freelancermap.de/unsubscribe', + }, + OPTIONS +); +check('Automat liefert keinen Kontakt', automated.length === 0, `${automated.length}`); + +// Personen im Fliesstext - nur mit Anrede, und nie automatisch. +const mentioned = extractCandidates( + { + fromName: 'Petra Klein', + fromEmail: 'p.klein@vermittler.de', + to: [{ name: null, email: 'crm@perinco.de' }], + cc: [], + bcc: [], + bodyText: + 'Guten Tag,\n\nbitte melden Sie sich bei Herrn Max Mustermann Tel. 0221/3456789.\n' + + 'Zweiter Ansprechpartner ist Frau Julia Berg, 0170 1122334.\n' + + 'Das Angebot Neue Leistung Zwei liegt bei 0221/111111.\n\n' + + 'Mit freundlichen Grüßen\nPetra Klein\nVermittler GmbH\nTel: 030/998877', + }, + OPTIONS +); + +check('Anrede-Treffer erkannt', Boolean(byName(mentioned, 'Max Mustermann') && byName(mentioned, 'Julia Berg'))); +check('Beschriftung nicht im Namen', !mentioned.some((entry) => /Tel/.test(entry.lastName ?? ''))); +check( + 'Wortfolge ohne Anrede wird nicht zum Kontakt', + !byName(mentioned, 'Neue Leistung Zwei') && !byName(mentioned, 'Zweiter Ansprechpartner') +); +check( + 'Textfunde bleiben zur Durchsicht', + mentioned.filter((entry) => entry.origin === 'text').every((entry) => entry.confidence < 70) +); +check('Absender behaelt seine Rufnummer', byEmail(mentioned, 'p.klein@vermittler.de')?.phone === '030/998877'); + +// --------------------------------------------------------------------------- +group('Graph-Client'); + +let calls = []; +let handlers = []; +const originalFetch = globalThis.fetch; + +globalThis.fetch = async (url, options) => { + calls.push({ url: String(url), method: options?.method ?? 'GET' }); + const handler = handlers.shift(); + if (!handler) throw new Error(`Kein Handler mehr fuer ${url}`); + return typeof handler === 'function' ? handler(String(url), options) : handler; +}; + +const json = (status, body, headers = {}) => + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); +const tokenOk = () => json(200, { access_token: 'TOKEN-A', expires_in: 3600 }); +const networkError = () => { + throw new TypeError('fetch failed'); +}; + +function arrange(list) { + calls = []; + handlers = list; + resetTokenCache(); +} + +arrange([tokenOk()]); +const first = await accessToken(); +const second = await accessToken(); +check('Token wird zwischengespeichert', calls.length === 1 && first === second, `${calls.length} Aufruf(e)`); + +arrange([json(400, { error: 'invalid_client', error_description: 'AADSTS7000215: Invalid client secret provided.' })]); +try { + await accessToken(); + check('Anmeldefehler wird gemeldet', false); +} catch (err) { + check( + 'Anmeldefehler nennt den Grund', + err instanceof GraphError && err.message.includes('AADSTS7000215'), + err.message + ); +} + +arrange([tokenOk(), json(429, { error: {} }, { 'retry-after': '0' }), json(200, { value: [1, 2] })]); +const throttled = await graphRequest('/users/x/messages'); +check('429 wird wiederholt', throttled.value.length === 2 && calls.length === 3, `${calls.length} Aufruf(e)`); + +arrange([tokenOk(), json(401, { error: { code: 'InvalidAuthenticationToken' } }), tokenOk(), json(200, { ok: true })]); +const renewed = await graphRequest('/users/x/messages'); +check('401 holt ein neues Token', renewed.ok === true && calls.length === 4, `${calls.length} Aufruf(e)`); + +arrange([ + tokenOk(), + json(401, { error: { code: 'InvalidAuthenticationToken' } }), + tokenOk(), + json(401, { error: { code: 'InvalidAuthenticationToken', message: 'weg' } }), +]); +try { + await graphRequest('/users/x/messages'); + check('Zweites 401 bricht ab', false); +} catch (err) { + check('Zweites 401 bricht ab', err.status === 401 && calls.length === 4, `${calls.length} Aufruf(e)`); +} + +arrange([tokenOk(), json(403, { error: { code: 'ErrorAccessDenied', message: 'Access is denied.' } })]); +try { + await graphRequest('/users/x/messages'); + check('403 wird durchgereicht', false); +} catch (err) { + check('403 wird nicht wiederholt', err.code === 'ErrorAccessDenied' && calls.length === 2, `${calls.length} Aufruf(e)`); +} + +arrange([tokenOk(), json(200, { value: [] })]); +const deltaUrl = 'https://graph.microsoft.com/v1.0/users/x/messages/delta?$deltatoken=abc'; +await graphRequest(deltaUrl); +check('Absolute Delta-URL bleibt unveraendert', calls[1].url === deltaUrl, calls[1].url); + +check( + 'Postfachpfad kodiert die Adresse', + mailboxPath('/messages') === '/users/crm%40perinco.de/messages', + mailboxPath('/messages') +); + +arrange([tokenOk(), json(403, { error: { code: 'ErrorAccessDenied', message: 'Access is denied.' } })]); +const denied = await testConnection(); +check( + 'Test erkennt fehlende Berechtigung', + denied.ok === false && denied.stage === 'mailbox' && denied.hint.includes('Mail.Read') +); + +arrange([tokenOk(), json(200, { displayName: 'Posteingang', totalItemCount: 1842, unreadItemCount: 3 })]); +const granted = await testConnection(); +check('Test bestaetigt Postfachzugriff', granted.ok === true && granted.totalItems === 1842); + +arrange([tokenOk(), networkError, networkError, networkError, networkError]); +try { + await graphRequest('/users/x/messages'); + check('Netzwerkfehler wird gemeldet', false); +} catch (err) { + check('Netzwerkfehler wird gemeldet', err instanceof GraphError && err.status === 0, err.message); +} + +globalThis.fetch = originalFetch; + +// --------------------------------------------------------------------------- +console.log( + failures === 0 + ? '\nAlle Pruefungen bestanden.' + : `\n${failures} Pruefung(en) fehlgeschlagen (letzte Gruppe: ${currentGroup}).` +); +process.exit(failures === 0 ? 0 : 1); diff --git a/src/App.tsx b/src/App.tsx index 8ecf68d..a5efc79 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { Opportunities } from './components/opportunities/Opportunities'; import { Calendar } from './components/calendar/Calendar'; import { parseSqlDateTime } from './components/calendar/calendarUtils'; import { Settings } from './components/settings/Settings'; +import { Mails } from './components/mail/Mails'; import { ScannerDashboard } from './components/scanner/ScannerDashboard'; import { ScannerRejected } from './components/scanner/ScannerRejected'; import { ScannerAgents } from './components/scanner/ScannerAgents'; @@ -50,6 +51,11 @@ const App: React.FC = () => { * nach Whisper, das keinen eigenen Menuepunkt mehr hat. */ const [settingsTab, setSettingsTab] = useState(undefined); + /** + * Kontakt, der in der Kontaktansicht sofort geoeffnet werden soll. Wird beim + * Sprung aus der Mailansicht gesetzt und von dort wieder zurueckgesetzt. + */ + const [focusLeadId, setFocusLeadId] = useState(null); const [leads, setLeads] = useState([]); const [connections, setConnections] = useState([]); @@ -63,9 +69,10 @@ const App: React.FC = () => { * gleich den passenden Reiter der Einstellungen; ohne Angabe entscheidet die * Einstellungsseite selbst (Rolle admin: Nutzerverwaltung, sonst Mein Konto). */ - const goTo = useCallback((view: ViewState, options?: { tab?: SettingsTab }) => { + const goTo = useCallback((view: ViewState, options?: { tab?: SettingsTab; leadId?: number }) => { setCurrentView(view); setSettingsTab(options?.tab); + setFocusLeadId(options?.leadId ?? null); }, []); const loadData = useCallback( @@ -117,11 +124,12 @@ const App: React.FC = () => { // Untertitel der Kopfzeile. Die Scanner-Reiter bringen ihre Kennzahlen selbst // mit, deshalb steht hier nur, worum es auf der Seite geht. const STATIC_SUBTITLES: Partial> = { + Mails: 'Postfach crm@perinco.de - Mails zuordnen und Kontakte übernehmen', Scanner: 'Projekt Scanner - Suchagenten, Portale und Projektübereinstimmungen', Unpassend: 'Projekt Scanner - als „passt nicht" markierte Treffer', Suchagenten: 'Projekt Scanner - Beraterprofile mit CV und Suchbegriffen', 'Börsen-Konten': 'Projekt Scanner - Verbindungen zu den Jobbörsen', - Einstellungen: 'Konto, Diktat, Benutzer und Scanner', + Einstellungen: 'Konto, Diktat, Benutzer, Postfach und Scanner', }; const subtitle = @@ -183,8 +191,13 @@ const App: React.FC = () => { users={users} isLoading={isDataLoading} reload={() => loadData(true)} + focusLeadId={focusLeadId} + onFocusHandled={() => setFocusLeadId(null)} /> )} + {currentView === 'Mails' && ( + loadData(true)} /> + )} {currentView === 'Scanner' && } {currentView === 'Unpassend' && } {currentView === 'Suchagenten' && } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 9cd79d6..b547cf7 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -32,6 +32,10 @@ const CATEGORY_ITEMS: MenuItem[] = [ name: 'Kontakte', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z', }, + { + name: 'Mails', + icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z', + }, ]; /** diff --git a/src/components/leads/Leads.tsx b/src/components/leads/Leads.tsx index b6c5ba7..a921a73 100644 --- a/src/components/leads/Leads.tsx +++ b/src/components/leads/Leads.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { LeadGrid } from './LeadGrid'; import { LeadList, type SortConfig, type SortKey } from './LeadList'; import { LeadNetwork } from './LeadNetwork'; @@ -8,10 +8,12 @@ import { useToast } from '../Toast'; import { leadsApi } from '../../lib/api'; import { LEAD_STATUSES, + MAIL_LEAD_SOURCE, leadFullName, type AppUser, type Lead, type LeadConnection, + type LeadOriginFilter, type LeadStatus, type LeadViewMode, } from '../../types'; @@ -22,8 +24,21 @@ interface LeadsProps { users: AppUser[]; isLoading: boolean; reload: () => void; + /** Kontakt, der beim Wechsel in diese Ansicht direkt geoeffnet werden soll. */ + focusLeadId?: number | null; + onFocusHandled?: () => void; } +/** + * Herkunft der Kontakte. "Aus Mails" sind die aus dem Postfach uebernommenen - + * sie tragen `source = 'Mail'` und brauchen deshalb kein eigenes Feld. + */ +const ORIGIN_FILTERS: { id: LeadOriginFilter; label: string }[] = [ + { id: 'Alle', label: 'Alle Herkunft' }, + { id: 'Mail', label: 'Aus Mails' }, + { id: 'Manuell', label: 'Selbst angelegt' }, +]; + const VIEW_BUTTONS: { mode: LeadViewMode; label: string; icon: string }[] = [ { mode: 'grid', @@ -38,17 +53,40 @@ const VIEW_BUTTONS: { mode: LeadViewMode; label: string; icon: string }[] = [ }, ]; -export const Leads: React.FC = ({ leads, connections, users, isLoading, reload }) => { +export const Leads: React.FC = ({ + leads, + connections, + users, + isLoading, + reload, + focusLeadId = null, + onFocusHandled, +}) => { const { addToast } = useToast(); const [viewMode, setViewMode] = useState('grid'); const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState('Alle'); + const [originFilter, setOriginFilter] = useState('Alle'); const [sortConfig, setSortConfig] = useState({ key: 'updated_at', direction: 'desc' }); const [isModalOpen, setIsModalOpen] = useState(false); const [editingLead, setEditingLead] = useState(null); const [leadToDelete, setLeadToDelete] = useState(null); + /* + * Sprung aus der Mailansicht: den gemeinten Kontakt direkt oeffnen. Ist er noch + * nicht in der Liste (gerade erst angelegt), wartet der Effekt auf das + * Nachladen - deshalb haengt er auch an `leads`. + */ + useEffect(() => { + if (focusLeadId === null) return; + const lead = leads.find((entry) => entry.id === focusLeadId); + if (!lead) return; + setEditingLead(lead); + setIsModalOpen(true); + onFocusHandled?.(); + }, [focusLeadId, leads, onFocusHandled]); + // Verbindungen pro Kontakt - einmal zaehlen statt pro Karte filtern. const connectionCounts = useMemo(() => { const counts = new Map(); @@ -71,6 +109,8 @@ export const Leads: React.FC = ({ leads, connections, users, isLoadi const matches = leads.filter((lead) => { if (statusFilter !== 'Alle' && lead.status !== statusFilter) return false; + if (originFilter === 'Mail' && lead.source !== MAIL_LEAD_SOURCE) return false; + if (originFilter === 'Manuell' && lead.source === MAIL_LEAD_SOURCE) return false; if (terms.length === 0) return true; const haystack = [ @@ -110,7 +150,13 @@ export const Leads: React.FC = ({ leads, connections, users, isLoadi } } }); - }, [leads, searchTerm, statusFilter, sortConfig, connectionCounts]); + }, [leads, searchTerm, statusFilter, originFilter, sortConfig, connectionCounts]); + + // Zaehler fuer den Herkunftsfilter - nur anzeigen, wenn es solche Kontakte gibt. + const mailLeadCount = useMemo( + () => leads.filter((lead) => lead.source === MAIL_LEAD_SOURCE).length, + [leads] + ); const handleSort = (key: SortKey) => { setSortConfig((prev) => @@ -187,6 +233,21 @@ export const Leads: React.FC = ({ leads, connections, users, isLoadi ))} + {mailLeadCount > 0 && ( + + )} +
{VIEW_BUTTONS.map((button) => ( diff --git a/src/components/mail/CandidateRow.tsx b/src/components/mail/CandidateRow.tsx new file mode 100644 index 0000000..7b8ce55 --- /dev/null +++ b/src/components/mail/CandidateRow.tsx @@ -0,0 +1,163 @@ +import React, { useState } from 'react'; +import { + Badge, + CANDIDATE_STATUS_LABELS, + CANDIDATE_STATUS_STYLES, + ConfidenceBar, + ORIGIN_LABELS, + ORIGIN_STYLES, +} from './mailUi'; +import { useToast } from '../Toast'; +import { mailApi, candidateName, type MailCandidate } from '../../lib/mailApi'; + +/* + * Ein Kontakt-Vorschlag mit Uebernehmen/Verwerfen. + * + * Zwei Faelle werden unterschieden und auch so benannt: + * - Die Adresse ist neu -> es entsteht ein Kontakt mit Herkunft "Mail". + * - Die Adresse existiert -> der vorhandene Kontakt wird nur ergaenzt; leere + * Felder werden gefuellt, gepflegte Werte bleiben unangetastet. + * + * Das Altsystem hat in beiden Faellen stumpf angelegt und dabei Platzhalter- + * Adressen erfunden, wenn keine vorhanden war. + */ + +interface CandidateRowProps { + candidate: MailCandidate; + onChanged: () => void; + onOpenLead?: (leadId: number) => void; + /** In der Sammelliste zusaetzlich zeigen, aus welcher Mail der Vorschlag kommt. */ + showMessage?: boolean; + onOpenMessage?: (messageId: number) => void; +} + +export const CandidateRow: React.FC = ({ + candidate, + onChanged, + onOpenLead, + showMessage = false, + onOpenMessage, +}) => { + const { addToast } = useToast(); + const [isBusy, setIsBusy] = useState(false); + + const isOpen = candidate.status === 'offen'; + const willMerge = Boolean(candidate.existing_lead_id) && isOpen; + + const details = [ + candidate.position, + candidate.company, + candidate.phone && `Tel. ${candidate.phone}`, + candidate.mobile && `Mobil ${candidate.mobile}`, + [candidate.zip, candidate.city].filter(Boolean).join(' ') || null, + ].filter(Boolean) as string[]; + + const adopt = async () => { + setIsBusy(true); + try { + const result = await mailApi.adoptCandidate(candidate.id); + addToast( + result.status === 'uebernommen' + ? `${candidateName(candidate)} als Kontakt angelegt.` + : `${candidateName(candidate)} war vorhanden – ${result.enrichedFields} Feld(er) ergänzt.`, + 'success' + ); + onChanged(); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Übernehmen fehlgeschlagen.', 'error'); + } finally { + setIsBusy(false); + } + }; + + const reject = async () => { + setIsBusy(true); + try { + await mailApi.rejectCandidate(candidate.id); + addToast('Vorschlag verworfen.', 'success'); + onChanged(); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Verwerfen fehlgeschlagen.', 'error'); + } finally { + setIsBusy(false); + } + }; + + return ( +
  • +
    +
    +
    +

    {candidateName(candidate)}

    + {ORIGIN_LABELS[candidate.origin]} + {!isOpen && ( + + {CANDIDATE_STATUS_LABELS[candidate.status]} + + )} + {willMerge && ( + + Bereits vorhanden + + )} +
    + +

    + {candidate.email || 'Keine E-Mail-Adresse'} +

    + {details.length > 0 && ( +

    + {details.join(' · ')} +

    + )} + + {showMessage && ( + + )} + + {!isOpen && candidate.lead_id && onOpenLead && ( + + )} +
    + +
    + + {isOpen && ( +
    + + +
    + )} +
    +
    +
  • + ); +}; diff --git a/src/components/mail/MailBody.tsx b/src/components/mail/MailBody.tsx new file mode 100644 index 0000000..57b857a --- /dev/null +++ b/src/components/mail/MailBody.tsx @@ -0,0 +1,125 @@ +import React, { useState } from 'react'; + +/* + * Darstellung des Mailtexts. + * + * HTML aus fremden Mails laeuft in einem vollstaendig abgeschotteten iframe: + * `sandbox=""` ohne jedes allow-Flag. Das Dokument bekommt damit eine eigene, + * undurchsichtige Origin, kann keine Skripte ausfuehren und weder auf die + * Anwendung noch auf deren Sitzungsdaten zugreifen. + * + * Wichtig: allow-same-origin waere hier ein Fehler. Ein srcDoc-Dokument erbt mit + * diesem Flag die Origin der Anwendung und koennte dann localStorage lesen - also + * das Anmeldetoken. Das Backend entfernt Skripte und Ereignis-Attribute + * zusaetzlich schon vor dem Speichern; zwei unabhaengige Schranken. + * + * Weil ohne allow-same-origin die Hoehe des Inhalts nicht auslesbar ist, hat der + * Rahmen eine feste Hoehe mit eigenem Rollbalken und laesst sich aufklappen. Das + * ist der Preis fuer die Abschottung und in der Bedienung kaum spuerbar. + * + * Externe Bilder bleiben zunaechst gesperrt (Content-Security-Policy im + * eingebetteten Dokument): sonst meldet jedes Oeffnen einer Mail dem Absender, + * dass sie gelesen wurde. Pro Mail laesst sich das freigeben. + */ + +interface MailBodyProps { + html: string | null; + text: string | null; +} + +const FRAME_STYLE = ` + html { color-scheme: light; } + body { + margin: 0; + padding: 14px 16px; + font-family: "Segoe UI", system-ui, -apple-system, sans-serif; + font-size: 14px; + line-height: 1.55; + color: #1f2429; + background: #ffffff; + word-break: break-word; + } + img { max-width: 100%; height: auto; } + table { max-width: 100%; border-collapse: collapse; } + a { color: #1f6feb; } + blockquote { + margin: 8px 0; + padding-left: 12px; + border-left: 3px solid #d8dcdf; + color: #5a6570; + } + pre { white-space: pre-wrap; word-break: break-word; } +`; + +export const MailBody: React.FC = ({ html, text }) => { + const [showImages, setShowImages] = useState(false); + const [showSource, setShowSource] = useState(false); + const [expanded, setExpanded] = useState(false); + + const hasHtml = Boolean(html && html.trim()); + const hasRemoteImages = hasHtml && /]+src\s*=\s*["']?https?:/i.test(html ?? ''); + + if (!hasHtml || showSource) { + return ( +
    + {hasHtml && ( + + )} +
    +          {text || 'Kein Text vorhanden.'}
    +        
    +
    + ); + } + + // Bilder werden ueber die CSP des eingebetteten Dokuments freigegeben oder + // gesperrt. Skripte sind in beiden Faellen ausgeschlossen. + const imagePolicy = showImages ? 'img-src data: https:' : 'img-src data:'; + const srcDocument = ` + +${html}`; + + return ( +
    +
    + {hasRemoteImages && ( + + )} + + + {hasRemoteImages && !showImages && ( + + Externe Bilder gesperrt – sie würden dem Absender das Öffnen melden. + + )} +
    + +