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. + + )} +
    + +