feat(postfach): crm@perinco.de lesend anbinden, Kontakte aus Mails

Spiegelt das Vertriebspostfach ueber Microsoft Graph ins CRM. Aus jeder Mail
entstehen Kontakt-Vorschlaege, und jede Mail laesst sich an Opportunities und
Kontakte haengen.

Uebernommen ist die Idee aus dem Altsystem KeepTrack, nicht die Umsetzung:

- Delta Query statt "$filter=isRead eq false" mit anschliessendem PATCH. Damit
  genuegt die Anwendungsberechtigung Mail.Read, es braucht keinen Schreibzugriff
  auf das Postfach, und eine vorher in Outlook geoeffnete Mail geht nicht
  verloren. Der Fortschritt liegt als delta_link in der Datenbank und ueberlebt
  Neustarts; ein Lauf ist gedeckelt und fortsetzbar.
- Tenant- und Client-ID kommen aus der .env, nicht aus dem Browser. Im
  Altsystem konnte jeder Angemeldete das Server-Secret gegen einen fremden
  Tenant verwenden.
- Kontaktdaten werden aus den Graph-Feldern und der Signatur gelesen, ohne
  Sprachmodell. Mailinhalte verlassen den Server nicht.
- Ein Vorschlag ohne E-Mail-Adresse wird nicht mit einer erfundenen Adresse
  angelegt, sondern bleibt zur Durchsicht liegen. Ist die Adresse schon als
  Kontakt vorhanden, werden nur leere Felder ergaenzt.
- Mails haengen als eigene Verknuepfung an Opportunity und Kontakt, nicht als
  Text im Notizfeld - loesbar und mehrfach moeglich.
- Fremdes Mail-HTML wird serverseitig entschaerft und in einem iframe mit
  sandbox="" gezeigt. Externe Bilder sind per CSP gesperrt, damit das Oeffnen
  dem Absender nichts meldet.
- Der Verbindungstest prueft Anmeldung und tatsaechlichen Postfachzugriff und
  benennt den wahrscheinlichen Grund. Der alte Test holte nur ein Token.

Fehlen die MS_*-Werte, startet das CRM normal; die Einstellungen nennen die
fehlende Variable. Neue Tabellen: crm_mail_messages, crm_mail_links,
crm_mail_contact_candidates, crm_mail_sync_state, crm_settings.

Uebernommene Kontakte tragen source = 'Mail'; die Kontaktliste hat dafuer einen
Herkunftsfilter.

Nicht enthalten: Senden, Antworten, Weiterleiten, Anhaenge und die automatische
Zuordnung zu Opportunities.

npm test im Verzeichnis server prueft Kontaktextraktion und Graph-Client
(47 Pruefungen, ohne Framework und ohne Datenbank).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-11 00:06:01 +02:00
co-authored by Claude Opus 5
parent 3647201111
commit 6fd713497d
42 changed files with 5926 additions and 32 deletions
+3
View File
@@ -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
+9 -6
View File
@@ -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
+25 -3
View File
@@ -34,7 +34,25 @@ flowchart LR
```
Der Scanner im CRM haengt unter `/api/scanner` und braucht damit **keine eigene
Apache-Regel**: alles laeuft ueber den bestehenden `/api`-Proxy.
Apache-Regel**: alles laeuft ueber den bestehenden `/api`-Proxy. Dasselbe gilt fuer
die Postfachanbindung unter `/api/mail` (siehe
[11 Postfachanbindung](11-postfach.md)).
## Ausgehende Verbindungen
Das Backend spricht von sich aus mit drei fremden Diensten. Alle drei sind
optional - fehlen die Zugangsdaten, laeuft die Anwendung ohne den jeweiligen
Teil weiter.
| Ziel | Wofuer | Ausgeloest von |
| --- | --- | --- |
| Projektboersen | Scraping der Projektangebote | Scanner-Scheduler |
| `api.openai.com` | Bewertung Lebenslauf gegen Projekt | Scanner-Pipeline |
| `login.microsoftonline.com`, `graph.microsoft.com` | Postfach `crm@perinco.de` lesen | Mail-Scheduler |
Die Postfachanbindung schickt **keine** Mailinhalte an Dritte: Kontaktdaten
werden auf dem Server aus Graph-Feldern und Signaturen gelesen, ohne
Sprachmodell.
## Anfrageweg mit Anmeldung
@@ -72,10 +90,14 @@ sofort draussen, ohne auf den Ablauf des Tokens zu warten.
```mermaid
flowchart TD
L["Anmeldung"] --> R{"Rolle"}
R -->|admin| A["Opportunities, Kalender, Kontakte, Scanner<br/>Einstellungen: Nutzerverwaltung, Mein Konto, Scanner, Whisper"]
R -->|user| U["Opportunities, Kalender, Kontakte, Scanner<br/>Einstellungen: Mein Konto, Whisper"]
R -->|admin| A["Opportunities, Kalender, Kontakte, Mails, Scanner<br/>Einstellungen: Nutzerverwaltung, Mein Konto, Postfach, Scanner, Whisper"]
R -->|user| U["Opportunities, Kalender, Kontakte, Mails, Scanner<br/>Einstellungen: Mein Konto, Whisper"]
```
Bei den Mails darf jede Rolle lesen und zuordnen - `crm@perinco.de` ist ein
gemeinsames Vertriebspostfach. Einrichten, Verbindungstest und manueller Abgleich
sind Administratoren vorbehalten.
Die Rollenpruefung sitzt **doppelt**: das Menue blendet aus, was nicht erlaubt
ist, und das Backend weist Zugriffe zusaetzlich mit `403` ab (`requireAdmin`).
Ausnahme ist `GET /api/users/directory` - die Benutzerliste brauchen alle Rollen
+83 -1
View File
@@ -4,7 +4,7 @@ Zwei Datenbanken, absichtlich getrennt:
| Datenbank | Inhalt | Zugriff |
| --- | --- | --- |
| `perinco_CRM` | Benutzer, Kontakte, Opportunities, Termine | `mysql2` direkt, Schema aus `server/src/schema.sql` |
| `perinco_CRM` | Benutzer, Kontakte, Opportunities, Termine, Postfach, Einstellungen | `mysql2` direkt, Schema aus `server/src/schema.sql` |
| `perinco_scanner` | Suchagenten, Portale, Projekte, Treffer, Scanner-Einstellungen | Sequelize, Schema aus den Modellen (`sequelize.sync()`) |
Die Trennung ist kein Zufall: die Scanner-Tabellen sind eine 1:1-Uebernahme aus
@@ -95,6 +95,88 @@ Drei Eigenheiten, die man kennen muss:
Bild vor dem Upload auf 256 px (`src/lib/imageCompress.ts`), aus 2,9 MB werden
wenige Kilobyte.
### Postfach crm@perinco.de
Diese Tabellen liegen in derselben Datenbank, haengen aber nur ueber
`crm_mail_links` und `crm_mail_contact_candidates.lead_id` am Rest. Fachliche
Beschreibung in [11 Postfachanbindung](11-postfach.md).
```mermaid
erDiagram
crm_mail_messages ||--o{ crm_mail_links : hat
crm_mail_messages ||--o{ crm_mail_contact_candidates : liefert
crm_leads ||--o{ crm_mail_links : "lead_id"
crm_opportunities ||--o{ crm_mail_links : "opportunity_id"
crm_leads ||--o{ crm_mail_contact_candidates : "lead_id, nach Uebernahme"
crm_mail_messages {
int id PK
varchar graph_id "Id aus Microsoft Graph"
char graph_id_hash UK "SHA-256 hex, traegt die Eindeutigkeit"
varchar conversation_id "Unterhaltung"
varchar subject
varchar from_email
longtext to_json "[{name,email}] als Text"
datetime received_at "UTC"
longtext body_text "aus HTML gewandelt, Zeilen erhalten"
longtext body_html "entschaerft fuer die Anzeige"
tinyint has_attachments
tinyint is_deleted "im Postfach geloescht"
}
crm_mail_links {
int id PK
int message_id FK
int lead_id FK "NULL erlaubt"
int opportunity_id FK "NULL erlaubt"
enum origin "manuell | automatisch"
varchar note
int created_by FK
}
crm_mail_contact_candidates {
int id PK
int message_id FK
char dedupe_hash "aus E-Mail bzw. Name+Telefon"
varchar first_name
varchar last_name
varchar email
varchar company
enum origin "absender | empfaenger | signatur | text"
tinyint confidence "0-100"
enum status "offen | uebernommen | verworfen | dublette"
int lead_id FK "gesetzt ab uebernommen"
}
crm_mail_sync_state {
tinyint id PK "immer 1"
varchar mailbox
text delta_link "naechster Graph-Aufruf"
tinyint has_more "nextLink offen"
datetime last_run_at
datetime last_success_at
text last_error
}
crm_settings {
varchar key_name PK
text value "alles als Text, auch Zahlen und Schalter"
}
```
Vier Eigenheiten:
- **Die Graph-ID hat einen Hash daneben.** Sie ist mit ueber 150 Zeichen zu lang
fuer einen Unique-Index unter `utf8mb4`; die Eindeutigkeit traegt deshalb
`graph_id_hash` (SHA-256 als Hex, `CHAR(64)`).
- **`received_at` steht in UTC**, anders als `crm_events.starts_at` (lokale
Wandzeit). Graph liefert UTC, und eine stille Umrechnung beim Speichern waere
eine Fehlerquelle. Das Frontend rechnet um (`parseMailDate` in
`src/lib/mailApi.ts`).
- **`crm_mail_links` erlaubt NULL in beiden Fremdschluesseln.** MariaDB laesst in
einem Unique-Index mehrere NULL zu - die beiden Schluessel sperren damit genau
die Doppelung desselben Paares, und eine Mail kann an mehreren Kontakten und
mehreren Opportunities haengen.
- **`crm_settings` enthaelt keine Geheimnisse.** Zugangsdaten fuer Microsoft 365
stehen ausschliesslich in `server/.env`, damit ein Datenbank-Dump keine Secrets
enthaelt.
## perinco_scanner
```mermaid
+32 -7
View File
@@ -12,6 +12,9 @@ flowchart TD
K --> O["Opportunities<br/>(Startansicht)"]
K --> C["Kalender"]
K --> L["Kontakte"]
K --> M["Mails"]
M --> M1["Postfach"]
M --> M2["Kontakt-Vorschlaege"]
K --> SC["Scanner"]
SC --> SC1["Dashboard"]
SC --> SC2["Unpassend"]
@@ -20,10 +23,14 @@ flowchart TD
SB --> E["Einstellungen"]
E --> E1["Nutzerverwaltung (admin)"]
E --> E2["Mein Konto"]
E --> E3["Scanner (admin)"]
E --> E4["Whisper"]
E --> E3["Postfach (admin)"]
E --> E4["Scanner (admin)"]
E --> E5["Whisper"]
```
*Postfach* und *Kontakt-Vorschlaege* sind Reiter **innerhalb** der Ansicht
*Mails*, keine Menuepunkte - sie gehoeren zusammen und wechseln oft hin und her.
Der Menuepunkt *Scanner* klappt seine Unterpunkte auf; *Scanner* selbst ist das
Dashboard. Bei eingeklappter Leiste (Symbolbreite) erscheinen nur die Symbole,
die Unterpunkte kommen mit den Beschriftungen zurueck.
@@ -39,8 +46,8 @@ flowchart TB
WH["WhisperProvider<br/>Modell, Worker"]
end
APP["App.tsx<br/>currentView, settingsTab"]
NAV["NavigationProvider<br/>goTo(view, {tab})"]
APP["App.tsx<br/>currentView, settingsTab, focusLeadId"]
NAV["NavigationProvider<br/>goTo(view, {tab, leadId})"]
DATA["loadData()<br/>leads, connections, events, users, opportunities"]
Kontexte --> APP
@@ -49,14 +56,25 @@ flowchart TB
DATA --> V1["Opportunities"]
DATA --> V2["Kalender"]
DATA --> V3["Kontakte"]
DATA --> V4["Mails<br/>leads + opportunities fuer den Zuordnungsdialog"]
NAV --> DF["DictationField<br/>springt nach Whisper"]
NAV --> ML["Mails<br/>springt zum Kontakt einer Zuordnung"]
SCV["Scanner-Ansichten"] -->|"laden selbst"| API["/api/scanner"]
MV["Mailansicht"] -->|"laedt selbst"| MAPI["/api/mail"]
```
Die CRM-Daten werden **einmal zentral** geladen und als Props durchgereicht; die
Scanner-Ansichten laden dagegen selbst, weil sie eine andere Datenbank und eine
eigene Auffrischung im 30-Sekunden-Takt haben.
Scanner- und Mailansichten laden ihre eigenen Daten selbst - der Scanner, weil er
eine andere Datenbank und eine Auffrischung im 30-Sekunden-Takt hat; die Mails,
weil sie blaettern, suchen und filtern und dabei nicht die ganze Liste im Speicher
halten sollen. Kontakte und Opportunities bekommt die Mailansicht dagegen als
Props: der Zuordnungsdialog braucht sie beide zum Vorschlagen.
`goTo('Kontakte', { leadId })` ist der Sprung aus einer Mailzuordnung zum
Kontakt. `App.tsx` haelt die Id in `focusLeadId`, die Kontaktansicht oeffnet den
Kontakt und meldet mit `onFocusHandled()` zurueck - der Effekt haengt an `leads`
und wartet damit auf einen gerade erst angelegten Kontakt.
`reload={() => loadData(true)}` laedt still nach - ohne Ladeanzeige, damit ein
Speichern im Dialog nicht die ganze Liste flackern laesst.
@@ -70,6 +88,7 @@ src/
lib/
api.ts Fetch-Wrapper (JWT, 401-Behandlung) + CRM-Endpunkte
scannerApi.ts Typisierte Endpunkte unter /api/scanner
mailApi.ts Endpunkte unter /api/mail, UTC-Datumshelfer
auth.tsx Anmeldung, Benutzer, Rolle
theme.tsx Hell/Dunkel/System
navigation.tsx Ansichtswechsel fuer tiefe Komponenten
@@ -83,8 +102,9 @@ src/
opportunities/ Kachel, Liste, Pipeline (Drag & Drop), Formular
calendar/ Monat/Woche/Tag, Termine (Drag & Drop), @-Mentions
leads/ Kachel, Liste, Netzwerkdiagramm (d3-force)
mail/ Postfachliste, Detail, Zuordnung, Kontakt-Vorschlaege
scanner/ Dashboard, Unpassend, Suchagenten, Boersen-Konten
settings/ Reiter: Nutzerverwaltung, Konto, Scanner, Whisper
settings/ Reiter: Nutzerverwaltung, Konto, Postfach, Scanner, Whisper
whisper/ Einrichtung der Spracherkennung
ui/ Modal, Avatare, Diktatfeld
```
@@ -109,3 +129,8 @@ Ein Inline-Skript in `index.html` setzt die Klasse vor dem ersten Frame, sonst
blitzt beim Laden die helle Variante auf. Drei Werte bleiben in beiden Modi
gleich: `ink-950` (liegt nur unter Dialog-Overlays), `text-white` (steht immer auf
farbigen Flaechen) und `brand-500` (das CI-Gruen der Buttons).
Eine bewusste Ausnahme: der **Mailinhalt** bleibt auch im Dunkelmodus auf weissem
Grund. Fremdes Mail-HTML bringt seine eigenen Farben mit; ein dunkler Hintergrund
darunter macht schwarze Schrift auf schwarzer Flaeche. Der Rahmen liest sich damit
wie ein Blatt Papier im dunklen Fenster - gewollt.
+22
View File
@@ -60,11 +60,25 @@ mitten im Scrapen beenden.
| `SCANNER_SCHEDULER` | Hintergrund-Scraper, Standard an, nur `false` schaltet ab |
| `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz fuer den ersten Lauf, Standard 30 |
| `LEGACY_*` | Quelle fuer `npm run scanner:import` |
| `MS_TENANT_ID`, `MS_CLIENT_ID`, `MS_CLIENT_SECRET` | App-only-Zugriff auf Microsoft Graph |
| `MS_MAILBOX` | abzugleichendes Postfach, Standard `crm@perinco.de` |
| `MS_MAIL_FOLDER` | Ordner, Standard `inbox` |
| `MAIL_SYNC` | Postfachabgleich, Standard an, nur `false` schaltet ab |
| `MAIL_MAX_PAGES`, `MAIL_MAX_BODY_CHARS` | Bremsen fuer Erstabgleich und Textgroesse |
Die Scanner-Datenbank wird beim Start automatisch angelegt
(`CREATE DATABASE IF NOT EXISTS`), Tabellen kommen aus `sequelize.sync()`, Portale
und Standardeinstellungen werden geseedet.
Fehlen die `MS_*`-Werte, startet das Backend normal und protokolliert
`[Mail] Zugangsdaten fehlen`. Die Ansicht *Mails* und der Einstellungsreiter
*Postfach* nennen die fehlende Variable beim Namen. Einrichtung in Entra und
Exchange Online: [11 Postfachanbindung](11-postfach.md).
> Die `MS_*`-Werte werden **beim Start** gelesen. Nach einer Aenderung an der
> `.env` muss der Dienst neu starten, ein Speichern in der Oberflaeche genuegt
> nicht - dort liegen nur Intervall, Schalter und Zeitraum (`crm_settings`).
## Playwright
`playwright` ist auf **exakt 1.60.0** gepinnt - dieselbe Version wie die alte App.
@@ -105,6 +119,8 @@ systemctl restart perinco-crm-backend
| `Target page ... has been closed` | Dienst wurde mitten im Scrapen neu gestartet |
| Backend stirbt beim Scrapen | `MemoryMax` zu klein, `systemctl show ... -p MemoryCurrent` |
| Ton bleibt aus | Schwellenwert, Dashboard-Ansicht offen, einmal auf der Seite geklickt? |
| Keine Mails im Postfachreiter | `journalctl -u perinco-crm-backend \| grep '\[Mail\]'`, dann Verbindungstest in den Einstellungen |
| Mail-Abgleich meldet `ErrorAccessDenied` | Administratorzustimmung fuer `Mail.Read` fehlt, oder die ApplicationAccessPolicy erlaubt das Postfach nicht |
Nuetzliche Abfragen:
@@ -116,6 +132,12 @@ curl -s http://127.0.0.1:5006/api/health
set -a; . /var/www/perinco_CRM/server/.env; set +a
mysql -u"$DB_USER" -p"$DB_PASSWORD" "$SCANNER_DB_NAME" -e \
"SELECT COUNT(*) FROM matches; SELECT key_name, value FROM settings"
# Zustand des Postfachabgleichs
mysql -u"$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" -e \
"SELECT mailbox, has_more, last_run_at, last_success_at, last_error FROM crm_mail_sync_state;
SELECT COUNT(*) AS mails FROM crm_mail_messages;
SELECT status, COUNT(*) FROM crm_mail_contact_candidates GROUP BY status"
```
## Sicherung
+82
View File
@@ -34,6 +34,7 @@ die Scanner-Einstellungen. Alles andere ist fuer beide Rollen gleich.
│ Opportunities├──────────────────────────────────────────┤
│ Kalender │ │
│ Kontakte │ Inhalt │
│ Mails │ │
│ Scanner ▾ │ │
│ Dashboard │ │
│ Unpassend │ │
@@ -126,6 +127,72 @@ setzt ihn fest (gruener Punkt), Doppelklick loest ihn wieder. Ueber *Verbindung
erstellen* zwei Kontakte anklicken, um eine Verbindung anzulegen; `Esc` bricht ab.
Suche und Statusfilter wirken auch hier.
Sobald Kontakte aus dem Postfach uebernommen wurden, erscheint neben dem
Statusfilter ein **Herkunftsfilter**: *Aus Mails* zeigt nur die automatisch
entstandenen, *Selbst angelegt* nur die von Hand erfassten.
## Mails
Das gemeinsame Postfach `crm@perinco.de` liegt hier im CRM. Der Zugriff ist
**nur lesend** - im Postfach selbst wird nichts veraendert, keine Mail als gelesen
markiert, nichts geloescht. Antworten geht weiter in Outlook; der Link *In Outlook
oeffnen* springt direkt zur Mail.
Zwei Reiter oben:
### Postfach
Links die Mailliste, rechts die gewaehlte Mail. Die Kennzeichen an einem
Listeneintrag sagen, was noch zu tun ist:
| Kennzeichen | Bedeutung |
| --- | --- |
| **1x zugeordnet** | haengt an einer Opportunity oder einem Kontakt |
| **2 Kontakte** | so viele Kontakt-Vorschlaege warten noch auf eine Entscheidung |
| 📎 | Anhaenge vorhanden (im Postfach, nicht im CRM) |
Das Auswahlfeld filtert die Liste. **Nicht zugeordnet** ist die eigentliche
Arbeitsliste: alles, was noch nirgends haengt. Die Suche geht ueber Betreff,
Absender und den kompletten Mailtext; mehrere Woerter wirken als ein Suchbegriff.
**Zuordnen** haengt die Mail an eine Opportunity und/oder einen Kontakt. Der
Dialog schlaegt oben vor, was zu den Mailadressen passt - ein Klick auf den
Vorschlag genuegt. Beides gleichzeitig ist moeglich, jede Zuordnung laesst sich
einzeln wieder mit dem **x** loesen.
Beim Mailinhalt sind externe Bilder zunaechst gesperrt. Das ist Absicht: geladene
Bilder melden dem Absender, dass die Mail geoeffnet wurde. Ueber *Externe Bilder
laden* geht es pro Mail. *Groesser* klappt den Inhalt auf, *Nur Text anzeigen*
zeigt die Rohfassung ohne Formatierung.
Steht an einer Mail **Im Postfach geloescht**, wurde sie in Outlook entfernt. Im
CRM bleibt sie erhalten, damit Zuordnungen nicht verloren gehen.
### Kontakt-Vorschlaege
Aus Absender, Empfaengerliste und Signatur jeder Mail liest die Anwendung
Kontaktdaten. Wer eine echte Mailadresse hat und aus Absender oder
Empfaengerliste stammt, wird **automatisch** Kontakt. Alles Unsichere landet
hier - typischerweise Personen, die nur mit Telefonnummer im Text genannt werden.
Der Balken rechts ist die Zuversicht: je voller und gruener, je verlaesslicher die
Angaben. Das Kennzeichen darunter sagt, woher sie stammen (*Absender*,
*Empfaenger*, *Signatur*, *Im Text genannt*).
Zwei Knoepfe:
* **Als Kontakt anlegen** - es entsteht ein neuer Kontakt mit Herkunft „Mail".
* **Ergaenzen** (statt „anlegen", wenn **Bereits vorhanden** dransteht) - der
vorhandene Kontakt wird um die fehlenden Angaben erweitert. Was schon gepflegt
ist, bleibt unangetastet.
* **Verwerfen** - der Vorschlag verschwindet aus der Liste.
Eigene Kollegen (`@perinco.de`) und Funktionsadressen wie `info@` oder
`no-reply@` werden nie vorgeschlagen.
Ist die Liste leer und die automatische Uebernahme an, ist das der Normalzustand:
es gab nichts zu entscheiden.
## Scanner
Durchsucht Projektboersen und bewertet jeden Fund mit KI gegen die Lebenslaeufe
@@ -223,6 +290,7 @@ Datenbank.
| --- | --- | --- |
| **Nutzerverwaltung** | Admin | Benutzer anlegen, bearbeiten, deaktivieren, loeschen |
| **Mein Konto** | alle | Foto, Kalenderfarbe, Passwort aendern |
| **Postfach** | Admin | Verbindung zu Microsoft 365, Abgleich, Kontaktuebernahme |
| **Scanner** | Admin | Suchintervall, Schwellenwerte, KI-Schluessel, Ton, E-Mail |
| **Whisper** | alle | Spracherkennung fuer das Diktat einrichten |
@@ -234,6 +302,16 @@ nicht mehr: kein Lauf im Hintergrund, und *Jetzt scannen* antwortet mit einem
Hinweis statt zu starten. Der Zustand steht auch oben im Scanner-Dashboard, damit
man nicht raetselt, warum keine neuen Treffer kommen.
Im Reiter **Postfach** steht oben, ob die Verbindung zu Microsoft 365 steht.
**Verbindung testen** prueft beides: die Anmeldung *und* den tatsaechlichen
Zugriff auf `crm@perinco.de`. Schlaegt etwas fehl, nennt die Meldung den
wahrscheinlichen Grund. Darunter lassen sich Intervall, Zeitraum des ersten
Durchlaufs und die automatische Kontaktuebernahme einstellen.
Die Zugangsdaten selbst stehen **nicht** in der Oberflaeche, sondern in der
Serverkonfiguration - sie werden beim Start gelesen. Fehlt einer, sagt die Seite
welcher.
Foto und Kalenderfarbe werden **sofort** gespeichert, ohne Speichern-Knopf. Der
letzte aktive Administrator kann sich nicht selbst degradieren oder loeschen -
das verhindert der Server.
@@ -261,3 +339,7 @@ nicht zum Benutzerkonto.
| Boerse zeigt „Abgelaufen" | Cookies neu importieren |
| „Ein Suchlauf laeuft bereits" | kurz warten, es laeuft nur einer zur Zeit |
| Aendern nicht erlaubt | bei Opportunities darf nur Ersteller, Zustaendiger oder Admin |
| Keine Mails sichtbar | steht oben ein Hinweisbalken? Sonst Admin bitten, in *Einstellungen &rarr; Postfach* die Verbindung zu testen |
| „Erstabgleich laeuft noch" | aeltere Mails werden in mehreren Durchlaeufen nachgeholt, die Liste waechst noch |
| Bilder in einer Mail fehlen | *Externe Bilder laden* klicken - sie sind absichtlich gesperrt |
| Kontakt-Vorschlaege bleiben leer | Normalfall: sichere Vorschlaege werden automatisch uebernommen und tauchen hier nicht auf |
+32 -2
View File
@@ -53,10 +53,18 @@ ansehen - siehe *Oberflaeche pruefen ohne Datenbank* weiter unten.
```bash
npm run lint # = tsc --noEmit, das ist die Wahrheit
npm run build # muss ohne Fehler durchlaufen
cd server && npm test # Pruefungen der Postfachanbindung
```
Es gibt **keine automatischen Tests**. Wer etwas aendert, prueft mit `tsc`, einem
Build und einem Blick in die Oberflaeche.
Automatische Tests gibt es nur fuer **einen** Bereich: `server/test/mail.test.mjs`
prueft die Kontaktextraktion aus Mails und den Graph-Client. Beides laeuft
unbeaufsichtigt im Hintergrund und laesst sich von Hand kaum nachstellen - ein
falsch erkannter Name landet still in der Kontaktliste, und ein 429 vom echten
Dienst kommt genau dann, wenn niemand hinsieht. Kein Test-Framework, reines Node,
keine Datenbank.
Fuer alles andere gilt: `tsc`, ein Build und ein Blick in die Oberflaeche - notfalls
mit dem Stub aus [Oberflaeche pruefen ohne Datenbank](#oberflaeche-pruefen-ohne-datenbank).
## Aufbau
@@ -69,8 +77,10 @@ src/ Frontend
server/src/ Backend
server.js Express-App, Reihenfolge der Routen
db.js Verbindungspool, Schema-Init, Admin-Seed
settings.js crm_settings lesen und schreiben (ohne Geheimnisse)
routes/ ein Router je Ressource
middleware/auth.js JWT pruefen, Adminrechte
mail/ Postfach crm@perinco.de ueber Microsoft Graph
scanner/ portierter Projekt Scanner (Sequelize, Playwright, OpenAI)
docs/ diese Dokumentation
deploy/ Apache-Vhosts, systemd-Unit, Deployskript
@@ -141,6 +151,22 @@ Ein Sprung dorthin aus einer beliebigen Tiefe:
2. In `server/src/server.js` einhaengen - **vor** dem `/api`-404-Fallback.
3. In `src/lib/api.ts` eine typisierte Funktion ergaenzen.
### Neue Einstellung im CRM
Nur fuer Werte, die im Betrieb verstellbar sein sollen. **Zugangsdaten gehoeren
nicht dazu** - die stehen in `server/.env` und werden beim Start gelesen, damit ein
Datenbank-Dump keine Geheimnisse enthaelt.
1. Schluessel samt Vorgabe in das Vorgabenobjekt des Bereichs eintragen (Muster:
`MAIL_SETTING_DEFAULTS` in `server/src/mail/config.js`). Es ist gleichzeitig die
Positivliste des Speicher-Endpunkts.
2. Lesen mit `getSettings(defaults)` aus `server/src/settings.js`, schreiben mit
`setSetting()`. Zahlen und Schalter mit `asInt`/`asBool` wandeln.
3. Feld im passenden Einstellungsreiter ergaenzen.
Die Tabelle `crm_settings` legt `schema.sql` an; ein neuer Schluessel braucht keine
Migration.
## Fallen
Diese Punkte haben real Zeit gekostet:
@@ -157,6 +183,10 @@ Diese Punkte haben real Zeit gekostet:
| **`sequelize.sync({ alter: true })`** | Legt bei jedem Start Unique-Indizes neu an und sprengt irgendwann MySQLs Grenze von 64 Keys. Nur `sync()` benutzen. |
| **Kaskaden beim Loeschen** | Einen Suchagenten loeschen loescht alle seine Treffer mit. Inaktiv setzen ist meist gemeint. |
| **Doppelte Scanner-Laeufe** | Scheduler und *Jetzt scannen* teilen eine Sperre. Ohne sie zahlt man jede KI-Bewertung zweimal. |
| **`allow-same-origin` bei `srcDoc`** | Ein so eingebettetes Dokument erbt die Origin der Anwendung und kann `localStorage` lesen - also das Anmeldetoken. Fremdes HTML gehoert in `sandbox=""` ohne jedes Flag. Der Preis: die Inhaltshoehe ist nicht auslesbar, deshalb hat der Mailrahmen eine feste Hoehe. |
| **`/me` unter App-only** | Es gibt keinen Benutzer im Token, also auch kein `/me` - jeder Graph-Aufruf muss `/users/{postfach}/…` lauten. Genau daran ist das Altsystem gescheitert, still, weil die Antwort nicht geprueft wurde. |
| **UTC neben Wandzeit** | `crm_mail_messages.received_at` steht in UTC (so liefert Graph es), `crm_events.starts_at` in lokaler Wandzeit. Beim Lesen im Frontend `parseMailDate()` benutzen, sonst ist die Anzeige um Stunden verschoben. |
| **Aufzaehlungen in der Datenbank sind deutsch** | `crm_mail_links.origin` (`manuell`/`automatisch`), Vorschlagsstatus (`offen`, `uebernommen`, `verworfen`, `dublette`). Wer englische Werte schickt, bekommt einen Datenbankfehler statt einer Validierungsmeldung. |
## Oberflaeche pruefen ohne Datenbank
+65 -1
View File
@@ -70,6 +70,7 @@ Alle unter `router.use(requireAuth)`.
| `connections.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | jeder Angemeldete |
| `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; aendern/loeschen nur Ersteller oder Admin |
| `opportunities.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | aendern/loeschen nur Ersteller, Zustaendiger oder Admin |
| `mail.js` | `GET /status`, `/messages`, `/messages/:id`, `/candidates`; `POST /messages/:id/links`, `/candidates/:id/adopt`, `/candidates/:id/reject`; `DELETE /links/:id`; `requireAdmin`: `GET/POST /settings`, `POST /test`, `POST /sync` | lesen und zuordnen jeder; einrichten Admin |
Wiederkehrende Muster in diesen Routern:
@@ -86,6 +87,46 @@ Wiederkehrende Muster in diesen Routern:
* Der letzte aktive Administrator kann nicht degradiert, deaktiviert oder
geloescht werden.
### `server/src/settings.js`
`getSetting`, `getSettings`, `setSetting` auf `crm_settings`, dazu `asBool` und
`asInt`. Alle Werte liegen als Text in der Datenbank. `getSettings(defaults)`
nimmt die Vorgaben gleichzeitig als Positivliste der erlaubten Schluessel - ein
unbekannter Name kommt so nie in die Datenbank.
Bewusst getrennt vom Setting-Modell des Scanners (andere Datenbank) und bewusst
**ohne Geheimnisse**: Zugangsdaten stehen in `server/.env`.
### `server/src/mail/`
Postfachanbindung, fachlich in [11 Postfachanbindung](11-postfach.md).
| Datei | Inhalt |
| --- | --- |
| `config.js` | `.env` lesen, `MAIL_CONFIGURED`, `missingCredentials()`, verstellbare Einstellungen mit Vorgaben (`mailSettings()`) |
| `graph.js` | Token mit Zwischenspeicher, `graphRequest()` mit Wiederholung bei 429/5xx und Beachtung von `Retry-After`, `testConnection()` |
| `html.js` | `htmlToText()` (Zeilen bleiben erhalten, Linkziele werden hinter den Linktext gesetzt), `sanitizeHtml()` |
| `extract.js` | `extractCandidates()`, `parsePersonName()`, `parseSignature()`, `AUTO_ADOPT_MIN_CONFIDENCE` |
| `store.js` | Zustandszeile, `upsertMessage()`, `insertCandidates()`, `linkMessage()` |
| `contacts.js` | `adoptCandidate()`: anlegen oder vorhandenen Kontakt ergaenzen, `markCandidate()` |
| `sync.js` | ein Abgleichslauf, Delta-Seiten, Einzelsperre, Fehlerbehandlung |
| `scheduler.js` | Minutentakt, Faelligkeit aus `last_run_at`, kein Zeitfenster |
Eigenheiten, die man kennen muss:
* `graphRequest()` nimmt Pfade **und** vollstaendige URLs - Delta-Links kommen
absolut von Graph zurueck.
* Ein `401` verwirft das Token genau einmal und wiederholt; so ueberlebt ein Lauf
ein serverseitig zurueckgezogenes Token.
* `upsertMessage()` laesst die Textspalten unberuehrt, wenn `bodyText` im
uebergebenen Objekt `undefined` ist. Ein reines Metadaten-Update darf den schon
geholten Mailtext nicht loeschen.
* `extractCandidates()` verlangt im Fliesstext eine Anrede vor dem Namen.
Ohne diese Bedingung liest der Musterabgleich jede Folge grossgeschriebener
Woerter als Namen.
* Der Lauf ist **einfach gesperrt** (`running` in `sync.js`) - ein zweiter Aufruf
kommt sofort mit `skipped` zurueck, statt parallel zu laufen.
### `server/src/routes/scanner.js`
Die portierte Scanner-API unter `/api/scanner`, vollstaendig in
@@ -160,13 +201,25 @@ damit bleibt der Vergleich mit der alten App moeglich. Nur `ScannerProject`
traegt beide Schreibweisen fuer `created_at`, weil das Modell die Spalte selbst
umbenennt.
### `src/lib/mailApi.ts`
Typen (`MailMessageSummary`, `MailMessageDetail`, `MailCandidate`, `MailLink`,
`MailStatus`, `MailSyncResult`, `MailConnectionTest`, `MailSettingsValues`), das
Objekt `mailApi`, `MAIL_SETTINGS_DEFAULTS` und die Datumshelfer.
`parseMailDate()` haengt ein `Z` an den MariaDB-Zeitstempel, bevor daraus ein
`Date` wird. Ohne das liest der Browser den Wert als Ortszeit, und die Anzeige
waere je Zeitzone um Stunden verschoben - die Mailzeiten stehen als UTC in der
Datenbank, anders als die Kalendertermine. `formatMailDate()` zeigt heute nur die
Uhrzeit, dieses Jahr Tag und Monat, sonst mit Jahr.
### Kontexte
| Datei | Haelt | Eigenheit |
| --- | --- | --- |
| `auth.tsx` | Benutzer, `isAdmin`, `login`, `logout`, `updateUser` | prueft beim Start das gespeicherte Token gegen `/auth/me` |
| `theme.tsx` | hell/dunkel/System | Wahl in `localStorage`, Doppelklick loest sie wieder |
| `navigation.tsx` | `currentView`, `goTo(view, { tab })` | erspart es, Callbacks durch vier Ebenen zu reichen |
| `navigation.tsx` | `currentView`, `goTo(view, { tab, leadId })` | erspart es, Callbacks durch vier Ebenen zu reichen; `leadId` oeffnet den Kontakt direkt |
| `whisper.tsx` | Modellstatus, Worker, Fortschritt, `transcribe` | ein Worker fuer die ganze App |
### Whisper
@@ -188,6 +241,8 @@ Warum genau diese Konstanten festgelegt sind, steht in
| --- | --- |
| `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `StatusBadge`, `Avatar`, Statusfarben |
| `components/scanner/scannerUi.tsx` | `cardClass`, Buttonklassen, `MatchBadge`, `MatchStatusBadge`, `PortalStatusPill`, `formatDateTime`, `projectTimestamp` |
| `components/mail/mailUi.tsx` | `Badge`, `ConfidenceBar`, Herkunfts- und Statusbeschriftungen, `formatAddresses`, `avatarColor` |
| `components/mail/MailBody.tsx` | Mailinhalt im `<iframe sandbox="">`, externe Bilder per CSP gesperrt |
| `components/ui/Modal.tsx` | `Modal` (Escape schliesst) und `ConfirmDialog` |
| `components/Toast.tsx` | `useToast().addToast(text, kind)` mit `success`, `error` oder `info` |
| `components/ui/DictationField.tsx` | Mikrofon-Knopf, Hinweisbox, Textfeld mit Diktat |
@@ -201,6 +256,7 @@ nicht auseinanderlaufen:
| Export | Zweck |
| --- | --- |
| `ViewState`, `SettingsTab`, `ADMIN_SETTINGS_TABS`, `SCANNER_VIEWS` | Ansichten und Reiter |
| `MAIL_LEAD_SOURCE`, `LeadOriginFilter` | Herkunft „Mail" in `Lead.source` und ihr Filter |
| `USER_COLORS`, `userColor()` | Farbe gesetzt, sonst stabil aus der Benutzer-Id |
| `opportunityVolume()`, `weightedVolume()`, `formatEuro()` | Tagessatz x Personentage, gewichtet, formatiert |
| `OPPORTUNITY_STAGES`, `OPEN_STAGES`, `LEAD_STATUSES`, `CONNECTION_TYPES` | die Aufzaehlungen der Datenbank |
@@ -218,8 +274,16 @@ flowchart TD
F -->|"still, ohne Ladeanzeige"| C
G["Scanner-Ansichten"] -->|"eigener 30-s-Takt"| H["/api/scanner"]
I["Mailansicht"] -->|"blaettert, sucht, filtert"| J["/api/mail"]
D --> I
I -->|"nach Uebernahme eines Vorschlags"| F
```
Die Mailansicht laedt ihre Mails selbst, bekommt Kontakte und Opportunities aber
als Props - der Zuordnungsdialog braucht beide Listen zum Vorschlagen. Nach einer
Kontaktuebernahme ruft sie `reload()`, damit der neue Kontakt in der Kontaktliste
der App auftaucht.
Die fuenf CRM-Endpunkte laufen **parallel**, nicht nacheinander - sonst addieren
sich fuenf Rundreisen beim Anmelden. Die Scanner-Ansichten haengen bewusst nicht
an `loadData`: andere Datenbank, andere Auffrischung.
+23
View File
@@ -10,8 +10,11 @@ Quelltext.
| **Ausschluss-Begriff** | Wort, das ein Projekt fuer diesen Agenten sofort aussortiert - noch **vor** der KI-Bewertung, spart also Kosten. Typisch: „Arbeitnehmerueberlassung". |
| **Benutzer** | Konto fuer die Anmeldung, Rolle `admin` oder `user`. Tabelle `crm_users`. |
| **Boerse** / Portal | Projektplattform, die der Scanner durchsucht. Oeffentlich (Hays, Etengo) oder geschuetzt (Freelance.de, Freelancermap.de). Tabelle `portals`. |
| **App-only** | Zugriff auf Microsoft 365 im Namen der Anwendung, nicht eines Benutzers (OAuth-Ablauf *Client Credentials*). Braucht Anwendungsberechtigungen und Administratorzustimmung, aber keinen Login. |
| **Cookie-Import** | Uebertragen einer angemeldeten Browsersitzung auf den Scraper, per JSON-Export der Erweiterung *Cookie-Editor*. Landet als `session_state` am Portal. |
| **Delta Query** | Graph-Abfrage, die beim naechsten Aufruf nur noch die Aenderungen liefert. Der Fortschritt steckt in einem Verweis (`delta_link`), der in `crm_mail_sync_state` liegt. |
| **Kontakt** / Lead | Person im Netzwerk: Firma, Position, Status, Verbindungen. Tabelle `crm_leads`. Im Code durchgaengig `lead`. |
| **Kontakt-Vorschlag** | Aus einer Mail gelesene Person, die noch kein Kontakt ist. Tabelle `crm_mail_contact_candidates`. Wird uebernommen, ergaenzt oder verworfen. |
| **Match** / Treffer | Die Bewertung **eines** Projekts fuer **einen** Agenten: Prozentwert, Pro-Liste, Contra-Liste, Status. Tabelle `matches`. Ein Projekt erzeugt so viele Treffer, wie es aktive Agenten gibt. |
| **Opportunity** | Geschaeft in Arbeit: Phase, Tagessatz, Laufzeit, Wahrscheinlichkeit, Ansprechpartner. Tabelle `crm_opportunities`. |
| **Phase** / Stage | Fortschritt einer Opportunity: Neu, Qualifizierung, Angebot, Verhandlung, Gewonnen, Verloren. |
@@ -26,7 +29,27 @@ Quelltext.
| **Verbindung** | Kante zwischen zwei Kontakten im Netzwerkdiagramm, mit Art und Staerke. Tabelle `crm_lead_connections`. |
| **Volumen** | Tagessatz x Personentage. **Gewichtet** heisst zusaetzlich mit der Abschlusswahrscheinlichkeit multipliziert. |
| **Whisper** | Das Spracherkennungsmodell fuer das Diktat. Laeuft im Browser, nicht auf dem Server. |
| **Zugriffsrichtlinie** | `ApplicationAccessPolicy` in Exchange Online. Begrenzt eine App-only-Berechtigung auf einzelne Postfaecher - ohne sie gilt sie tenant-weit. |
| **Zustaendiger** / Owner | Benutzer, dem ein Kontakt oder eine Opportunity zugeordnet ist. Darf sie aendern, auch wenn er sie nicht angelegt hat. |
| **Zuversicht** / Confidence | Wert von 0 bis 100 an einem Kontakt-Vorschlag. Ab 70 und mit echter Mailadresse wird automatisch uebernommen. |
## Herkunft eines Kontakt-Vorschlags
| Wert in der DB | in der Oberflaeche | Zuversicht |
| --- | --- | --- |
| `absender` | Absender | 90, mit bestaetigender Signatur 95 |
| `empfaenger` | Empfaenger | 78 |
| `signatur` | Signatur | 55 - weitere Adresse in der Signatur, etwa eine Assistenz |
| `text` | Im Text genannt | 35 - nur mit Anrede und Rufnummer, nie automatisch uebernommen |
## Status eines Kontakt-Vorschlags
| Wert | in der Oberflaeche | Bedeutung |
| --- | --- | --- |
| `offen` | Offen | wartet auf eine Entscheidung |
| `uebernommen` | Uebernommen | es wurde ein Kontakt angelegt |
| `dublette` | Ergaenzt | Adresse war vorhanden, der Kontakt wurde erweitert |
| `verworfen` | Verworfen | von Hand abgelehnt |
## Statuswerte im Scanner
+305
View File
@@ -0,0 +1,305 @@
# 11 Postfachanbindung (Microsoft 365)
Das gemeinsame Vertriebspostfach `crm@perinco.de` wird **lesend** in das CRM
gespiegelt. Aus jeder Mail entstehen Kontakt-Vorschlaege, und jede Mail laesst
sich an eine Opportunity und an Kontakte haengen.
Vorbild ist die gleiche Funktion im Altsystem KeepTrack. Uebernommen wurde die
Idee, nicht die Umsetzung - die Unterschiede stehen unten unter
[Was anders ist als im Altsystem](#was-anders-ist-als-im-altsystem) und sind der
Grund fuer mehrere Entwurfsentscheidungen.
## Ueberblick
```mermaid
flowchart LR
subgraph M365["Microsoft 365"]
ENT["Entra ID<br/>App-Registrierung"]
GR["Microsoft Graph<br/>graph.microsoft.com"]
MB[("Postfach<br/>crm@perinco.de")]
GR --- MB
end
subgraph Server["perinco CRM Backend"]
SCH["mail/scheduler.js<br/>node-cron, Minutentakt"]
SY["mail/sync.js<br/>Delta Query"]
EX["mail/extract.js<br/>Kontakte lesen"]
ST["mail/store.js"]
CO["mail/contacts.js<br/>Uebernahme"]
API["routes/mail.js<br/>/api/mail"]
end
DB[("perinco_CRM<br/>crm_mail_*")]
SPA["Ansicht Mails"]
SCH --> SY
SY -->|"Token: client_credentials"| ENT
SY -->|"GET .../messages/delta"| GR
SY --> EX
EX --> ST
ST --> DB
EX --> CO
CO --> DB
SPA -->|"/api/mail/..."| API
API --> DB
API --> SY
```
## Was in Microsoft 365 eingerichtet sein muss
Genau **eine** Berechtigung, **ein** Zustimmungsklick, **eine**
Zugriffsrichtlinie. Kein Schreibrecht, kein Redirect-URI, kein interaktiver
Login.
1. **App-Registrierung** in Entra (Name z. B. `perinco CRM`), Kontotyp „nur
dieses Verzeichnis", Redirect-URI leer lassen. Anwendungs-ID und
Verzeichnis-ID notieren.
2. **Client-Geheimnis** anlegen und den Wert sofort kopieren.
3. **API-Berechtigung** Microsoft Graph &rarr; *Anwendungsberechtigungen* &rarr;
`Mail.Read`, danach Administratorzustimmung erteilen.
`Mail.ReadBasic` genuegt nicht, es liefert keinen Mailtext.
4. **Zugriff auf dieses eine Postfach begrenzen.** Anwendungsberechtigungen
gelten tenant-weit; ohne Richtlinie darf die App jedes Postfach lesen:
```powershell
Connect-ExchangeOnline -UserPrincipalName adm.sascha.wolf@perinco.onmicrosoft.com
New-ApplicationAccessPolicy -AppId <APP-ID> `
-PolicyScopeGroupId crm@perinco.de `
-AccessRight RestrictAccess `
-Description "perinco CRM: nur crm@perinco.de"
Test-ApplicationAccessPolicy -Identity crm@perinco.de -AppId <APP-ID> # Granted
Test-ApplicationAccessPolicy -Identity sascha.wolf@perinco.de -AppId <APP-ID> # Denied
```
`Add-MailboxPermission` ist hier **wirkungslos** - das ist der Weg fuer
delegierten Zugriff bzw. EWS-Impersonation. Bei App-only zaehlt allein die
Zugriffsrichtlinie.
Der Server muss ausgehend `login.microsoftonline.com` und `graph.microsoft.com`
auf 443 erreichen.
## Umgebungsvariablen
Zugangsdaten stehen ausschliesslich in `server/.env` - nie in der Datenbank und
nie in einer API-Antwort. Ein Datenbank-Dump enthaelt damit keine Geheimnisse.
| Variable | Pflicht | Bedeutung |
| --- | --- | --- |
| `MS_TENANT_ID` | ja | Verzeichnis-ID des Tenants |
| `MS_CLIENT_ID` | ja | Anwendungs-ID der App-Registrierung |
| `MS_CLIENT_SECRET` | ja | Client-Geheimnis |
| `MS_MAILBOX` | ja | abzugleichendes Postfach, Vorgabe `crm@perinco.de` |
| `MS_MAIL_FOLDER` | nein | Ordner, Vorgabe `inbox` |
| `MAIL_SYNC` | nein | `false` schaltet den Hintergrundlauf hart ab |
| `MAIL_MAX_PAGES` | nein | Delta-Seiten je Lauf, Vorgabe 20 |
| `MAIL_MAX_BODY_CHARS` | nein | Obergrenze je Mailtext, Vorgabe 400 000 |
Fehlt einer der Pflichtwerte, **startet das CRM normal**. Der Hintergrundlauf
bleibt aus, und die Einstellungen nennen die fehlende Variable beim Namen. Genau
wie beim Scanner soll eine unfertige Teilfunktion den Betrieb nicht verhindern.
Verstellbares liegt dagegen in `crm_settings` und ist unter *Einstellungen &rarr;
Postfach* erreichbar: `mail_sync_active`, `mail_sync_interval`,
`mail_auto_contacts`, `mail_since_days`, `mail_internal_domains`.
## Abgleich per Delta Query
```mermaid
sequenceDiagram
participant S as scheduler.js
participant Y as sync.js
participant G as Graph
participant D as MariaDB
S->>D: last_run_at, has_more
Note over S: faellig? sonst nichts tun
S->>Y: runMailSync()
Y->>D: delta_link lesen
alt erster Lauf
Y->>G: /mailFolders/inbox/messages/delta?$filter=receivedDateTime ge ...
else spaeter
Y->>G: gespeicherter delta_link
end
G-->>Y: Seite mit Metadaten + nextLink/deltaLink
loop je Nachricht
Y->>D: bekannt? Text vorhanden?
opt Text fehlt
Y->>G: /messages/{id}?$select=body
end
Y->>D: upsert crm_mail_messages
opt neue Mail
Y->>D: Kontakt-Vorschlaege anlegen
opt gesichert und Automatik an
Y->>D: Kontakt anlegen bzw. ergaenzen
end
end
end
Y->>D: delta_link + has_more sichern
```
Wesentliche Eigenschaften:
* **Kein Schreibzugriff.** Der Fortschritt steht im `delta_link` in der
Datenbank, nicht im `isRead`-Kennzeichen des Postfachs. Deshalb genuegt
`Mail.Read`, und deshalb geht keine Mail verloren, wenn ein Mensch sie vorher
in Outlook oeffnet.
* **Fortsetzbar.** Nach jeder Seite wird der Verweis gesichert. Ein Absturz oder
ein Deployment mitten im Erstabgleich kostet hoechstens eine Seite.
* **Gedeckelt.** `MAIL_MAX_PAGES` begrenzt einen Lauf. Ist danach noch etwas
offen, setzt der naechste Lauf nach 15 Sekunden fort (`has_more`), statt das
normale Intervall abzuwarten.
* **Selbstheilend.** Antwortet Graph mit `410 Gone` bzw. `resyncRequired`, wird
der Verweis verworfen und beim naechsten Lauf neu aufgebaut. Vorhandene Mails
werden am SHA-256 der Graph-ID wiedererkannt, es entstehen keine Doppelten.
* **Nur Metadaten in der Delta-Abfrage.** Der Mailtext wird einzeln nachgeholt
und nur, wenn er fehlt. Ein reines Metadaten-Update (etwa ein geaendertes
Gelesen-Kennzeichen) loescht den gespeicherten Text nicht.
* **Zeitfilter mit Ersatzweg.** Der Erstabgleich versucht
`$filter=receivedDateTime ge ...`. Die Filterunterstuetzung in Delta-Abfragen
ist eng begrenzt; lehnt Graph ab (400), laeuft der Abgleich ohne Filter und die
Zeitgrenze greift beim Speichern.
Im Postfach geloeschte Mails werden mit `is_deleted = 1` gekennzeichnet, nicht
entfernt: eine an eine Opportunity gehaengte Mail soll nicht aus dem CRM
verschwinden, weil jemand sein Postfach aufraeumt.
## Kontakte aus Mails
Die Extraktion arbeitet **ohne Sprachmodell**. Mailinhalte verlassen den Server
nicht.
```mermaid
flowchart TD
M["Mail"] --> A["Absender<br/>from (Graph)"]
M --> E["Empfaenger<br/>to, cc, bcc (Graph)"]
M --> S["Signaturblock<br/>Text nach der Grussformel"]
M --> T["Fliesstext<br/>Anrede + Name + Rufnummer"]
A --> F{"Filter"}
E --> F
S --> F
T --> F
F -->|"eigene Domain"| X1["verworfen"]
F -->|"info@, no-reply@, ..."| X2["verworfen"]
F -->|"Person"| C["Vorschlag mit Zuversicht"]
C --> Z{"E-Mail vorhanden<br/>und Zuversicht &ge; 70?"}
Z -->|ja, Automatik an| N["Kontakt anlegen<br/>source = 'Mail'"]
Z -->|Adresse existiert| G["vorhandenen Kontakt ergaenzen"]
Z -->|nein| R["bleibt zur Durchsicht"]
```
Zuversicht je Herkunft: Absender 90 (95, wenn die Signatur die Adresse
bestaetigt), Empfaenger 78, weitere Adresse aus der Signatur 55, im Text genannte
Person 35.
Aus dem Signaturblock kommen Telefon und Mobil (nach Beschriftung
unterschieden), Firma (an der Rechtsform erkannt), Position, Strasse, PLZ, Ort,
Website und LinkedIn-Profil.
Zwei Entscheidungen, die die Trefferqualitaet tragen:
* **Im Fliesstext ist eine Anrede Pflicht** („Herrn Max Mustermann Tel. …").
Ohne diese Bedingung liest der Musterabgleich jede Folge grossgeschriebener
Woerter als Namen; „Zweiter Ansprechpartner" landete so als Kontakt. Ein
verpasster Treffer ist harmloser als ein falscher - eine Durchsichtsliste voll
Unsinn schaut niemand an.
* **Der Anzeigename schlaegt die Adresse.** Bei „Mueller" plus
`k.mueller@…` wird der Vorname aus der Adresse genommen, der Nachname aber aus
dem Anzeigenamen - dort stehen die Umlaute richtig.
Uebernommene Kontakte tragen `crm_leads.source = 'Mail'`. Das ist die
„Unterkategorie": die Kontaktliste hat dafuer einen Herkunftsfilter, ein eigenes
Feld war nicht noetig.
## Anzeige des Mailtexts
Fremdes HTML laeuft in einem `<iframe sandbox="">` **ohne jedes allow-Flag**. Das
Dokument bekommt eine eigene, undurchsichtige Origin und kann weder Skripte
ausfuehren noch auf die Anwendung zugreifen.
`allow-same-origin` waere hier ein Fehler: ein `srcDoc`-Dokument erbt damit die
Origin der Anwendung und koennte `localStorage` lesen - also das Anmeldetoken.
Weil die Hoehe des Inhalts ohne dieses Flag nicht auslesbar ist, hat der Rahmen
eine feste Hoehe mit eigenem Rollbalken und einen Schalter „Groesser".
Zusaetzlich entfernt das Backend beim Speichern Skripte, eingebettete Rahmen,
Formulare, Ereignis-Attribute und gefaehrliche URL-Schemata
(`mail/html.js`, `sanitizeHtml`).
Externe Bilder sind per Content-Security-Policy im eingebetteten Dokument
gesperrt und lassen sich pro Mail freigeben - andernfalls meldet jedes Oeffnen
dem Absender, dass die Mail gelesen wurde.
## Endpunkte
Alle unter `/api/mail`, alle hinter `requireAuth`.
| Methode | Pfad | Rolle | Zweck |
| --- | --- | --- | --- |
| GET | `/status` | alle | Betriebszustand, Zaehler, letzter Fehler |
| GET | `/messages` | alle | Liste; `search`, `filter`, `limit`, `offset` |
| GET | `/messages/:id` | alle | Mail mit Text, Zuordnungen, Vorschlaegen |
| POST | `/messages/:id/links` | alle | an Kontakt und/oder Opportunity haengen |
| DELETE | `/links/:id` | alle | Zuordnung loesen |
| GET | `/candidates` | alle | Kontakt-Vorschlaege, `status` waehlbar |
| POST | `/candidates/:id/adopt` | alle | Kontakt anlegen bzw. ergaenzen |
| POST | `/candidates/:id/reject` | alle | Vorschlag verwerfen |
| GET | `/settings` | admin | verstellbare Einstellungen |
| POST | `/settings` | admin | Einstellungen speichern |
| POST | `/test` | admin | Anmeldung **und** Postfachzugriff pruefen |
| POST | `/sync` | admin | Abgleich anstossen, `{"reset":true}` von vorn |
Lesen ist fuer jeden Angemeldeten frei - `crm@perinco.de` ist ein gemeinsames
Vertriebspostfach. Konfigurieren, Testen und Anstossen sind Administratoren
vorbehalten.
`filter` in der Liste: `unlinked` (noch nirgends zugeordnet - die Arbeitsliste),
`candidates` (offene Kontakt-Vorschlaege), `linked` (bereits zugeordnet).
## Was anders ist als im Altsystem
| Thema | KeepTrack | perinco CRM |
| --- | --- | --- |
| Fortschritt | `$filter=isRead eq false`, danach `PATCH isRead` | Delta Query mit gespeichertem `delta_link` |
| Berechtigung | Schreibzugriff noetig | `Mail.Read` genuegt |
| Der Haken daran | `PATCH` ging auf `/me/…` - unter App-only gibt es kein `/me`. Der Aufruf lief in einen 400, die Antwort wurde nicht geprueft. Mails blieben ungelesen und wurden bei **jedem** Lauf erneut durch das Sprachmodell geschickt. | entfaellt |
| Tenant-/Client-ID | kamen vom Browser mit; jeder Angemeldete konnte das Server-Secret gegen einen fremden Tenant verwenden | serverseitig aus der `.env`, nie im Browser |
| Kontakte ohne Adresse | Platzhalter `name@kontakt-aus-email.local` erfunden, um eine Datenbankbedingung zu erfuellen | Kontakt ohne Adresse; Vorschlag bleibt zur Durchsicht liegen |
| Vorhandener Kontakt | wurde uebersprungen | leere Felder werden ergaenzt, gepflegte bleiben |
| Kontaktdaten lesen | kompletter Mailtext an OpenAI, auch fuer Daten, die Graph strukturiert liefert | Graph-Felder plus Signaturauswertung, kein externer Dienst |
| Mail am Deal | Text in das Freitextfeld `notizen` vorangestellt | eigene Tabelle `crm_mail_links`, loesbar, mehrfach moeglich |
| Verbindungstest | prueft nur, ob ein Token kommt | prueft Token **und** Postfachzugriff, mit Hinweis je Fehlercode |
| Paging | `@odata.nextLink` ignoriert, `$top=50` fest | Seiten werden gefolgt, je Lauf gedeckelt, fortsetzbar |
| HTML-Anzeige | ungefiltert | serverseitig entschaerft, `sandbox=""`, externe Bilder gesperrt |
| Zeitangaben | gemischt | durchgehend UTC in der Datenbank, Umrechnung im Frontend |
## Was noch nicht drin ist
* **Senden, Antworten, Weiterleiten.** Braucht `Mail.ReadWrite` und `Mail.Send`
und damit eine erneute Administratorzustimmung.
* **Anhaenge.** `has_attachments` wird gefuehrt und angezeigt, die Dateien
selbst werden nicht geholt.
* **Automatische Zuordnung zu Opportunities.** Die Zuordnung erfolgt per Hand;
der Dialog schlaegt anhand der Mailadressen Kontakt und Opportunity vor. Eine
Bewertung durch ein Sprachmodell waere der naechste Schritt - dann bitte mit
dem Schutz gegen Prompt-Injection, der im Altsystem in `mail-sync` fehlte.
* **Echtzeit statt Intervall.** Graph Change Notifications brauchen einen
oeffentlich erreichbaren Endpunkt (haben wir) und eine Erneuerung der
Subscription etwa alle drei Tage.
## Stoerungssuche
| Beobachtung | Ursache und Abhilfe |
| --- | --- |
| Einstellungen melden fehlende Variablen | `server/.env` ergaenzen, Dienst neu starten - die Werte werden beim Start gelesen |
| Test scheitert „bei der Anmeldung" | Tenant-ID, Client-ID oder Geheimnis falsch, oder das Geheimnis ist abgelaufen |
| Test scheitert „beim Postfachzugriff", `ErrorAccessDenied` | Administratorzustimmung fehlt, oder die ApplicationAccessPolicy erlaubt dieses Postfach nicht |
| Test scheitert mit `ResourceNotFound` | `MS_MAILBOX` zeigt auf ein Postfach, das es nicht gibt |
| „Erstabgleich laeuft noch" bleibt stehen | Log auf `[Mail]` pruefen; `MAIL_MAX_PAGES` erhoehen, wenn es zu langsam vorangeht |
| Keine Kontakt-Vorschlaege | stehen alle Absender auf einer eigenen Domain? `mail_internal_domains` pruefen |
| Doppelte Kontakte | tritt nicht auf: Abgleich erfolgt ueber die Adresse in Kleinschreibung; bei Treffer wird ergaenzt statt angelegt |
| Zaehler und Liste weichen ab | die Liste blendet im Postfach geloeschte Mails aus, der Zaehler in den Einstellungen ebenfalls - Zuordnungen bleiben trotzdem erhalten |
+6
View File
@@ -20,6 +20,7 @@ keine Vorkenntnisse voraus.
| [08 Anleitung fuer Entwickler](08-anleitung-entwickler.md) | Einrichten, erweitern, bekannte Fallen |
| [09 Quellcode-Referenz](09-quellcode.md) | jede Datei, ihre Exporte und Zusagen |
| [10 Glossar](10-glossar.md) | Agent, Match, Lead, Opportunity, Status- und Portalwerte |
| [11 Postfachanbindung](11-postfach.md) | crm@perinco.de ueber Microsoft Graph, Kontakte aus Mails |
Die Designentscheidungen und die Messwerte zur Spracherkennung stehen im
[README des Projekts](../README.md).
@@ -37,6 +38,7 @@ flowchart TB
AP["Apache<br/>:443"]
API["Express-Backend<br/>127.0.0.1:5006"]
SCH["Scanner-Scheduler<br/>node-cron"]
MSCH["Mail-Scheduler<br/>node-cron"]
PW["Playwright<br/>Chromium"]
DB1[("MariaDB<br/>perinco_CRM")]
DB2[("MariaDB<br/>perinco_scanner")]
@@ -46,6 +48,7 @@ flowchart TB
OAI["OpenAI<br/>gpt-4o-mini"]
BOERSE["Projektboersen<br/>freelancermap, hays, ..."]
GRAPH["Microsoft Graph<br/>crm@perinco.de"]
SPA -->|"HTTPS /api"| AP
AP --> API
@@ -56,6 +59,9 @@ flowchart TB
SCH --> OAI
SCH --> DB2
API -.-> SCH
API -.-> MSCH
MSCH -->|"Delta Query, nur lesend"| GRAPH
MSCH --> DB1
ALT --> DB3
ALT --> BOERSE
SPA -.->|"Audio bleibt im Browser"| WSP
+36
View File
@@ -25,6 +25,42 @@ ADMIN_PASSWORD=changeme-starkes-passwort
# Das Diktat braucht hier keine Konfiguration: Whisper laeuft im Browser des
# Benutzers, eingestellt wird es im Reiter "Whisper" der Anwendung.
# ---------------------------------------------------------------------------
# Postfachanbindung Microsoft 365 (crm@perinco.de)
# ---------------------------------------------------------------------------
# Lesender Zugriff ueber Microsoft Graph mit App-only-Berechtigung
# (Client Credentials). Benoetigt wird in Entra genau eine
# Anwendungsberechtigung: Mail.Read, mit Administratorzustimmung. Kein
# Schreibzugriff, kein Redirect-URI, kein interaktiver Login.
#
# Fehlen diese Werte, startet das CRM normal - die Postfachanbindung meldet in
# den Einstellungen, welche Angabe fehlt, und der Abgleich bleibt aus.
#
# In Exchange Online zusaetzlich den Zugriff auf dieses eine Postfach begrenzen,
# sonst darf die App jedes Postfach im Tenant lesen:
# New-ApplicationAccessPolicy -AppId <MS_CLIENT_ID> `
# -PolicyScopeGroupId crm@perinco.de -AccessRight RestrictAccess `
# -Description "perinco CRM: nur crm@perinco.de"
MS_TENANT_ID=
MS_CLIENT_ID=
MS_CLIENT_SECRET=
MS_MAILBOX=crm@perinco.de
# Ordner im Postfach. "inbox" ist ein von Graph vorgegebener Name.
# MS_MAIL_FOLDER=inbox
# Hintergrundabgleich. Standard ist AN; nur "false" schaltet ihn hart ab.
# Intervall, Zeitraum und automatische Kontaktuebernahme stehen dagegen in den
# Einstellungen der Anwendung (Tabelle crm_settings).
MAIL_SYNC=true
# Bremse fuer den Erstabgleich: so viele Delta-Seiten holt ein Lauf hoechstens.
# Der naechste Lauf setzt an derselben Stelle fort.
# MAIL_MAX_PAGES=20
# Obergrenze fuer den gespeicherten Mailtext je Nachricht (Zeichen).
# MAIL_MAX_BODY_CHARS=400000
# ---------------------------------------------------------------------------
# Projekt Scanner (portiert, laeuft parallel zur alten App)
# ---------------------------------------------------------------------------
+2 -1
View File
@@ -12,7 +12,8 @@
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"migrate": "node src/migrate.js",
"scanner:import": "node src/scanner/importLegacy.js"
"scanner:import": "node src/scanner/importLegacy.js",
"test": "node test/mail.test.mjs"
},
"dependencies": {
"bcryptjs": "^2.4.3",
+90
View File
@@ -0,0 +1,90 @@
import { getSettings, asBool, asInt } from '../settings.js';
/*
* Konfiguration der M365-Anbindung.
*
* Zugangsdaten kommen ausschliesslich aus der .env und werden nie an das
* Frontend gegeben - im Altsystem schickte der Browser Tenant- und Client-ID
* mit, wodurch jeder Angemeldete das Server-Secret gegen einen fremden Tenant
* verwenden konnte. Hier ist das serverseitig festgenagelt.
*
* Alles, was im Betrieb verstellbar sein soll (Intervall, Schalter, Zeitraum),
* liegt dagegen in crm_settings und ist ueber die Einstellungen erreichbar.
*/
export const TENANT_ID = (process.env.MS_TENANT_ID || '').trim();
export const CLIENT_ID = (process.env.MS_CLIENT_ID || '').trim();
const CLIENT_SECRET = (process.env.MS_CLIENT_SECRET || '').trim();
export const MAILBOX = (process.env.MS_MAILBOX || 'crm@perinco.de').trim().toLowerCase();
/** Ordner im Postfach, der abgeglichen wird. `inbox` ist ein Wellknown-Name. */
export const MAIL_FOLDER = (process.env.MS_MAIL_FOLDER || 'inbox').trim();
/** Harter Aus-Schalter fuer den Hintergrundabgleich, unabhaengig von der DB. */
export const SCHEDULER_ENABLED = process.env.MAIL_SYNC !== 'false';
/** Ohne diese vier Werte ist die Anbindung nicht betriebsbereit. */
export const MAIL_CONFIGURED = Boolean(TENANT_ID && CLIENT_ID && CLIENT_SECRET && MAILBOX);
export function clientSecret() {
if (!CLIENT_SECRET) throw new Error('MS_CLIENT_SECRET ist nicht gesetzt.');
return CLIENT_SECRET;
}
/**
* Welche Zugangsdaten fehlen - fuer eine brauchbare Meldung in der Oberflaeche,
* ohne Werte zu verraten.
*/
export function missingCredentials() {
const missing = [];
if (!TENANT_ID) missing.push('MS_TENANT_ID');
if (!CLIENT_ID) missing.push('MS_CLIENT_ID');
if (!CLIENT_SECRET) missing.push('MS_CLIENT_SECRET');
if (!MAILBOX) missing.push('MS_MAILBOX');
return missing;
}
/**
* Verstellbare Einstellungen mit ihren Vorgaben. Die Schluessel sind gleichzeitig
* die Positivliste des Einstellungs-Endpunkts - andere Namen werden verworfen.
*/
export const MAIL_SETTING_DEFAULTS = {
/** Hauptschalter des Abgleichs. */
mail_sync_active: 'true',
/** Minuten zwischen zwei Laeufen. */
mail_sync_interval: '10',
/** Kontakt-Vorschlaege mit echter E-Mail-Adresse automatisch uebernehmen. */
mail_auto_contacts: 'true',
/** Beim ersten Lauf nur Mails ab so vielen Tagen zuruecklesen. */
mail_since_days: '30',
/**
* Absender/Empfaenger dieser Domains werden nicht als Kontakt vorgeschlagen -
* die eigenen Leute stehen schon in der Benutzerverwaltung.
*/
mail_internal_domains: 'perinco.de,perinco.onmicrosoft.com',
};
/** Eingelesene und getypte Einstellungen. */
export async function mailSettings() {
const raw = await getSettings(MAIL_SETTING_DEFAULTS);
return {
raw,
active: asBool(raw.mail_sync_active, true),
intervalMinutes: asInt(raw.mail_sync_interval, 10, { min: 2, max: 1440 }),
autoContacts: asBool(raw.mail_auto_contacts, true),
sinceDays: asInt(raw.mail_since_days, 30, { min: 1, max: 3650 }),
internalDomains: String(raw.mail_internal_domains || '')
.split(',')
.map((entry) => entry.trim().toLowerCase().replace(/^@/, ''))
.filter(Boolean),
};
}
/** Wie viele Delta-Seiten ein Lauf maximal holt - Bremse fuer den Erstabgleich. */
export const MAX_PAGES_PER_RUN = asInt(process.env.MAIL_MAX_PAGES, 20, { min: 1, max: 500 });
/** Obergrenze fuer gespeicherte Mailtexte, damit einzelne Mails die DB nicht sprengen. */
export const MAX_BODY_CHARS = asInt(process.env.MAIL_MAX_BODY_CHARS, 400_000, {
min: 10_000,
max: 4_000_000,
});
+126
View File
@@ -0,0 +1,126 @@
import { pool } from '../db.js';
import { findLeadByEmail, linkMessage } from './store.js';
/*
* Aus einem Kontakt-Vorschlag einen Kontakt machen.
*
* Zwei Dinge macht das bewusst anders als das Altsystem:
*
* 1. Es werden keine Platzhalter-Adressen erfunden. Wer keine E-Mail-Adresse hat,
* wird angelegt ohne - das Feld ist in crm_leads ohnehin optional. Das
* Altsystem hat "name@kontakt-aus-email.local" eingetragen, um eine
* Datenbankbedingung zu befriedigen, und damit unbrauchbare Adressen erzeugt.
*
* 2. Ein bereits vorhandener Kontakt wird nicht ein zweites Mal angelegt, sondern
* ergaenzt: leere Felder werden gefuellt, gepflegte Werte bleiben unangetastet.
*/
/** Herkunftskennzeichen in crm_leads.source - traegt die Unterkategorie. */
export const MAIL_LEAD_SOURCE = 'Mail';
/** Felder, die aus einem Vorschlag in einen leeren Kontakt nachgetragen werden. */
const ENRICHABLE = ['company', 'position', 'phone', 'mobile', 'street', 'zip', 'city', 'website', 'linkedin', 'salutation'];
function candidateToLeadFields(candidate) {
// Mindestens ein Namensteil ist garantiert (siehe extract.js). Fehlt der
// Nachname, wandert der Vorname dorthin - crm_leads verlangt beide Spalten,
// und ein Kontakt ohne Nachnamen sieht in jeder Liste falsch aus.
const lastName = candidate.last_name || candidate.first_name || 'Unbekannt';
const firstName = candidate.last_name ? candidate.first_name || '' : '';
return {
salutation: candidate.salutation ?? null,
first_name: firstName,
last_name: lastName,
company: candidate.company ?? null,
position: candidate.position ?? null,
email: candidate.email ?? null,
phone: candidate.phone ?? null,
mobile: candidate.mobile ?? null,
linkedin: candidate.linkedin ?? null,
website: candidate.website ?? null,
street: candidate.street ?? null,
zip: candidate.zip ?? null,
city: candidate.city ?? null,
};
}
/** Leere Felder eines bestehenden Kontakts aus dem Vorschlag auffuellen. */
async function enrichLead(leadId, candidate) {
const [rows] = await pool.query('SELECT * FROM crm_leads WHERE id = ? LIMIT 1', [leadId]);
const lead = rows[0];
if (!lead) return 0;
const updates = {};
for (const field of ENRICHABLE) {
const incoming = candidate[field];
const current = lead[field];
if (incoming && (current === null || current === '')) updates[field] = incoming;
}
const keys = Object.keys(updates);
if (keys.length === 0) return 0;
await pool.query(
`UPDATE crm_leads SET ${keys.map((key) => `${key} = ?`).join(', ')} WHERE id = ?`,
[...keys.map((key) => updates[key]), leadId]
);
return keys.length;
}
/**
* Vorschlag uebernehmen.
*
* `mode` steuert nur die Protokollierung: 'automatisch' kommt aus dem
* Hintergrundlauf, 'manuell' aus der Oberflaeche. `userId` ist bei automatischer
* Uebernahme null.
*
* Rueckgabe: { status, leadId, enrichedFields } - status ist 'uebernommen' bei
* einem neuen Kontakt und 'dublette', wenn die Adresse schon vorhanden war.
*/
export async function adoptCandidate(candidate, { userId = null, ownerId = null, mode = 'manuell' } = {}) {
const existing = candidate.email ? await findLeadByEmail(candidate.email) : null;
if (existing) {
const enrichedFields = await enrichLead(existing.id, candidate);
await markCandidate(candidate.id, 'dublette', { leadId: existing.id, userId });
await linkMessage({
messageId: candidate.message_id,
leadId: existing.id,
origin: mode === 'automatisch' ? 'automatisch' : 'manuell',
note: 'Aus Kontakt-Vorschlag',
userId,
});
return { status: 'dublette', leadId: existing.id, enrichedFields };
}
const fields = candidateToLeadFields(candidate);
const columns = { ...fields, status: 'Neu', source: MAIL_LEAD_SOURCE, tags: '[]', owner_id: ownerId, created_by: userId };
const keys = Object.keys(columns);
const [result] = await pool.query(
`INSERT INTO crm_leads (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
keys.map((key) => columns[key])
);
await markCandidate(candidate.id, 'uebernommen', { leadId: result.insertId, userId });
await linkMessage({
messageId: candidate.message_id,
leadId: result.insertId,
origin: mode === 'automatisch' ? 'automatisch' : 'manuell',
note: 'Aus Kontakt-Vorschlag',
userId,
});
return { status: 'uebernommen', leadId: result.insertId, enrichedFields: 0 };
}
/** Status eines Vorschlags setzen. */
export async function markCandidate(candidateId, status, { leadId = null, userId = null } = {}) {
await pool.query(
`UPDATE crm_mail_contact_candidates
SET status = ?, lead_id = ?, decided_by = ?, decided_at = UTC_TIMESTAMP()
WHERE id = ?`,
[status, leadId, userId, candidateId]
);
}
+781
View File
@@ -0,0 +1,781 @@
import { createHash } from 'node:crypto';
/*
* Kontakt-Vorschlaege aus einer Mail gewinnen - ohne KI.
*
* Der weitaus groesste Teil der Ausbeute steckt in den strukturierten
* Graph-Feldern (Absender, To, Cc, Bcc): Name und Adresse sind dort schon
* getrennt und korrekt. Das Altsystem hat stattdessen den kompletten Mailtext an
* OpenAI geschickt, um Daten zu rekonstruieren, die es bereits hatte - teuer,
* langsam und eine Datenweitergabe, die niemand gebraucht hat.
*
* Der Fliesstext wird nur noch fuer die Anreicherung genutzt: Signaturblock
* auswerten (Telefon, Firma, Position, Adresse, Web) und Personen finden, die im
* Text mit Telefonnummer genannt werden. Letztere haben absichtlich niedrige
* Zuversicht und werden nie automatisch uebernommen.
*
* Erweiterung um ein Sprachmodell ist vorgesehen: extractCandidates() liefert
* eine flache Liste, ein spaeterer KI-Schritt kann dieselbe Struktur ergaenzen.
*/
/** Funktionspostfaecher - keine Personen, gehoeren nicht in die Kontaktliste. */
const FUNCTIONAL_LOCALPARTS = new Set([
'info',
'kontakt',
'contact',
'office',
'mail',
'email',
'service',
'support',
'help',
'helpdesk',
'sales',
'vertrieb',
'einkauf',
'buchhaltung',
'rechnung',
'rechnungen',
'invoice',
'accounting',
'hr',
'personal',
'jobs',
'job',
'bewerbung',
'bewerbungen',
'karriere',
'career',
'recruiting',
'noreply',
'no-reply',
'donotreply',
'do-not-reply',
'postmaster',
'mailer-daemon',
'bounce',
'bounces',
'newsletter',
'news',
'marketing',
'presse',
'press',
'datenschutz',
'privacy',
'admin',
'administrator',
'webmaster',
'notifications',
'notification',
'automail',
'system',
'crm',
]);
/** Titel und Grade, die nicht Teil des Namens sind. */
const NAME_TITLES = [
'herr',
'herrn',
'frau',
'hr',
'hrn',
'fr',
'frl',
'mr',
'mrs',
'ms',
'dr',
'prof',
'dipl',
'dipl.-ing',
'dipl.-kfm',
'ing',
'mag',
'msc',
'm.sc',
'bsc',
'b.sc',
'ma',
'm.a',
'ba',
'b.a',
'mba',
'llm',
'phd',
];
/** Namensbestandteile, die zum Nachnamen gehoeren. */
const NAME_PARTICLES = new Set([
'von',
'van',
'de',
'del',
'della',
'der',
'den',
'di',
'da',
'du',
'le',
'la',
'zu',
'zur',
'ter',
'ten',
'af',
'av',
]);
const LEGAL_FORMS =
'(?:GmbH\\s*&\\s*Co\\.?\\s*KGaA|GmbH\\s*&\\s*Co\\.?\\s*KG|AG\\s*&\\s*Co\\.?\\s*KG|gGmbH|gemeinnuetzige\\s+GmbH|GmbH|mbH|UG\\s*\\(haftungsbeschraenkt\\)|UG|AG|SE|KGaA|KG|OHG|GbR|eG|e\\.?\\s?V\\.?|e\\.?\\s?K\\.?|Ltd\\.?|LLC|LLP|Inc\\.?|Corp\\.?|PLC|B\\.?V\\.?|N\\.?V\\.?|S\\.?A\\.?|S\\.?A\\.?S\\.?|S\\.?r\\.?l\\.?|AB|AS|Oy|A/S|ApS|Sp\\.?\\s?z\\s?o\\.?o\\.?|s\\.?r\\.?o\\.?|Zrt\\.?|Kft\\.?)';
/** Berufsbezeichnungen, die in Signaturen vorkommen. */
const POSITION_HINTS = [
'CEO',
'CTO',
'CFO',
'COO',
'CIO',
'CDO',
'CHRO',
'Geschäftsführer',
'Geschäftsführerin',
'Geschaeftsfuehrer',
'Inhaber',
'Inhaberin',
'Vorstand',
'Prokurist',
'Prokuristin',
'Gesellschafter',
'Managing Director',
'Director',
'Direktor',
'Head of',
'Leiter',
'Leiterin',
'Teamleiter',
'Abteilungsleiter',
'Bereichsleiter',
'Projektleiter',
'Projektleiterin',
'Manager',
'Managerin',
'Consultant',
'Berater',
'Beraterin',
'Senior',
'Principal',
'Partner',
'Recruiter',
'Recruiting',
'Talent Acquisition',
'Account Executive',
'Account Manager',
'Key Account',
'Sales',
'Vertrieb',
'Einkauf',
'Entwickler',
'Developer',
'Engineer',
'Architekt',
'Architect',
'Analyst',
'Scientist',
'Assistenz',
'Sachbearbeiter',
'Sachbearbeiterin',
'Referent',
'Referentin',
'Werkstudent',
'Praktikant',
'Trainee',
'Scrum Master',
'Product Owner',
];
/** Grussformeln - alles danach ist Signatur. */
const CLOSING_PATTERNS = [
/^--+\s*$/,
/^__+\s*$/,
/mit\s+freundlichen\s+gr(ü|ue)(ß|ss)en/i,
/mit\s+besten\s+gr(ü|ue)(ß|ss)en/i,
/freundliche\s+gr(ü|ue)(ß|ss)e/i,
/viele\s+gr(ü|ue)(ß|ss)e/i,
/beste\s+gr(ü|ue)(ß|ss)e/i,
/liebe\s+gr(ü|ue)(ß|ss)e/i,
/herzliche\s+gr(ü|ue)(ß|ss)e/i,
/sonnige\s+gr(ü|ue)(ß|ss)e/i,
/^gr(ü|ue)(ß|ss)e\b/i,
/^vg\b/i,
/^mfg\b/i,
/^lg\b/i,
/^bg\b/i,
/best\s+regards/i,
/kind\s+regards/i,
/^regards\b/i,
/^cheers\b/i,
/^thanks\b/i,
/^sincerely\b/i,
];
/** Beginn eines zitierten Vorgaengers - danach wird nichts mehr ausgewertet. */
const QUOTE_PATTERNS = [
/^>/,
/^von:\s/i,
/^gesendet:\s/i,
/^an:\s/i,
/^betreff:\s/i,
/^from:\s/i,
/^sent:\s/i,
/^subject:\s/i,
/^-{3,}\s*urspr(ü|ue)ngliche\s+nachricht/i,
/^-{3,}\s*original\s+message/i,
/^am\s.+\sschrieb\s/i,
/^on\s.+\swrote:/i,
];
const EMAIL_RE = /[\w.!#$%&'*+/=?^`{|}~-]+@[\w-]+(?:\.[\w-]+)+/g;
const PHONE_RE =
/(?:\+|00)\s?\d{1,3}[\s./-]?(?:\(0\)\s?)?\d{1,5}(?:[\s./-]?\d{1,6}){1,4}|\b0\d{2,5}[\s./-]?\d{3,9}(?:[\s./-]?\d{1,6})?/g;
/*
* PLZ und Ort. Die kleingeschriebenen Verbindungswoerter sind noetig, damit
* "60311 Frankfurt am Main" oder "56242 Selters (Westerwald)" nicht nach dem
* ersten Wort abbricht - sie zaehlen nur, wenn danach wieder ein Ortsteil folgt.
*/
const ZIP_CITY_RE =
/\b(\d{5})\s+([A-ZÄÖÜ][\wäöüß.\-]*(?:[ -](?:[A-ZÄÖÜ][\wäöüß.\-]*|(?:am|an|auf|bei|der|den|im|in|ob|unter|vor|a\.|d\.)(?=[ -][A-ZÄÖÜ])))*)/;
const STREET_RE =
/\b([A-ZÄÖÜ][\wäöüß.\-]*(?:[ -](?:[A-ZÄÖÜ][\wäöüß.\-]*|von|der|am|im))*\s?(?:stra(?:ß|ss)e|str\.?|weg|allee|platz|ring|gasse|damm|ufer|chaussee|park|hof|berg|feld)\s+\d+\s?[a-zA-Z]?(?:\s?[-/]\s?\d+\s?[a-zA-Z]?)?)/i;
const URL_RE = /\b(?:https?:\/\/|www\.)[\w-]+(?:\.[\w-]+)+(?:\/[^\s,;)]*)?/gi;
const hash = (value) => createHash('sha256').update(value).digest('hex');
const clean = (value) => {
if (value === null || value === undefined) return null;
const text = String(value).replace(/\s+/g, ' ').trim();
return text === '' ? null : text;
};
/** Auf Spaltenbreite kuerzen, damit MariaDB nichts abschneiden muss. */
const cut = (value, max) => {
const text = clean(value);
return text === null ? null : text.slice(0, max);
};
const normalizeEmail = (value) => {
const text = clean(value);
if (!text) return null;
const match = text.match(/[\w.!#$%&'*+/=?^`{|}~-]+@[\w-]+(?:\.[\w-]+)+/);
return match ? match[0].toLowerCase() : null;
};
const domainOf = (email) => (email && email.includes('@') ? email.split('@')[1].toLowerCase() : null);
const titleCase = (value) =>
value
.split(/([ -])/)
.map((part) =>
/^[ -]$/.test(part) ? part : part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()
)
.join('');
/** Titel vom Anfang eines Namens abtrennen. */
function stripTitles(name) {
let rest = name;
let salutation = null;
for (;;) {
const match = rest.match(/^\s*([\wäöüÄÖÜß.\-]+)\.?\s+(.*)$/);
if (!match) break;
const token = match[1].replace(/\.$/, '').toLowerCase();
if (!NAME_TITLES.includes(token)) break;
if (['herr', 'herrn', 'hr', 'hrn', 'mr'].includes(token)) salutation = salutation ?? 'Herr';
if (['frau', 'fr', 'frl', 'mrs', 'ms'].includes(token)) salutation = salutation ?? 'Frau';
rest = match[2];
}
// Nachgestellte Grade: "Sascha Wolf, MBA"
rest = rest.replace(/,\s*(?:M\.?B\.?A|M\.?Sc|B\.?Sc|M\.?A|B\.?A|LL\.?M|Ph\.?D)\.?\s*$/i, '');
return { name: rest.trim(), salutation };
}
/**
* Anzeigename in Vor- und Nachnamen zerlegen. Beherrscht "Nachname, Vorname",
* Namenspartikel ("van der Berg") und faellt notfalls auf den lokalen Teil der
* Adresse zurueck.
*/
export function parsePersonName(displayName, email = null) {
const raw = clean(displayName);
let salutation = null;
let source = raw;
// Anzeigenamen wie "Wolf, Sascha (perinco GmbH)" entschlacken.
if (source) {
source = source.replace(/\s*[([][^)\]]*[)\]]\s*$/g, ' ').trim();
source = source.replace(/^["']|["']$/g, '').trim();
// Ist der Anzeigename nur die Adresse, bringt er nichts.
if (/^[^\s@]+@[^\s@]+$/.test(source)) source = null;
}
if (source) {
const stripped = stripTitles(source);
salutation = stripped.salutation;
source = stripped.name;
}
if (source && source.includes(',')) {
const [last, first] = source.split(',', 2).map((part) => part.trim());
if (last && first) return { firstName: cut(first, 120), lastName: cut(last, 120), salutation };
}
if (source) {
const tokens = source.split(/\s+/).filter(Boolean);
if (tokens.length >= 2) {
// Erstes Partikel markiert den Beginn des Nachnamens.
let splitAt = tokens.length - 1;
for (let i = 1; i < tokens.length - 1; i += 1) {
if (NAME_PARTICLES.has(tokens[i].toLowerCase())) {
splitAt = i;
break;
}
}
return {
firstName: cut(tokens.slice(0, splitAt).join(' '), 120),
lastName: cut(tokens.slice(splitAt).join(' '), 120),
salutation,
};
}
}
const derived = nameFromEmail(email);
/*
* Einteiliger Anzeigename ("Mueller") plus Adresse "k.mueller@...": den
* Vornamen aus der Adresse nehmen, aber den Nachnamen aus dem Anzeigenamen
* behalten - dort stehen die Umlaute richtig, in der Adresse nicht.
*/
if (source && derived?.firstName && derived?.lastName) {
if (foldUmlauts(derived.lastName) === foldUmlauts(source)) {
return { firstName: derived.firstName, lastName: cut(source, 120), salutation };
}
}
// Kein brauchbarer Anzeigename: aus dem lokalen Teil ableiten.
if (derived) return { ...derived, salutation };
if (source) return { firstName: null, lastName: cut(source, 120), salutation };
return { firstName: null, lastName: null, salutation };
}
/** Woerter, die neben einem Namen stehen, aber nicht dazugehoeren. */
const NAME_NOISE = new Set([
'tel',
'telefon',
'phone',
'fon',
'mobil',
'mobile',
'handy',
'fax',
'mail',
'email',
'e-mail',
'durchwahl',
'zentrale',
'nummer',
'nr',
'unter',
'erreichbar',
'kontakt',
'ansprechpartner',
'ansprechpartnerin',
]);
/** Beschriftungen am Rand eines gefundenen Namens abschneiden. */
function stripNameNoise(value) {
const tokens = String(value).split(/\s+/).filter(Boolean);
while (tokens.length > 1 && NAME_NOISE.has(tokens[tokens.length - 1].replace(/\.$/, '').toLowerCase())) {
tokens.pop();
}
while (tokens.length > 1 && NAME_NOISE.has(tokens[0].replace(/\.$/, '').toLowerCase())) {
tokens.shift();
}
return tokens.join(' ');
}
/** Umlaute und Schaerfe-S angleichen, damit "Müller" und "Mueller" gleich sind. */
function foldUmlauts(value) {
return String(value)
.toLowerCase()
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^a-z]/g, '');
}
/** "sascha.wolf@..." wird zu Sascha Wolf; ohne Trenner bleibt es einteilig. */
function nameFromEmail(email) {
const address = normalizeEmail(email);
if (!address) return null;
const local = address.split('@')[0].replace(/\+.*$/, '');
if (FUNCTIONAL_LOCALPARTS.has(local)) return null;
const parts = local
.split(/[._\-]+/)
.filter((part) => part.length > 0 && !/^\d+$/.test(part));
if (parts.length >= 2) {
return {
firstName: cut(titleCase(parts[0]), 120),
lastName: cut(titleCase(parts.slice(1).join(' ')), 120),
};
}
if (parts.length === 1 && parts[0].length > 1) {
return { firstName: null, lastName: cut(titleCase(parts[0]), 120) };
}
return null;
}
/** Funktionspostfach oder Automat? Solche Adressen werden nicht vorgeschlagen. */
export function isFunctionalAddress(email) {
const address = normalizeEmail(email);
if (!address) return true;
const local = address.split('@')[0].replace(/\+.*$/, '').toLowerCase();
if (FUNCTIONAL_LOCALPARTS.has(local)) return true;
// Muster wie "noreply-1234", "bounce+abc", "mailer_daemon"
return /^(?:no-?reply|do-?not-?reply|bounce|mailer-?daemon|postmaster|notifications?|automat|robot|bot)\b/.test(
local.replace(/[._]/g, '-')
);
}
/** Telefonnummer lesbar normalisieren, ohne die Zahl zu verfaelschen. */
function normalizePhone(value) {
const text = clean(value);
if (!text) return null;
const compact = text.replace(/[^\d+]/g, '');
// Zu kurz fuer eine echte Rufnummer (Hausnummern, Jahreszahlen).
if (compact.replace(/\D/g, '').length < 7) return null;
return cut(text.replace(/\s{2,}/g, ' '), 64);
}
/**
* Signaturblock bestimmen. Zuerst wird alles ab dem ersten Zitat abgeschnitten,
* dann ab der letzten Grussformel gelesen. Ohne Grussformel gelten die letzten
* Zeilen als Signatur.
*/
export function signatureLines(bodyText, maxLines = 18) {
if (!bodyText) return [];
const all = String(bodyText).split('\n');
let end = all.length;
for (let i = 0; i < all.length; i += 1) {
if (QUOTE_PATTERNS.some((pattern) => pattern.test(all[i].trim()))) {
end = i;
break;
}
}
const lines = all.slice(0, end);
let start = -1;
for (let i = lines.length - 1; i >= 0; i -= 1) {
const line = lines[i].trim();
if (line === '') continue;
if (CLOSING_PATTERNS.some((pattern) => pattern.test(line))) {
start = i + 1;
break;
}
}
const block = start >= 0 ? lines.slice(start) : lines.slice(Math.max(0, lines.length - maxLines));
return block.map((line) => line.trim()).filter(Boolean).slice(0, maxLines * 2);
}
/** Firmenname, Position, Adresse, Web und Rufnummern aus dem Signaturblock. */
export function parseSignature(bodyText) {
const lines = signatureLines(bodyText);
const block = lines.join('\n');
const result = {
phone: null,
mobile: null,
company: null,
position: null,
street: null,
zip: null,
city: null,
website: null,
linkedin: null,
emails: [],
};
if (lines.length === 0) return result;
/*
* Rufnummern nach Beschriftung unterscheiden. Zwischen Bezeichnung und Nummer
* darf mehr als ein Satzzeichen stehen - "Tel.:" ist der Normalfall in
* deutschen Signaturen, ein einzelnes optionales Zeichen reicht dafuer nicht.
*/
for (const line of lines) {
const labelled = line.match(
/\b(mobil|mobile|handy|cell|m)\b[\s.:|·•-]{0,4}((?:\+|00|0)[\d\s()./-]{6,})/i
);
if (labelled && !result.mobile) result.mobile = normalizePhone(labelled[2]);
const office = line.match(
/\b(tel(?:efon)?|phone|fon|festnetz|office|t)\b[\s.:|·•-]{0,4}((?:\+|00|0)[\d\s()./-]{6,})/i
);
if (office && !result.phone) result.phone = normalizePhone(office[2]);
}
// Unbeschriftete Nummer als Festnetz nehmen, wenn noch keine gefunden wurde.
if (!result.phone && !result.mobile) {
const found = block.match(PHONE_RE);
if (found) {
for (const candidate of found) {
const phone = normalizePhone(candidate);
if (phone) {
result.phone = phone;
break;
}
}
}
}
const company = block.match(new RegExp(`([\\wÄÖÜäöüß.&'-]+(?:[ -][\\wÄÖÜäöüß.&'-]+){0,5}\\s+${LEGAL_FORMS})`, 'i'));
if (company) result.company = cut(company[1], 190);
for (const hint of POSITION_HINTS) {
const line = lines.find((entry) => {
if (!entry.toLowerCase().includes(hint.toLowerCase())) return false;
// Zeilen mit Adresse oder Rufnummer sind keine Positionsangaben.
return !/\d{5}\s|\+\d|@/.test(entry) && entry.length <= 80;
});
if (line) {
result.position = cut(line.replace(/^[|•·\-–—\s]+|[|•·\-–—\s]+$/g, ''), 190);
break;
}
}
const street = block.match(STREET_RE);
if (street) result.street = cut(street[1], 190);
const zipCity = block.match(ZIP_CITY_RE);
if (zipCity) {
result.zip = cut(zipCity[1], 24);
result.city = cut(zipCity[2], 120);
}
const urls = block.match(URL_RE) || [];
for (const url of urls) {
if (/linkedin\.com/i.test(url)) {
result.linkedin = result.linkedin ?? cut(url, 255);
} else if (!/(?:xing|facebook|instagram|twitter|x\.com|youtube)\./i.test(url)) {
result.website = result.website ?? cut(url, 255);
}
}
result.emails = [...new Set((block.match(EMAIL_RE) || []).map((entry) => entry.toLowerCase()))];
return result;
}
/**
* Personen, die im Fliesstext mit Rufnummer genannt werden.
*
* Es wird bewusst eine Anrede verlangt ("Herrn Max Mustermann Tel. ..."). Ohne
* diese Bedingung liest der Musterabgleich jede Folge grossgeschriebener Woerter
* als Namen - "Zweiter Ansprechpartner" landete so als Kontakt in der Liste.
* Ein verpasster Treffer ist harmloser als ein falscher: die Durchsicht soll
* kurz bleiben, sonst schaut niemand hin.
*/
function textMentions(bodyText, signatureBlock) {
if (!bodyText) return [];
const lines = String(bodyText)
.split('\n')
.filter((line) => line.trim() && !signatureBlock.includes(line.trim()));
const found = [];
// Anrede ist Pflicht; danach ein bis drei grossgeschriebene Namensteile.
const namePattern =
/\b(Herrn?|Frau|Hrn?\.|Fr\.|Frl\.)\s+((?:(?:Dr|Prof|Dipl[\w.-]*)\.\s+)*[A-ZÄÖÜ][a-zäöüß]+(?:\s+(?:von|van|de|zu|der)\b)?(?:\s+[A-ZÄÖÜ][a-zäöüß]+){0,2})/g;
for (const line of lines) {
const phones = line.match(PHONE_RE);
if (!phones) continue;
const phone = normalizePhone(phones[0]);
if (!phone) continue;
for (const match of line.matchAll(namePattern)) {
// "Herrn Max Mustermann Tel. 0221/..." - die Beschriftung der Rufnummer
// steht gross direkt hinter dem Namen und wuerde sonst mitgelesen.
const parsed = parsePersonName(stripNameNoise(match[2]));
if (!parsed.lastName) continue;
found.push({
...parsed,
salutation: /^(?:Herrn?|Hrn?\.)$/.test(match[1]) ? 'Herr' : 'Frau',
phone,
origin: 'text',
confidence: 35,
});
}
}
return found;
}
/** Zuversicht je Herkunft. Hoehere Werte duerfen automatisch uebernommen werden. */
const CONFIDENCE = {
absender: 90,
empfaenger: 78,
signatur: 55,
text: 35,
};
/** Schluessel fuer die Dublettenpruefung innerhalb einer Mail. */
function dedupeKey(candidate) {
if (candidate.email) return `email:${candidate.email}`;
const name = [candidate.firstName, candidate.lastName].filter(Boolean).join(' ').toLowerCase();
const phone = (candidate.phone || candidate.mobile || '').replace(/\D/g, '');
return `name:${name}|tel:${phone}`;
}
/**
* Alle Vorschlaege einer Mail.
*
* `message` erwartet die normalisierte Form aus sync.js:
* { fromName, fromEmail, to[], cc[], bcc[], bodyText }
* `options.internalDomains` sind die eigenen Domains, `options.mailbox` das
* abgeglichene Postfach - beide werden uebersprungen.
*/
export function extractCandidates(message, options = {}) {
const internalDomains = (options.internalDomains || []).map((entry) => entry.toLowerCase());
const mailbox = normalizeEmail(options.mailbox);
const isInternal = (email) => {
const domain = domainOf(email);
return Boolean(domain && internalDomains.includes(domain));
};
const signature = parseSignature(message.bodyText);
const signatureBlock = signatureLines(message.bodyText).join('\n');
const senderEmail = normalizeEmail(message.fromEmail);
/** Sammler: pro Dublettenschluessel bleibt der Eintrag mit der hoechsten Zuversicht. */
const collected = new Map();
const add = (candidate) => {
if (!candidate) return;
if (!candidate.email && !candidate.phone && !candidate.mobile) return;
if (!candidate.firstName && !candidate.lastName) return;
const key = dedupeKey(candidate);
const existing = collected.get(key);
if (!existing) {
collected.set(key, candidate);
return;
}
// Zusammenfuehren: fehlende Felder auffuellen, hoehere Zuversicht gewinnt.
for (const [field, value] of Object.entries(candidate)) {
if (existing[field] === null || existing[field] === undefined) existing[field] = value;
}
if (candidate.confidence > existing.confidence) {
existing.confidence = candidate.confidence;
existing.origin = candidate.origin;
}
};
const personFrom = (entry, origin) => {
const email = normalizeEmail(entry?.email);
if (!email) return null;
if (email === mailbox) return null;
if (isInternal(email)) return null;
if (isFunctionalAddress(email)) return null;
const parsed = parsePersonName(entry?.name, email);
if (!parsed.firstName && !parsed.lastName) return null;
return {
...parsed,
email: cut(email, 190),
phone: null,
mobile: null,
company: null,
position: null,
street: null,
zip: null,
city: null,
website: null,
linkedin: null,
origin,
confidence: CONFIDENCE[origin],
};
};
// 1. Absender - inklusive Signaturdaten, die eindeutig zu ihm gehoeren.
const sender = personFrom({ name: message.fromName, email: message.fromEmail }, 'absender');
if (sender) {
Object.assign(sender, {
phone: signature.phone,
mobile: signature.mobile,
company: signature.company,
position: signature.position,
street: signature.street,
zip: signature.zip,
city: signature.city,
website: signature.website,
linkedin: signature.linkedin,
});
// Signatur mit passender Adresse bestaetigt den Absender zusaetzlich.
if (senderEmail && signature.emails.includes(senderEmail)) sender.confidence = 95;
add(sender);
}
// 2. Empfaenger. Deckt auch das Muster ab, bei dem eine interne Mail das
// CRM auf Cc setzt: der Absender ist dann intern und faellt weg, die
// externen Empfaenger sind die eigentlichen Kontakte. Dafuer braucht es
// keinen Sonderfall wie im Altsystem.
for (const list of [message.to, message.cc, message.bcc]) {
for (const entry of list || []) add(personFrom(entry, 'empfaenger'));
}
// 3. Weitere Adressen aus der Signatur (Assistenz, zweiter Ansprechpartner).
for (const email of signature.emails) {
if (senderEmail && email === senderEmail) continue;
add(personFrom({ name: null, email }, 'signatur'));
}
// 4. Im Text genannte Personen mit Rufnummer, ohne Adresse.
for (const mention of textMentions(message.bodyText, signatureBlock)) add(mention);
return [...collected.values()].map((candidate) => ({
dedupeHash: hash(dedupeKey(candidate)),
firstName: candidate.firstName,
lastName: candidate.lastName,
salutation: cut(candidate.salutation, 32),
email: candidate.email,
phone: candidate.phone,
mobile: candidate.mobile,
company: candidate.company,
position: candidate.position,
street: candidate.street,
zip: candidate.zip,
city: candidate.city,
website: candidate.website,
linkedin: candidate.linkedin,
origin: candidate.origin,
confidence: Math.max(0, Math.min(100, candidate.confidence)),
}));
}
/**
* Ab dieser Zuversicht darf ein Vorschlag mit E-Mail-Adresse automatisch zum
* Kontakt werden. Absender und Empfaenger liegen darueber, Signatur- und
* Textfunde bleiben zur Durchsicht liegen.
*/
export const AUTO_ADOPT_MIN_CONFIDENCE = 70;
+218
View File
@@ -0,0 +1,218 @@
import { TENANT_ID, CLIENT_ID, MAILBOX, MAIL_FOLDER, clientSecret } from './config.js';
/*
* Schmaler Microsoft-Graph-Client fuer den App-only-Zugriff (Client Credentials).
*
* Bewusst anders als im Altsystem:
* - Das Token wird zwischengespeichert, statt bei jedem Aufruf neu geholt.
* - 429 und 5xx werden mit Beachtung von Retry-After wiederholt.
* - Ein 401 verwirft das Token genau einmal und wiederholt - so ueberlebt der
* Lauf einen serverseitig zurueckgezogenen Token.
* - Fehlerantworten werden gelesen und mit Graph-Fehlercode weitergegeben,
* statt still verschluckt zu werden.
*/
const AUTHORITY = 'https://login.microsoftonline.com';
export const GRAPH_ROOT = 'https://graph.microsoft.com/v1.0';
/** Zeitlimit je Anfrage - haengende Verbindungen sollen den Lauf nicht blockieren. */
const REQUEST_TIMEOUT_MS = 30_000;
const MAX_ATTEMPTS = 4;
export class GraphError extends Error {
constructor(message, { status = 0, code = null, requestUrl = null } = {}) {
super(message);
this.name = 'GraphError';
this.status = status;
this.code = code;
this.requestUrl = requestUrl;
}
}
let tokenCache = null;
/** Verwirft das gespeicherte Token - nach 401 und beim Verbindungstest. */
export function resetTokenCache() {
tokenCache = null;
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function fetchAccessToken() {
const body = new URLSearchParams({
client_id: CLIENT_ID,
client_secret: clientSecret(),
scope: 'https://graph.microsoft.com/.default',
grant_type: 'client_credentials',
});
let response;
try {
response = await fetch(`${AUTHORITY}/${TENANT_ID}/oauth2/v2.0/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (err) {
throw new GraphError(`Anmeldung bei Microsoft nicht erreichbar: ${err.message}`, { status: 0 });
}
const data = await response.json().catch(() => null);
if (!response.ok) {
// error_description von Entra ist die einzige brauchbare Fehlerquelle bei
// falscher Tenant-ID, falschem Secret oder fehlender Zustimmung.
const detail = data?.error_description || data?.error || `HTTP ${response.status}`;
throw new GraphError(`Anmeldung fehlgeschlagen: ${String(detail).split('\n')[0]}`, {
status: response.status,
code: data?.error ?? null,
});
}
if (!data?.access_token) {
throw new GraphError('Anmeldung lieferte kein Token zurueck.', { status: response.status });
}
return data;
}
/** Gueltiges Token, notfalls neu geholt. Puffer von 120 s vor dem Ablauf. */
export async function accessToken() {
if (tokenCache && tokenCache.expiresAt > Date.now()) return tokenCache.value;
const data = await fetchAccessToken();
const lifetime = Number(data.expires_in) || 3600;
tokenCache = {
value: data.access_token,
expiresAt: Date.now() + Math.max(60, lifetime - 120) * 1000,
};
return tokenCache.value;
}
/** Wartezeit aus Retry-After (Sekunden oder HTTP-Datum), sonst exponentiell. */
function retryDelayMs(response, attempt) {
const header = response?.headers?.get('retry-after');
if (header) {
const seconds = Number(header);
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds, 120) * 1000;
const date = Date.parse(header);
if (Number.isFinite(date)) return Math.min(Math.max(0, date - Date.now()), 120_000);
}
return Math.min(1000 * 2 ** (attempt - 1), 16_000);
}
/**
* Graph-Aufruf mit Wiederholung. `target` darf ein Pfad relativ zu /v1.0 oder
* eine vollstaendige URL sein - Delta-Links kommen absolut von Graph zurueck.
*/
export async function graphRequest(target, { method = 'GET', body = null, headers = {} } = {}) {
const url = target.startsWith('http') ? target : `${GRAPH_ROOT}${target}`;
let tokenRetried = false;
for (let attempt = 1; ; attempt += 1) {
const token = await accessToken();
let response;
try {
response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (err) {
// Netzwerkfehler und Zeitlimit: wiederholen, solange Versuche bleiben.
if (attempt < MAX_ATTEMPTS) {
await sleep(retryDelayMs(null, attempt));
continue;
}
throw new GraphError(`Microsoft Graph nicht erreichbar: ${err.message}`, {
status: 0,
requestUrl: url,
});
}
if (response.status === 401 && !tokenRetried) {
tokenRetried = true;
resetTokenCache();
continue;
}
if ((response.status === 429 || response.status >= 500) && attempt < MAX_ATTEMPTS) {
await sleep(retryDelayMs(response, attempt));
continue;
}
if (!response.ok) {
const text = await response.text().catch(() => '');
let code = null;
let message = `HTTP ${response.status}`;
try {
const parsed = JSON.parse(text);
code = parsed?.error?.code ?? null;
message = parsed?.error?.message || message;
} catch {
if (text) message = text.slice(0, 300);
}
throw new GraphError(message, { status: response.status, code, requestUrl: url });
}
if (response.status === 204) return null;
return response.json();
}
}
/** URL-sicherer Pfad auf das konfigurierte Postfach. */
export function mailboxPath(suffix = '') {
return `/users/${encodeURIComponent(MAILBOX)}${suffix}`;
}
/**
* Verbindungstest: Token holen und einen minimalen Zugriff auf das Postfach
* versuchen. Der zweite Schritt ist der eigentlich wichtige - ein Token bekommt
* man auch dann, wenn Berechtigung oder Zugriffsrichtlinie fehlen. Genau daran
* ist der Test des Altsystems vorbeigelaufen: er hat nur das Token geprueft.
*/
export async function testConnection() {
resetTokenCache();
try {
await accessToken();
} catch (err) {
return {
ok: false,
stage: 'token',
error: err.message,
hint: 'Tenant-ID, Client-ID und Client-Secret in der .env pruefen.',
};
}
try {
const data = await graphRequest(
`${mailboxPath(`/mailFolders/${encodeURIComponent(MAIL_FOLDER)}`)}?$select=displayName,totalItemCount,unreadItemCount`
);
return {
ok: true,
stage: 'mailbox',
mailbox: MAILBOX,
folder: data?.displayName ?? null,
totalItems: data?.totalItemCount ?? null,
unreadItems: data?.unreadItemCount ?? null,
};
} catch (err) {
// Die typischen Stolpersteine liefern eindeutige Graph-Codes.
const hint =
err.code === 'ErrorAccessDenied' || err.status === 403
? 'Zugriff verweigert: Anwendungsberechtigung Mail.Read erteilt und Administratorzustimmung gegeben? Erlaubt die ApplicationAccessPolicy dieses Postfach?'
: err.code === 'ResourceNotFound' || err.status === 404
? `Postfach ${MAILBOX} wurde nicht gefunden - Adresse pruefen.`
: 'Berechtigungen und Zugriffsrichtlinie in Exchange Online pruefen.';
return { ok: false, stage: 'mailbox', error: err.message, code: err.code ?? null, hint };
}
}
+141
View File
@@ -0,0 +1,141 @@
/*
* HTML aus Mails aufbereiten.
*
* Zwei Aufgaben, die im Altsystem beide zu kurz kamen:
*
* 1. htmlToText: Der alte Wandler hat jede Folge von Leerraum auf ein Leerzeichen
* gestaucht - damit ist die Zeilenstruktur weg, und genau die braucht man, um
* eine Signatur zu erkennen. Hier bleiben Zeilenumbrueche erhalten.
*
* 2. sanitizeHtml: Fremdes HTML wird im Browser dargestellt. Die Anzeige laeuft
* zwar in einem abgeschotteten iframe ohne Skripte, aber Verteidigung in der
* Tiefe kostet hier fast nichts: Skripte, eingebettete Rahmen, Formulare und
* Ereignis-Attribute fliegen schon serverseitig raus.
*/
const ENTITIES = {
nbsp: ' ',
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
'#39': "'",
euro: '€',
auml: 'ä',
ouml: 'ö',
uuml: 'ü',
Auml: 'Ä',
Ouml: 'Ö',
Uuml: 'Ü',
szlig: 'ß',
ndash: '–',
mdash: '—',
hellip: '…',
laquo: '«',
raquo: '»',
shy: '',
zwnj: '',
};
function decodeEntities(text) {
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+[0-9]*);/g, (match, entity) => {
if (entity.startsWith('#x') || entity.startsWith('#X')) {
const code = Number.parseInt(entity.slice(2), 16);
return Number.isFinite(code) ? safeCodePoint(code) : match;
}
if (entity.startsWith('#')) {
const code = Number.parseInt(entity.slice(1), 10);
return Number.isFinite(code) ? safeCodePoint(code) : match;
}
return entity in ENTITIES ? ENTITIES[entity] : match;
});
}
function safeCodePoint(code) {
try {
return String.fromCodePoint(code);
} catch {
return '';
}
}
/** Blockelemente, nach denen ein Zeilenumbruch stehen muss. */
const BLOCK_TAGS =
'address|article|aside|blockquote|div|dd|dl|dt|fieldset|figure|footer|h[1-6]|header|hr|li|main|nav|ol|p|pre|section|table|tbody|td|tfoot|th|thead|tr|ul';
/**
* HTML in lesbaren Text wandeln - mit erhaltenen Zeilen. Ist der Text schon
* reiner Text (contentType: text), passiert nur die Normalisierung.
*/
export function htmlToText(html) {
if (!html) return '';
let text = String(html)
// Nicht darstellbare Bereiche komplett entfernen.
.replace(/<(script|style|head|noscript|template)\b[\s\S]*?<\/\1>/gi, ' ')
.replace(/<!--[\s\S]*?-->/g, ' ')
/*
* Ziel eines Links hinter den Linktext setzen. Signaturen verstecken Website
* und LinkedIn-Profil fast immer hinter einem Wort ("LinkedIn", "Website") -
* ohne diesen Schritt ist die Adresse im Text verloren und die Extraktion
* findet sie nicht.
*/
.replace(
/<a\b[^>]*\bhref\s*=\s*["']?(https?:\/\/[^"'\s>]+)["']?[^>]*>([\s\S]{0,120}?)<\/a>/gi,
(match, href, label) => {
const text = label.replace(/<[^>]+>/g, '').trim();
if (!text) return ` ${href} `;
// Ist der Linktext selbst die Adresse, waere die Wiederholung nur Ballast.
const normalizedLabel = text.replace(/^https?:\/\//i, '').replace(/\/$/, '');
const normalizedHref = href.replace(/^https?:\/\//i, '').replace(/\/$/, '');
if (normalizedLabel.toLowerCase() === normalizedHref.toLowerCase()) return ` ${text} `;
return ` ${text} (${href}) `;
}
)
// Umbrueche erhalten.
.replace(/<br\s*\/?>/gi, '\n')
.replace(new RegExp(`</(?:${BLOCK_TAGS})\\s*>`, 'gi'), '\n')
.replace(new RegExp(`<(?:${BLOCK_TAGS})\\b[^>]*>`, 'gi'), '\n')
// Zellen innerhalb einer Zeile trennen, damit Signaturtabellen lesbar bleiben.
.replace(/<\/t[dh]\s*>/gi, ' \t')
.replace(/<[^>]+>/g, '');
text = decodeEntities(text);
return text
.replace(/\r\n?/g, '\n')
// Geschuetzte Leerzeichen und Tabs zu normalen Leerzeichen, Zeilen bleiben.
.replace(/[^\S\n]+/g, ' ')
.split('\n')
.map((line) => line.trim())
// Maximal eine Leerzeile in Folge - Outlook-HTML produziert Dutzende.
.filter((line, index, lines) => line !== '' || (index > 0 && lines[index - 1] !== ''))
.join('\n')
.trim();
}
/** Attribute, die in der Anzeige nichts zu suchen haben. */
const EVENT_ATTR = /\s+on[a-z-]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi;
const DANGEROUS_URL = /(?:href|src|action|formaction|xlink:href)\s*=\s*(?:"|')?\s*(?:javascript|vbscript|data:text\/html)/i;
/**
* Entschaerftes HTML fuer die Anzeige. Kein vollstaendiger Sanitizer - die
* eigentliche Absicherung ist das sandbox-iframe im Frontend. Was hier
* verschwindet, kann dort also gar nicht erst ankommen.
*/
export function sanitizeHtml(html) {
if (!html) return '';
let out = String(html)
.replace(/<(script|style|iframe|frame|frameset|object|embed|applet|form|link|meta|base)\b[\s\S]*?<\/\1\s*>/gi, '')
// Selbstschliessende bzw. nie geschlossene Varianten derselben Elemente.
.replace(/<\/?(script|iframe|frame|frameset|object|embed|applet|form|link|meta|base)\b[^>]*>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(EVENT_ATTR, '');
// Verbleibende gefaehrliche URL-Schemata neutralisieren.
out = out.replace(/<[^>]+>/g, (tag) => (DANGEROUS_URL.test(tag) ? tag.replace(/(href|src|action|formaction|xlink:href)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '') : tag));
return out;
}
+72
View File
@@ -0,0 +1,72 @@
import cron from 'node-cron';
import { MAIL_CONFIGURED, SCHEDULER_ENABLED, mailSettings } from './config.js';
import { getSyncState, saveSyncState } from './store.js';
import { isMailSyncRunning, runMailSync } from './sync.js';
/*
* Hintergrundabgleich des Postfachs.
*
* Wie beim Scanner prueft ein Minutentakt, ob ein Lauf faellig ist, statt ein
* festes Intervall einzuplanen: ein in der Oberflaeche geaendertes Intervall
* greift dadurch sofort, und der Zeitpunkt des letzten Laufs steht in der
* Datenbank - der Zeitplan ueberlebt also Neustarts und Deployments.
*
* Anders als beim Scanner gibt es kein Zeitfenster und keine Wochentagssperre:
* Mails kommen auch nachts und am Wochenend an, und der Abgleich kostet nichts
* ausser einem Graph-Aufruf.
*/
let job = null;
/** Steht noch eine Delta-Seite offen, wird ohne Wartezeit weitergemacht. */
const CONTINUE_DELAY_MS = 15_000;
export function isMailSchedulerRunning() {
return job !== null;
}
export async function startMailScheduler() {
if (!MAIL_CONFIGURED) {
console.log('[Mail] Zugangsdaten fehlen - Abgleich bleibt aus, die Oberflaeche laeuft.');
return;
}
if (!SCHEDULER_ENABLED) {
console.log('[Mail] Abgleich per MAIL_SYNC=false abgeschaltet.');
return;
}
if (job) return;
const settings = await mailSettings();
console.log(
`[Mail] Abgleich eingerichtet: alle ${settings.intervalMinutes} Minuten, ` +
`Kontakt-Uebernahme ${settings.autoContacts ? 'automatisch' : 'nur nach Durchsicht'}.`
);
job = cron.schedule('* * * * *', async () => {
if (isMailSyncRunning()) return;
try {
const active = await mailSettings();
if (!active.active) return;
const state = await getSyncState();
/*
* Faelligkeit aus last_run_at in der Datenbank. Fehlt der Wert, ist sofort
* ein Lauf faellig - so kommt eine frische Installation ohne Wartezeit in
* Gang.
*/
const lastRun = state.last_run_at ? Date.parse(`${state.last_run_at.replace(' ', 'T')}Z`) : 0;
const waitMs = state.has_more ? CONTINUE_DELAY_MS : active.intervalMinutes * 60_000;
if (Number.isFinite(lastRun) && lastRun > 0 && Date.now() - lastRun < waitMs) return;
await runMailSync({ trigger: 'plan' });
} catch (err) {
console.error('[Mail] Fehler im geplanten Abgleich:', err);
await saveSyncState({ last_error: String(err.message).slice(0, 1000) }).catch(() => {});
}
});
job.start();
}
+217
View File
@@ -0,0 +1,217 @@
import { pool } from '../db.js';
/*
* Datenbankzugriffe des Mailabgleichs. Lesende Abfragen der Oberflaeche stehen
* wie im uebrigen Backend direkt in routes/mail.js - hier liegt nur, was sowohl
* der Hintergrundlauf als auch die Routen brauchen.
*/
/** Zustandszeile des Abgleichs (immer id = 1). */
export async function getSyncState() {
const [rows] = await pool.query('SELECT * FROM crm_mail_sync_state WHERE id = 1 LIMIT 1');
if (rows[0]) return rows[0];
// Sollte die Startzeile fehlen (fremder Dump), hier nachziehen.
await pool.query('INSERT IGNORE INTO crm_mail_sync_state (id) VALUES (1)');
const [again] = await pool.query('SELECT * FROM crm_mail_sync_state WHERE id = 1 LIMIT 1');
return again[0];
}
const SYNC_STATE_FIELDS = new Set([
'mailbox',
'delta_link',
'has_more',
'last_run_at',
'last_success_at',
'last_error',
'last_new_messages',
'last_candidates',
]);
/** Teilweises Aktualisieren der Zustandszeile. */
export async function saveSyncState(patch) {
const entries = Object.entries(patch).filter(([key]) => SYNC_STATE_FIELDS.has(key));
if (entries.length === 0) return;
await pool.query(
`UPDATE crm_mail_sync_state SET ${entries.map(([key]) => `${key} = ?`).join(', ')} WHERE id = 1`,
entries.map(([, value]) => value)
);
}
/** Vollstaendigen Neuaufbau anstossen: Delta-Verweis verwerfen. */
export async function resetDelta() {
await saveSyncState({ delta_link: null, has_more: 0 });
}
/**
* Ist die Mail schon bekannt, und liegt ihr Text vor? Der Delta-Abgleich holt
* absichtlich nur Metadaten; der Mailtext wird einzeln nachgeladen und nur dann,
* wenn er wirklich fehlt.
*/
export async function getMessageStateByHash(graphIdHash) {
const [rows] = await pool.query(
`SELECT id, (body_text IS NOT NULL AND body_text <> '') AS has_body
FROM crm_mail_messages WHERE graph_id_hash = ? LIMIT 1`,
[graphIdHash]
);
if (!rows[0]) return null;
return { id: rows[0].id, hasBody: Boolean(Number(rows[0].has_body)) };
}
/**
* Nachricht anlegen oder aktualisieren. Bewusst mit vorherigem SELECT statt
* ON DUPLICATE KEY UPDATE: so ist eindeutig, ob es eine neue Mail war - nur dann
* werden Kontakt-Vorschlaege erzeugt.
*
* Fehlen die Textfelder im uebergebenen Objekt (undefined), bleiben die
* gespeicherten Werte unangetastet - ein reines Metadaten-Update darf den
* bereits geholten Mailtext nicht loeschen.
*/
export async function upsertMessage(message) {
const [existing] = await pool.query(
'SELECT id FROM crm_mail_messages WHERE graph_id_hash = ? LIMIT 1',
[message.graphIdHash]
);
const columns = {
graph_id: message.graphId,
graph_id_hash: message.graphIdHash,
internet_message_id: message.internetMessageId,
conversation_id: message.conversationId,
subject: message.subject,
from_name: message.fromName,
from_email: message.fromEmail,
to_json: JSON.stringify(message.to ?? []),
cc_json: JSON.stringify(message.cc ?? []),
bcc_json: JSON.stringify(message.bcc ?? []),
received_at: message.receivedAt,
sent_at: message.sentAt,
body_preview: message.bodyPreview,
body_text: message.bodyText,
body_html: message.bodyHtml,
has_attachments: message.hasAttachments ? 1 : 0,
importance: message.importance,
web_link: message.webLink,
is_read_in_mailbox: message.isReadInMailbox ? 1 : 0,
// Wiederaufgetauchte Mail gilt wieder als vorhanden.
is_deleted: 0,
};
if (message.bodyText === undefined) {
delete columns.body_text;
delete columns.body_html;
delete columns.body_preview;
}
if (existing[0]) {
const keys = Object.keys(columns).filter((key) => key !== 'graph_id_hash');
await pool.query(
`UPDATE crm_mail_messages SET ${keys.map((key) => `${key} = ?`).join(', ')} WHERE id = ?`,
[...keys.map((key) => columns[key]), existing[0].id]
);
return { id: existing[0].id, isNew: false };
}
const keys = Object.keys(columns);
const [result] = await pool.query(
`INSERT INTO crm_mail_messages (${keys.join(', ')})
VALUES (${keys.map(() => '?').join(', ')})`,
keys.map((key) => columns[key])
);
return { id: result.insertId, isNew: true };
}
/**
* Im Postfach geloeschte Mail markieren, nicht entfernen: eine bereits an eine
* Opportunity gehaengte Mail soll nicht aus dem CRM verschwinden, nur weil
* jemand das Postfach aufraeumt.
*/
export async function markMessageDeleted(graphIdHash) {
const [result] = await pool.query(
'UPDATE crm_mail_messages SET is_deleted = 1 WHERE graph_id_hash = ?',
[graphIdHash]
);
return result.affectedRows > 0;
}
const CANDIDATE_COLUMNS = [
'message_id',
'dedupe_hash',
'first_name',
'last_name',
'salutation',
'email',
'phone',
'mobile',
'company',
'position',
'street',
'zip',
'city',
'website',
'linkedin',
'origin',
'confidence',
];
/**
* Vorschlaege zu einer Mail speichern. INSERT IGNORE zusammen mit dem
* Unique-Schluessel (message_id, dedupe_hash) macht den Aufruf wiederholbar.
* Zurueck kommen die tatsaechlich neu angelegten Zeilen.
*/
export async function insertCandidates(messageId, candidates) {
const created = [];
for (const candidate of candidates) {
const values = [
messageId,
candidate.dedupeHash,
candidate.firstName,
candidate.lastName,
candidate.salutation,
candidate.email,
candidate.phone,
candidate.mobile,
candidate.company,
candidate.position,
candidate.street,
candidate.zip,
candidate.city,
candidate.website,
candidate.linkedin,
candidate.origin,
candidate.confidence,
];
const [result] = await pool.query(
`INSERT IGNORE INTO crm_mail_contact_candidates (${CANDIDATE_COLUMNS.join(', ')})
VALUES (${CANDIDATE_COLUMNS.map(() => '?').join(', ')})`,
values
);
if (result.affectedRows > 0) created.push({ id: result.insertId, ...candidate });
}
return created;
}
/** Bestehenden Kontakt zu einer Adresse finden (Gross-/Kleinschreibung egal). */
export async function findLeadByEmail(email) {
if (!email) return null;
const [rows] = await pool.query(
'SELECT id, first_name, last_name FROM crm_leads WHERE LOWER(email) = LOWER(?) ORDER BY id LIMIT 1',
[email]
);
return rows[0] ?? null;
}
/** Verknuepfung Mail <-> Kontakt/Opportunity; doppelte Paare werden ignoriert. */
export async function linkMessage({ messageId, leadId = null, opportunityId = null, origin = 'manuell', note = null, userId = null }) {
const [result] = await pool.query(
`INSERT IGNORE INTO crm_mail_links (message_id, lead_id, opportunity_id, origin, note, created_by)
VALUES (?, ?, ?, ?, ?, ?)`,
[messageId, leadId, opportunityId, origin, note, userId]
);
return result.affectedRows > 0;
}
+355
View File
@@ -0,0 +1,355 @@
import { createHash } from 'node:crypto';
import {
MAILBOX,
MAIL_FOLDER,
MAIL_CONFIGURED,
MAX_PAGES_PER_RUN,
MAX_BODY_CHARS,
mailSettings,
missingCredentials,
} from './config.js';
import { GraphError, graphRequest, mailboxPath } from './graph.js';
import { htmlToText, sanitizeHtml } from './html.js';
import { extractCandidates, AUTO_ADOPT_MIN_CONFIDENCE } from './extract.js';
import { adoptCandidate } from './contacts.js';
import {
getMessageStateByHash,
getSyncState,
insertCandidates,
markMessageDeleted,
saveSyncState,
upsertMessage,
} from './store.js';
/*
* Abgleich des Postfachs ueber Microsoft Graph Delta Query.
*
* Warum Delta und nicht "ungelesene holen und als gelesen markieren" wie im
* Altsystem:
* - Es braucht keinen Schreibzugriff auf das Postfach. Mail.Read genuegt.
* - Oeffnet ein Mensch die Mail vorher in Outlook, wird sie trotzdem erfasst.
* Beim isRead-Ansatz war sie verloren.
* - Der Fortschritt liegt in der Datenbank (delta_link) und ueberlebt Neustarts.
*
* Der Delta-Aufruf holt nur Metadaten. Der Mailtext wird einzeln nachgeladen und
* ausschliesslich fuer Mails, deren Text noch fehlt.
*/
/** Metadaten, die der Delta-Aufruf liefert. */
const DELTA_SELECT = [
'id',
'internetMessageId',
'conversationId',
'subject',
'from',
'sender',
'toRecipients',
'ccRecipients',
'bccRecipients',
'receivedDateTime',
'sentDateTime',
'hasAttachments',
'importance',
'webLink',
'isRead',
].join(',');
const PAGE_SIZE = 50;
let running = false;
let lastResult = null;
export const isMailSyncRunning = () => running;
export const lastMailSyncResult = () => lastResult;
const hash = (value) => createHash('sha256').update(value).digest('hex');
/** ISO-Zeitstempel von Graph in MariaDB-DATETIME (UTC) wandeln. */
function toSqlUtc(iso) {
if (!iso) return null;
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString().slice(0, 19).replace('T', ' ');
}
const addressList = (recipients) =>
(recipients || [])
.map((entry) => ({
name: entry?.emailAddress?.name ?? null,
email: entry?.emailAddress?.address ? String(entry.emailAddress.address).toLowerCase() : null,
}))
.filter((entry) => entry.email);
/** Graph-Nachricht in unsere Form bringen. */
function normalizeMessage(raw) {
const from = raw.from?.emailAddress ?? raw.sender?.emailAddress ?? {};
return {
graphId: String(raw.id).slice(0, 1024),
graphIdHash: hash(String(raw.id)),
internetMessageId: raw.internetMessageId ? String(raw.internetMessageId).slice(0, 512) : null,
conversationId: raw.conversationId ? String(raw.conversationId).slice(0, 512) : null,
subject: raw.subject ? String(raw.subject).slice(0, 512) : null,
fromName: from.name ? String(from.name).slice(0, 190) : null,
fromEmail: from.address ? String(from.address).toLowerCase().slice(0, 190) : null,
to: addressList(raw.toRecipients),
cc: addressList(raw.ccRecipients),
bcc: addressList(raw.bccRecipients),
receivedAt: toSqlUtc(raw.receivedDateTime) ?? toSqlUtc(new Date().toISOString()),
sentAt: toSqlUtc(raw.sentDateTime),
hasAttachments: Boolean(raw.hasAttachments),
importance: raw.importance ? String(raw.importance).slice(0, 16) : null,
webLink: raw.webLink ? String(raw.webLink).slice(0, 1024) : null,
isReadInMailbox: Boolean(raw.isRead),
};
}
/** Mailtext einzeln nachladen und in Text plus entschaerftes HTML wandeln. */
async function loadBody(graphId) {
const data = await graphRequest(
`${mailboxPath(`/messages/${encodeURIComponent(graphId)}`)}?$select=body,bodyPreview`
);
const contentType = String(data?.body?.contentType || '').toLowerCase();
const content = data?.body?.content ? String(data.body.content) : '';
const capped = content.slice(0, MAX_BODY_CHARS);
return {
bodyPreview: data?.bodyPreview ? String(data.bodyPreview).slice(0, 512) : null,
bodyText: contentType === 'html' ? htmlToText(capped) : capped.replace(/\r\n?/g, '\n').trim(),
bodyHtml: contentType === 'html' ? sanitizeHtml(capped) : null,
};
}
/** Erster Delta-Aufruf. `since` begrenzt den Erstabgleich. */
function initialDeltaUrl(since) {
const base = mailboxPath(`/mailFolders/${encodeURIComponent(MAIL_FOLDER)}/messages/delta`);
const query = new URLSearchParams();
query.set('$select', DELTA_SELECT);
query.set('$top', String(PAGE_SIZE));
if (since) query.set('$filter', `receivedDateTime ge ${since}`);
// URLSearchParams kodiert die Doppelpunkte im Zeitstempel - Graph akzeptiert
// beides, aber unkodiert ist es in Logs deutlich besser lesbar.
return `${base}?${query.toString().replace(/%3A/g, ':')}`;
}
/**
* Delta-Seite holen. Der erste Aufruf versucht es mit Zeitfilter; lehnt Graph den
* ab (die Filterunterstuetzung in Delta-Abfragen ist eng begrenzt), wird ohne
* Filter wiederholt und der Zeitraum stattdessen beim Speichern beachtet.
*/
async function fetchDeltaPage(url, { allowFilterFallback = false, since = null } = {}) {
try {
return { data: await graphRequest(url), url };
} catch (err) {
if (allowFilterFallback && err instanceof GraphError && err.status === 400) {
console.warn('[Mail] Delta mit Zeitfilter abgelehnt - wiederhole ohne Filter.');
const fallback = initialDeltaUrl(null);
return { data: await graphRequest(fallback), url: fallback, filtered: false, since };
}
throw err;
}
}
/**
* Ein Abgleichslauf. `trigger` steht nur im Protokoll ('plan' oder 'manuell').
* Laeuft immer nur einer; ein zweiter Aufruf kommt sofort mit skipped zurueck.
*/
export async function runMailSync({ trigger = 'manuell' } = {}) {
if (!MAIL_CONFIGURED) {
return { skipped: 'nicht-konfiguriert', missing: missingCredentials() };
}
if (running) return { skipped: 'laeuft-bereits' };
running = true;
const startedAt = new Date();
let newMessages = 0;
let updatedMessages = 0;
let deletedMessages = 0;
let newCandidates = 0;
let adoptedContacts = 0;
let pages = 0;
try {
const settings = await mailSettings();
await saveSyncState({ mailbox: MAILBOX, last_run_at: toSqlUtc(startedAt.toISOString()) });
const state = await getSyncState();
const isFirstRun = !state.delta_link;
// Grenze fuer den Erstabgleich. Auch der Filter-Ersatzweg nutzt sie.
const cutoff = new Date(Date.now() - settings.sinceDays * 86_400_000);
const cutoffSql = toSqlUtc(cutoff.toISOString());
let url = state.delta_link || initialDeltaUrl(cutoff.toISOString().replace(/\.\d{3}Z$/, 'Z'));
// Wird nur gesetzt, wenn Graph den Zeitfilter ablehnt und wir ohne Filter
// durchlaufen - dann muss die Zeitgrenze hier greifen.
let applyCutoffLocally = false;
let deltaLink = null;
let hasMore = false;
while (url && pages < MAX_PAGES_PER_RUN) {
const page = await fetchDeltaPage(url, {
allowFilterFallback: isFirstRun && pages === 0,
since: cutoff.toISOString(),
});
if (page.filtered === false) applyCutoffLocally = true;
pages += 1;
const data = page.data ?? {};
for (const raw of data.value || []) {
if (!raw?.id) continue;
// Im Postfach entfernte Mail: Kennzeichnen, nicht loeschen.
if (raw['@removed']) {
if (await markMessageDeleted(hash(String(raw.id)))) deletedMessages += 1;
continue;
}
const message = normalizeMessage(raw);
// Beim Ersatzweg ohne Zeitfilter greift die Grenze hier.
if (applyCutoffLocally && cutoffSql && message.receivedAt < cutoffSql) continue;
const existing = await getMessageStateByHash(message.graphIdHash);
if (!existing || !existing.hasBody) {
try {
Object.assign(message, await loadBody(message.graphId));
} catch (err) {
// Ohne Text ist die Mail immer noch nuetzlich - der Rest steht schon.
console.warn(`[Mail] Text konnte nicht geladen werden (${message.subject ?? 'ohne Betreff'}): ${err.message}`);
}
}
const stored = await upsertMessage(message);
if (stored.isNew) newMessages += 1;
else updatedMessages += 1;
// Vorschlaege entstehen nur einmal je Mail - bei erneutem Durchlauf
// sperrt der Unique-Schluessel (message_id, dedupe_hash).
if (stored.isNew) {
const candidates = extractCandidates(
{ ...message, bodyText: message.bodyText ?? '' },
{ internalDomains: settings.internalDomains, mailbox: MAILBOX }
);
const created = await insertCandidates(stored.id, candidates);
newCandidates += created.length;
if (settings.autoContacts) {
for (const candidate of created) {
if (!candidate.email) continue;
if (candidate.confidence < AUTO_ADOPT_MIN_CONFIDENCE) continue;
try {
const result = await adoptCandidate(
{
id: candidate.id,
message_id: stored.id,
first_name: candidate.firstName,
last_name: candidate.lastName,
salutation: candidate.salutation,
email: candidate.email,
phone: candidate.phone,
mobile: candidate.mobile,
company: candidate.company,
position: candidate.position,
street: candidate.street,
zip: candidate.zip,
city: candidate.city,
website: candidate.website,
linkedin: candidate.linkedin,
},
{ mode: 'automatisch' }
);
if (result.status === 'uebernommen') adoptedContacts += 1;
} catch (err) {
console.warn(`[Mail] Kontakt-Vorschlag konnte nicht uebernommen werden: ${err.message}`);
}
}
}
}
}
// Nach jeder Seite den Fortschritt sichern: ein Abbruch mitten im
// Erstabgleich soll nicht alles noch einmal durchlaufen.
if (data['@odata.nextLink']) {
url = data['@odata.nextLink'];
hasMore = true;
await saveSyncState({ delta_link: url, has_more: 1 });
} else {
deltaLink = data['@odata.deltaLink'] ?? null;
hasMore = false;
url = null;
if (deltaLink) await saveSyncState({ delta_link: deltaLink, has_more: 0 });
}
}
// Seitenlimit erreicht - der naechste Lauf macht an derselben Stelle weiter.
const truncated = Boolean(url);
if (truncated) hasMore = true;
await saveSyncState({
has_more: hasMore ? 1 : 0,
last_success_at: toSqlUtc(new Date().toISOString()),
last_error: null,
last_new_messages: newMessages,
last_candidates: newCandidates,
});
lastResult = {
ok: true,
trigger,
startedAt: startedAt.toISOString(),
durationMs: Date.now() - startedAt.getTime(),
pages,
newMessages,
updatedMessages,
deletedMessages,
newCandidates,
adoptedContacts,
hasMore: hasMore || truncated,
};
if (newMessages || newCandidates || deletedMessages) {
console.log(
`[Mail] Abgleich (${trigger}): ${newMessages} neu, ${updatedMessages} aktualisiert, ` +
`${deletedMessages} entfernt, ${newCandidates} Kontakt-Vorschlaege, ${adoptedContacts} Kontakte angelegt.`
);
}
if (hasMore || truncated) {
console.log('[Mail] Es sind noch weitere Seiten offen - der naechste Lauf setzt fort.');
}
return lastResult;
} catch (err) {
const message = err instanceof GraphError ? `${err.message}${err.code ? ` (${err.code})` : ''}` : err.message;
console.error(`[Mail] Abgleich fehlgeschlagen: ${message}`);
/*
* Ist der Delta-Verweis abgelaufen oder ungueltig (Graph: 410 Gone bzw.
* resyncRequired), wird er verworfen. Der naechste Lauf beginnt dann mit
* einem frischen Erstabgleich - vorhandene Mails werden dabei erkannt und
* nicht doppelt angelegt.
*/
if (err instanceof GraphError && (err.status === 410 || err.code === 'resyncRequired' || err.code === 'SyncStateNotFound')) {
await saveSyncState({ delta_link: null, has_more: 0 });
console.warn('[Mail] Delta-Verweis war ungueltig - der naechste Lauf beginnt von vorn.');
}
await saveSyncState({ last_error: message.slice(0, 1000) });
lastResult = {
ok: false,
trigger,
startedAt: startedAt.toISOString(),
durationMs: Date.now() - startedAt.getTime(),
error: message,
pages,
newMessages,
newCandidates,
};
return lastResult;
} finally {
running = false;
}
}
+504
View File
@@ -0,0 +1,504 @@
import express from 'express';
import { pool } from '../db.js';
import { requireAdmin } from '../middleware/auth.js';
import { setSetting } from '../settings.js';
import {
MAILBOX,
MAIL_CONFIGURED,
MAIL_FOLDER,
MAIL_SETTING_DEFAULTS,
SCHEDULER_ENABLED,
mailSettings,
missingCredentials,
} from '../mail/config.js';
import { testConnection } from '../mail/graph.js';
import { getSyncState, resetDelta } from '../mail/store.js';
import { isMailSyncRunning, lastMailSyncResult, runMailSync } from '../mail/sync.js';
import { adoptCandidate, markCandidate } from '../mail/contacts.js';
/*
* Endpunkte der Postfachanbindung. Lesen darf jeder Angemeldete - das Postfach
* crm@perinco.de ist ein gemeinsames Vertriebspostfach. Konfigurieren,
* Verbindungstest und manueller Abgleich sind Administratoren vorbehalten.
*/
const router = express.Router();
const parseJsonArray = (raw) => {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const clampInt = (value, fallback, min, max) => {
const parsed = Number.parseInt(String(value ?? ''), 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
};
/** Listenform einer Mail - ohne Textkoerper, damit die Liste schlank bleibt. */
function mapMessageRow(row) {
return {
id: row.id,
subject: row.subject,
from_name: row.from_name,
from_email: row.from_email,
to: parseJsonArray(row.to_json),
cc: parseJsonArray(row.cc_json),
received_at: row.received_at,
body_preview: row.body_preview,
has_attachments: Boolean(row.has_attachments),
is_deleted: Boolean(row.is_deleted),
web_link: row.web_link,
link_count: Number(row.link_count ?? 0),
open_candidates: Number(row.open_candidates ?? 0),
};
}
// ---------------------------------------------------------------------------
// Status und Einstellungen
// ---------------------------------------------------------------------------
// GET /api/mail/status
router.get('/status', async (req, res, next) => {
try {
const settings = await mailSettings();
const state = await getSyncState();
const [[counts]] = await pool.query(
`SELECT
(SELECT COUNT(*) FROM crm_mail_messages WHERE is_deleted = 0) AS messages,
(SELECT COUNT(*) FROM crm_mail_contact_candidates WHERE status = 'offen') AS open_candidates,
(SELECT COUNT(*) FROM crm_mail_contact_candidates WHERE status = 'uebernommen') AS adopted_candidates,
(SELECT COUNT(*) FROM crm_mail_links) AS links`
);
res.json({
configured: MAIL_CONFIGURED,
missing: MAIL_CONFIGURED ? [] : missingCredentials(),
mailbox: MAILBOX,
folder: MAIL_FOLDER,
scheduler_enabled: SCHEDULER_ENABLED,
active: settings.active,
interval_minutes: settings.intervalMinutes,
auto_contacts: settings.autoContacts,
since_days: settings.sinceDays,
running: isMailSyncRunning(),
has_more: Boolean(state.has_more),
initialized: Boolean(state.delta_link),
last_run_at: state.last_run_at,
last_success_at: state.last_success_at,
last_error: state.last_error,
last_new_messages: state.last_new_messages,
last_candidates: state.last_candidates,
counts: {
messages: Number(counts.messages),
open_candidates: Number(counts.open_candidates),
adopted_candidates: Number(counts.adopted_candidates),
links: Number(counts.links),
},
last_result: lastMailSyncResult(),
});
} catch (err) {
next(err);
}
});
// GET /api/mail/settings
router.get('/settings', requireAdmin, async (req, res, next) => {
try {
const settings = await mailSettings();
// Es gibt hier keine Geheimnisse zu maskieren - Zugangsdaten stehen in der
// .env und werden bewusst nie ausgeliefert.
res.json(settings.raw);
} catch (err) {
next(err);
}
});
// POST /api/mail/settings
router.post('/settings', requireAdmin, async (req, res, next) => {
try {
const allowed = Object.keys(MAIL_SETTING_DEFAULTS);
const incoming = Object.entries(req.body ?? {}).filter(([key]) => allowed.includes(key));
if (incoming.length === 0) {
return res.status(400).json({ error: 'Keine bekannten Einstellungen uebergeben.' });
}
for (const [key, value] of incoming) {
if (key === 'mail_sync_interval') {
const minutes = clampInt(value, 10, 2, 1440);
await setSetting(key, String(minutes));
continue;
}
if (key === 'mail_since_days') {
const days = clampInt(value, 30, 1, 3650);
await setSetting(key, String(days));
continue;
}
if (key === 'mail_sync_active' || key === 'mail_auto_contacts') {
await setSetting(key, value === true || value === 'true' ? 'true' : 'false');
continue;
}
await setSetting(key, value === null || value === undefined ? '' : String(value));
}
const settings = await mailSettings();
res.json(settings.raw);
} catch (err) {
next(err);
}
});
// POST /api/mail/test - Token holen und wirklich auf das Postfach zugreifen
router.post('/test', requireAdmin, async (req, res, next) => {
try {
if (!MAIL_CONFIGURED) {
return res.status(400).json({
ok: false,
stage: 'konfiguration',
error: `Zugangsdaten fehlen: ${missingCredentials().join(', ')}`,
hint: 'Werte in server/.env setzen und den Dienst neu starten.',
});
}
res.json(await testConnection());
} catch (err) {
next(err);
}
});
// POST /api/mail/sync - Abgleich sofort anstossen
router.post('/sync', requireAdmin, async (req, res, next) => {
try {
if (!MAIL_CONFIGURED) {
return res.status(409).json({ error: `Zugangsdaten fehlen: ${missingCredentials().join(', ')}` });
}
if (isMailSyncRunning()) {
return res.status(409).json({ error: 'Ein Abgleich laeuft bereits. Bitte kurz warten.' });
}
// Vollstaendiger Neuaufbau auf Wunsch: Delta-Verweis verwerfen. Vorhandene
// Mails werden dabei erkannt, es entstehen keine Doppelten.
if (req.body?.reset === true) await resetDelta();
const result = await runMailSync({ trigger: 'manuell' });
if (result.skipped) return res.status(409).json({ error: `Abgleich uebersprungen: ${result.skipped}` });
if (!result.ok) return res.status(502).json({ error: result.error, result });
res.json(result);
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Nachrichten
// ---------------------------------------------------------------------------
// GET /api/mail/messages?search=&limit=&offset=&filter=
router.get('/messages', async (req, res, next) => {
try {
const limit = clampInt(req.query.limit, 50, 1, 200);
const offset = clampInt(req.query.offset, 0, 0, 100_000);
const where = [];
const params = [];
if (req.query.include_deleted !== 'true') where.push('m.is_deleted = 0');
const search = String(req.query.search ?? '').trim();
if (search) {
where.push('(m.subject LIKE ? OR m.from_email LIKE ? OR m.from_name LIKE ? OR m.body_text LIKE ?)');
const like = `%${search}%`;
params.push(like, like, like, like);
}
// "unverknuepft" ist die Arbeitsliste: Mails, die noch an keiner Opportunity
// und an keinem Kontakt haengen.
if (req.query.filter === 'unlinked') {
where.push('NOT EXISTS (SELECT 1 FROM crm_mail_links l WHERE l.message_id = m.id)');
} else if (req.query.filter === 'linked') {
where.push('EXISTS (SELECT 1 FROM crm_mail_links l WHERE l.message_id = m.id)');
} else if (req.query.filter === 'candidates') {
where.push(
"EXISTS (SELECT 1 FROM crm_mail_contact_candidates c WHERE c.message_id = m.id AND c.status = 'offen')"
);
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
const [rows] = await pool.query(
`SELECT m.id, m.subject, m.from_name, m.from_email, m.to_json, m.cc_json, m.received_at,
m.body_preview, m.has_attachments, m.is_deleted, m.web_link,
(SELECT COUNT(*) FROM crm_mail_links l WHERE l.message_id = m.id) AS link_count,
(SELECT COUNT(*) FROM crm_mail_contact_candidates c
WHERE c.message_id = m.id AND c.status = 'offen') AS open_candidates
FROM crm_mail_messages m
${whereSql}
ORDER BY m.received_at DESC, m.id DESC
LIMIT ? OFFSET ?`,
[...params, limit, offset]
);
const [[total]] = await pool.query(
`SELECT COUNT(*) AS count FROM crm_mail_messages m ${whereSql}`,
params
);
res.json({ total: Number(total.count), limit, offset, messages: rows.map(mapMessageRow) });
} catch (err) {
next(err);
}
});
// GET /api/mail/messages/:id
router.get('/messages/:id', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Mail-ID.' });
try {
const [rows] = await pool.query('SELECT * FROM crm_mail_messages WHERE id = ? LIMIT 1', [id]);
const row = rows[0];
if (!row) return res.status(404).json({ error: 'Mail nicht gefunden.' });
const [links] = await pool.query(
`SELECT l.id, l.lead_id, l.opportunity_id, l.origin, l.note, l.created_at,
l.created_by, u.name AS created_by_name,
CONCAT_WS(' ', lead.first_name, lead.last_name) AS lead_name,
lead.company AS lead_company,
opp.title AS opportunity_title,
opp.stage AS opportunity_stage
FROM crm_mail_links l
LEFT JOIN crm_leads lead ON lead.id = l.lead_id
LEFT JOIN crm_opportunities opp ON opp.id = l.opportunity_id
LEFT JOIN crm_users u ON u.id = l.created_by
WHERE l.message_id = ?
ORDER BY l.id`,
[id]
);
const [candidates] = await pool.query(
`SELECT c.*,
CONCAT_WS(' ', lead.first_name, lead.last_name) AS lead_name,
(SELECT lx.id FROM crm_leads lx
WHERE c.email IS NOT NULL AND LOWER(lx.email) = LOWER(c.email)
ORDER BY lx.id LIMIT 1) AS existing_lead_id
FROM crm_mail_contact_candidates c
LEFT JOIN crm_leads lead ON lead.id = c.lead_id
WHERE c.message_id = ?
ORDER BY c.confidence DESC, c.id`,
[id]
);
res.json({
...mapMessageRow({ ...row, link_count: links.length }),
bcc: parseJsonArray(row.bcc_json),
sent_at: row.sent_at,
importance: row.importance,
conversation_id: row.conversation_id,
internet_message_id: row.internet_message_id,
is_read_in_mailbox: Boolean(row.is_read_in_mailbox),
body_text: row.body_text,
body_html: row.body_html,
links,
candidates: candidates.map((candidate) => ({
...candidate,
confidence: Number(candidate.confidence),
})),
});
} catch (err) {
next(err);
}
});
// POST /api/mail/messages/:id/links - Mail an Kontakt und/oder Opportunity haengen
router.post('/messages/:id/links', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Mail-ID.' });
const leadId = req.body?.lead_id === undefined || req.body.lead_id === null ? null : Number(req.body.lead_id);
const opportunityId =
req.body?.opportunity_id === undefined || req.body.opportunity_id === null
? null
: Number(req.body.opportunity_id);
if (leadId === null && opportunityId === null) {
return res.status(400).json({ error: 'Kontakt oder Opportunity angeben.' });
}
if (leadId !== null && !Number.isInteger(leadId)) {
return res.status(400).json({ error: 'Ungueltige Kontakt-ID.' });
}
if (opportunityId !== null && !Number.isInteger(opportunityId)) {
return res.status(400).json({ error: 'Ungueltige Opportunity-ID.' });
}
try {
const [messages] = await pool.query('SELECT id FROM crm_mail_messages WHERE id = ? LIMIT 1', [id]);
if (!messages[0]) return res.status(404).json({ error: 'Mail nicht gefunden.' });
// Erst beide Ziele pruefen, dann schreiben. Sonst haengt bei einer falschen
// Opportunity-ID der Kontakt schon dran, obwohl die Anfrage mit 400 endet.
if (leadId !== null) {
const [exists] = await pool.query('SELECT id FROM crm_leads WHERE id = ? LIMIT 1', [leadId]);
if (!exists[0]) return res.status(400).json({ error: 'Kontakt existiert nicht.' });
}
if (opportunityId !== null) {
const [exists] = await pool.query('SELECT id FROM crm_opportunities WHERE id = ? LIMIT 1', [
opportunityId,
]);
if (!exists[0]) return res.status(400).json({ error: 'Opportunity existiert nicht.' });
}
const note = req.body?.note ? String(req.body.note).slice(0, 500) : null;
const created = [];
// Kontakt und Opportunity werden als zwei Verknuepfungen gefuehrt - so laesst
// sich jede einzeln wieder loesen.
for (const [leadValue, opportunityValue] of [
[leadId, null],
[null, opportunityId],
]) {
if (leadValue === null && opportunityValue === null) continue;
const [result] = await pool.query(
`INSERT IGNORE INTO crm_mail_links (message_id, lead_id, opportunity_id, origin, note, created_by)
VALUES (?, ?, ?, 'manuell', ?, ?)`,
[id, leadValue, opportunityValue, note, req.user.id]
);
if (result.affectedRows > 0) created.push(result.insertId);
}
if (created.length === 0) {
return res.status(409).json({ error: 'Diese Verknuepfung besteht bereits.' });
}
res.status(201).json({ ok: true, created });
} catch (err) {
next(err);
}
});
// DELETE /api/mail/links/:id
router.delete('/links/:id', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Verknuepfungs-ID.' });
try {
const [result] = await pool.query('DELETE FROM crm_mail_links WHERE id = ?', [id]);
if (result.affectedRows === 0) return res.status(404).json({ error: 'Verknuepfung nicht gefunden.' });
res.json({ ok: true });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Kontakt-Vorschlaege
// ---------------------------------------------------------------------------
const CANDIDATE_STATUSES = new Set(['offen', 'uebernommen', 'verworfen', 'dublette']);
// GET /api/mail/candidates?status=offen
router.get('/candidates', async (req, res, next) => {
try {
const limit = clampInt(req.query.limit, 100, 1, 500);
const status = String(req.query.status ?? 'offen');
const where = [];
const params = [];
if (status !== 'alle') {
if (!CANDIDATE_STATUSES.has(status)) return res.status(400).json({ error: 'Ungueltiger Status.' });
where.push('c.status = ?');
params.push(status);
}
const [rows] = await pool.query(
`SELECT c.*,
m.subject AS message_subject,
m.from_email AS message_from,
m.received_at AS message_received_at,
CONCAT_WS(' ', lead.first_name, lead.last_name) AS lead_name,
u.name AS decided_by_name,
/* Gibt es diese Adresse schon als Kontakt? Dann ist es keine
Neuanlage, sondern eine Ergaenzung. */
(SELECT lx.id FROM crm_leads lx
WHERE c.email IS NOT NULL AND LOWER(lx.email) = LOWER(c.email)
ORDER BY lx.id LIMIT 1) AS existing_lead_id
FROM crm_mail_contact_candidates c
JOIN crm_mail_messages m ON m.id = c.message_id
LEFT JOIN crm_leads lead ON lead.id = c.lead_id
LEFT JOIN crm_users u ON u.id = c.decided_by
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY c.confidence DESC, m.received_at DESC, c.id DESC
LIMIT ?`,
[...params, limit]
);
res.json(
rows.map((row) => ({
...row,
confidence: Number(row.confidence),
existing_lead_id: row.existing_lead_id ?? null,
}))
);
} catch (err) {
next(err);
}
});
async function loadCandidate(id) {
const [rows] = await pool.query('SELECT * FROM crm_mail_contact_candidates WHERE id = ? LIMIT 1', [id]);
return rows[0] ?? null;
}
// POST /api/mail/candidates/:id/adopt - Vorschlag zum Kontakt machen
router.post('/candidates/:id/adopt', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Vorschlags-ID.' });
try {
const candidate = await loadCandidate(id);
if (!candidate) return res.status(404).json({ error: 'Vorschlag nicht gefunden.' });
if (candidate.status !== 'offen') {
return res.status(409).json({ error: `Vorschlag ist bereits bearbeitet (${candidate.status}).` });
}
// Zustaendigkeit: uebergeben, sonst der Bearbeitende selbst.
const ownerId =
req.body?.owner_id === undefined || req.body.owner_id === null
? req.user.id
: Number(req.body.owner_id);
if (!Number.isInteger(ownerId)) return res.status(400).json({ error: 'Ungueltige Benutzer-ID.' });
const result = await adoptCandidate(candidate, {
userId: req.user.id,
ownerId,
mode: 'manuell',
});
res.json(result);
} catch (err) {
next(err);
}
});
// POST /api/mail/candidates/:id/reject
router.post('/candidates/:id/reject', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Vorschlags-ID.' });
try {
const candidate = await loadCandidate(id);
if (!candidate) return res.status(404).json({ error: 'Vorschlag nicht gefunden.' });
await markCandidate(id, 'verworfen', { userId: req.user.id });
res.json({ ok: true, status: 'verworfen' });
} catch (err) {
next(err);
}
});
export default router;
+145
View File
@@ -151,3 +151,148 @@ CREATE TABLE IF NOT EXISTS crm_event_participants (
CONSTRAINT fk_crm_ep_event FOREIGN KEY (event_id) REFERENCES crm_events (id) ON DELETE CASCADE,
CONSTRAINT fk_crm_ep_user FOREIGN KEY (user_id) REFERENCES crm_users (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ===========================================================================
-- Allgemeine Einstellungen des CRM als Schluessel/Wert. Bewusst ohne
-- Geheimnisse: Zugangsdaten stehen ausschliesslich in der .env auf dem Server,
-- damit ein Datenbank-Dump keine Secrets enthaelt.
-- ===========================================================================
CREATE TABLE IF NOT EXISTS crm_settings (
key_name VARCHAR(100) NOT NULL,
value TEXT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (key_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ===========================================================================
-- M365-Postfachanbindung (crm@perinco.de)
-- ===========================================================================
/*
* Gespiegelte Nachrichten aus dem Postfach. Die Graph-ID ist zu lang fuer einen
* Unique-Index (utf8mb4), deshalb liegt daneben ihr SHA-256 als Hex - der traegt
* die Eindeutigkeit. Zeiten stehen in UTC, so wie Graph sie liefert. Die
* Umrechnung macht das Frontend.
*
* ROW_FORMAT=DYNAMIC steht ausdruecklich da: bei COMPACT legt InnoDB von jeder
* langen Spalte 768 Bytes in die Zeile, und die Summe aus mehreren VARCHAR(512+)
* und fuenf LONGTEXT reisst dann die Zeilengrenze.
*/
CREATE TABLE IF NOT EXISTS crm_mail_messages (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
graph_id VARCHAR(1024) NOT NULL,
graph_id_hash CHAR(64) NOT NULL,
internet_message_id VARCHAR(512) NULL,
conversation_id VARCHAR(512) NULL,
subject VARCHAR(512) NULL,
from_name VARCHAR(190) NULL,
from_email VARCHAR(190) NULL,
to_json LONGTEXT NULL, -- [{name,email}] als JSON-Text
cc_json LONGTEXT NULL,
bcc_json LONGTEXT NULL,
received_at DATETIME NOT NULL, -- UTC
sent_at DATETIME NULL, -- UTC
body_preview VARCHAR(512) NULL,
body_text LONGTEXT NULL, -- aus HTML gewandelt, Zeilen erhalten
body_html LONGTEXT NULL, -- entschaerftes HTML fuer die Anzeige
has_attachments TINYINT(1) NOT NULL DEFAULT 0,
importance VARCHAR(16) NULL,
web_link VARCHAR(1024) NULL, -- Outlook-Web-Link
is_read_in_mailbox TINYINT(1) NOT NULL DEFAULT 0,
is_deleted TINYINT(1) NOT NULL DEFAULT 0, -- im Postfach geloescht
first_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_crm_mail_graph (graph_id_hash),
KEY idx_crm_mail_received (received_at),
KEY idx_crm_mail_from (from_email),
KEY idx_crm_mail_conversation (conversation_id(191))
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*
* Verknuepfung einer Mail mit Kontakt und/oder Opportunity. Absichtlich eine
* eigene Tabelle statt eines Textanhangs im Notizfeld: so laesst sich eine
* Verknuepfung wieder loesen, und dieselbe Mail kann an mehreren Stellen haengen.
* Mehrere NULL-Werte sind in einem Unique-Index erlaubt - damit sperren die
* beiden Schluessel genau die Doppelung desselben Paares.
*/
CREATE TABLE IF NOT EXISTS crm_mail_links (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
message_id INT UNSIGNED NOT NULL,
lead_id INT UNSIGNED NULL,
opportunity_id INT UNSIGNED NULL,
origin ENUM('manuell','automatisch') NOT NULL DEFAULT 'manuell',
note VARCHAR(500) NULL,
created_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_crm_mail_link_lead (message_id, lead_id),
UNIQUE KEY uq_crm_mail_link_opp (message_id, opportunity_id),
KEY idx_crm_mail_link_lead (lead_id),
KEY idx_crm_mail_link_opp (opportunity_id),
CONSTRAINT fk_crm_ml_message FOREIGN KEY (message_id) REFERENCES crm_mail_messages (id) ON DELETE CASCADE,
CONSTRAINT fk_crm_ml_lead FOREIGN KEY (lead_id) REFERENCES crm_leads (id) ON DELETE CASCADE,
CONSTRAINT fk_crm_ml_opp FOREIGN KEY (opportunity_id) REFERENCES crm_opportunities (id) ON DELETE CASCADE,
CONSTRAINT fk_crm_ml_user FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*
* Kontakt-Vorschlaege aus Mails. Bewusst NICHT direkt als Kontakt angelegt:
* das Altsystem hat dafuer Platzhalter-Adressen wie name@kontakt-aus-email.local
* erfunden und damit die Kontaktliste vermuellt. Hier entsteht ein Kontakt erst
* bei der Uebernahme - automatisch nur, wenn eine echte E-Mail-Adresse vorliegt.
*/
CREATE TABLE IF NOT EXISTS crm_mail_contact_candidates (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
message_id INT UNSIGNED NOT NULL,
dedupe_hash CHAR(64) NOT NULL, -- aus E-Mail bzw. Name+Telefon
first_name VARCHAR(120) NULL,
last_name VARCHAR(120) NULL,
salutation VARCHAR(32) NULL,
email VARCHAR(190) NULL,
phone VARCHAR(64) NULL,
mobile VARCHAR(64) NULL,
company VARCHAR(190) NULL,
position VARCHAR(190) NULL,
street VARCHAR(190) NULL,
zip VARCHAR(24) NULL,
city VARCHAR(120) NULL,
website VARCHAR(255) NULL,
linkedin VARCHAR(255) NULL,
origin ENUM('absender','empfaenger','signatur','text') NOT NULL DEFAULT 'text',
confidence TINYINT UNSIGNED NOT NULL DEFAULT 50, -- 0-100
status ENUM('offen','uebernommen','verworfen','dublette') NOT NULL DEFAULT 'offen',
lead_id INT UNSIGNED NULL, -- gesetzt bei uebernommen und dublette
decided_by INT UNSIGNED NULL,
decided_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_crm_mail_cand (message_id, dedupe_hash),
KEY idx_crm_mail_cand_status (status),
KEY idx_crm_mail_cand_email (email),
CONSTRAINT fk_crm_mc_message FOREIGN KEY (message_id) REFERENCES crm_mail_messages (id) ON DELETE CASCADE,
CONSTRAINT fk_crm_mc_lead FOREIGN KEY (lead_id) REFERENCES crm_leads (id) ON DELETE SET NULL,
CONSTRAINT fk_crm_mc_user FOREIGN KEY (decided_by) REFERENCES crm_users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*
* Zustand der Delta-Synchronisation - genau eine Zeile. `delta_link` ist die
* URL, die Graph fuer den naechsten Aufruf zurueckgegeben hat. `has_more`
* unterscheidet nextLink (Seite folgt noch) von deltaLink (aktueller Stand).
* Damit ueberlebt der Abgleich Neustarts und braucht keinen Schreibzugriff auf
* das Postfach - anders als der isRead-Ansatz des Altsystems.
*/
CREATE TABLE IF NOT EXISTS crm_mail_sync_state (
id TINYINT UNSIGNED NOT NULL DEFAULT 1,
mailbox VARCHAR(190) NULL,
delta_link TEXT NULL,
has_more TINYINT(1) NOT NULL DEFAULT 0,
last_run_at DATETIME NULL,
last_success_at DATETIME NULL,
last_error TEXT NULL,
last_new_messages INT UNSIGNED NOT NULL DEFAULT 0,
last_candidates INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO crm_mail_sync_state (id) VALUES (1);
+13
View File
@@ -9,10 +9,12 @@ import connectionRoutes from './routes/connections.js';
import eventRoutes from './routes/events.js';
import opportunityRoutes from './routes/opportunities.js';
import scannerRoutes from './routes/scanner.js';
import mailRoutes from './routes/mail.js';
import { requireAuth } from './middleware/auth.js';
import { initScannerDatabase } from './scanner/models/index.js';
import { startScheduler } from './scanner/services/scheduler.js';
import { SCANNER_KEY_CONFIGURED } from './scanner/utils/crypto.js';
import { startMailScheduler } from './mail/scheduler.js';
const app = express();
const PORT = Number(process.env.PORT || 5006);
@@ -50,6 +52,10 @@ app.use('/api/opportunities', opportunityRoutes);
// HTTP-Basic-Auth-Zugang des Scanners entfaellt damit.
app.use('/api/scanner', requireAuth, scannerRoutes);
// Postfachanbindung crm@perinco.de. Lesen darf jeder Angemeldete, konfigurieren
// und anstossen nur Administratoren - das regeln die Routen selbst.
app.use('/api/mail', requireAuth, mailRoutes);
// Das Diktat laeuft komplett im Browser (Whisper ueber transformers.js) - das
// Backend ist daran nicht beteiligt und sieht keine Audiodaten.
@@ -110,6 +116,13 @@ try {
console.log('[Scanner] Scheduler deaktiviert (SCANNER_SCHEDULER=false).');
}
/*
* Postfachabgleich (Microsoft Graph). Fehlen die Zugangsdaten in der .env,
* meldet der Scheduler das und bleibt aus - die Oberflaeche laeuft weiter und
* zeigt in den Einstellungen, welche Werte fehlen.
*/
await startMailScheduler();
app.listen(PORT, HOST, () => {
console.log(`[perinco CRM] Backend laeuft auf http://${HOST}:${PORT}`);
});
+62
View File
@@ -0,0 +1,62 @@
import { pool } from './db.js';
/*
* Allgemeine CRM-Einstellungen (Tabelle crm_settings) als Schluessel/Wert.
*
* Absichtlich getrennt von den Scanner-Einstellungen: der Scanner hat seine
* eigene Datenbank und sein eigenes Setting-Modell. Und absichtlich ohne
* Geheimnisse - Zugangsdaten fuer Microsoft 365 stehen ausschliesslich in der
* .env, damit ein Datenbank-Dump keine Secrets enthaelt.
*
* Alle Werte liegen als Text in der Datenbank, auch Zahlen und Schalter. Die
* Helfer unten wandeln beim Lesen.
*/
/** Einzelnen Wert lesen; `fallback`, wenn der Schluessel fehlt. */
export async function getSetting(key, fallback = null) {
const [rows] = await pool.query('SELECT value FROM crm_settings WHERE key_name = ? LIMIT 1', [key]);
return rows[0] ? rows[0].value : fallback;
}
/**
* Mehrere Werte auf einmal, aufgefuellt mit den Vorgaben. `defaults` ist
* gleichzeitig die Liste der erlaubten Schluessel.
*/
export async function getSettings(defaults) {
const keys = Object.keys(defaults);
if (keys.length === 0) return {};
const [rows] = await pool.query(
`SELECT key_name, value FROM crm_settings WHERE key_name IN (${keys.map(() => '?').join(', ')})`,
keys
);
const result = { ...defaults };
for (const row of rows) {
// Ein leerer Eintrag soll nicht die Vorgabe ueberschreiben.
if (row.value !== null && row.value !== '') result[row.key_name] = row.value;
}
return result;
}
/** Wert setzen (anlegen oder ueberschreiben). */
export async function setSetting(key, value) {
await pool.query(
`INSERT INTO crm_settings (key_name, value) VALUES (?, ?)
ON DUPLICATE KEY UPDATE value = VALUES(value)`,
[key, value === null || value === undefined ? null : String(value)]
);
}
/** `'true'` gilt als an, alles andere als aus. */
export function asBool(value, fallback = false) {
if (value === undefined || value === null || value === '') return fallback;
return String(value) === 'true' || String(value) === '1';
}
/** Zahl mit Grenzen; ausserhalb oder unlesbar heisst Vorgabe. */
export function asInt(value, fallback, { min = -Infinity, max = Infinity } = {}) {
const parsed = Number.parseInt(String(value ?? ''), 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
+339
View File
@@ -0,0 +1,339 @@
/*
* Pruefungen fuer die Postfachanbindung: `npm test` im Verzeichnis server/.
*
* Absichtlich ohne Test-Framework - reines Node, kein zusaetzliches Paket. Es
* geht um zwei Teile, die unbeaufsichtigt laufen und sich von Hand kaum pruefen
* lassen:
*
* 1. extract.js - was aus einer Mail als Kontakt herausfaellt. Fehler hier
* landen still in der Kontaktliste.
* 2. graph.js - Token, Wiederholungen, Fehlerbehandlung. Der echte Dienst
* liefert 429 und 401 erst dann, wenn es unpassend ist.
*
* Alles, was eine Datenbank braucht (store.js, contacts.js, sync.js), ist hier
* nicht abgedeckt.
*/
process.env.MS_TENANT_ID ||= 'test-tenant';
process.env.MS_CLIENT_ID ||= 'test-client';
process.env.MS_CLIENT_SECRET ||= 'test-secret';
process.env.MS_MAILBOX ||= 'crm@perinco.de';
const { htmlToText, sanitizeHtml } = await import('../src/mail/html.js');
const { extractCandidates, parsePersonName, parseSignature, isFunctionalAddress } = await import(
'../src/mail/extract.js'
);
const { accessToken, graphRequest, resetTokenCache, testConnection, GraphError, mailboxPath } =
await import('../src/mail/graph.js');
let failures = 0;
let currentGroup = '';
function group(name) {
currentGroup = name;
console.log(`\n${name}`);
}
function check(name, condition, detail = '') {
if (condition) {
console.log(` ok ${name}${detail ? ` - ${detail}` : ''}`);
} else {
console.log(` FAIL ${name}${detail ? ` - ${detail}` : ''}`);
failures += 1;
}
}
const OPTIONS = {
internalDomains: ['perinco.de', 'perinco.onmicrosoft.com'],
mailbox: 'crm@perinco.de',
};
const byEmail = (candidates, email) => candidates.find((entry) => entry.email === email);
const byName = (candidates, name) =>
candidates.find((entry) => [entry.firstName, entry.lastName].filter(Boolean).join(' ') === name);
// ---------------------------------------------------------------------------
group('HTML in Text');
const outlookHtml = `<html><head><style>p{margin:0}</style></head><body>
<div>Hallo Herr Wolf,</div><div><br></div>
<div>vielen Dank f&uuml;r das Gespr&auml;ch.</div><div><br></div>
<div>Mit freundlichen Gr&uuml;&szlig;en</div>
<table><tr><td>Dr. Michael Wagner<br>Head of IT Sourcing<br>
Beispiel Consulting GmbH<br>Musterstra&szlig;e 12a<br>60311 Frankfurt am Main<br>
Tel.: +49 69 1234-567<br>Mobil: +49 171 9876543<br>
michael.wagner@beispiel-consulting.de<br>
<a href="https://www.linkedin.com/in/mwagner">LinkedIn</a></td></tr></table>
</body></html>`;
const text = htmlToText(outlookHtml);
check('Zeilenumbrueche bleiben erhalten', text.split('\n').length > 5, `${text.split('\n').length} Zeilen`);
check('Style-Block ist entfernt', !text.includes('margin:0'));
check('Entitaeten sind aufgeloest', text.includes('Grüßen') && text.includes('Musterstraße'));
check(
'Linkziel steht hinter dem Linktext',
text.includes('LinkedIn (https://www.linkedin.com/in/mwagner)')
);
check('Keine drei Leerzeilen in Folge', !/\n\s*\n\s*\n/.test(text));
const dirty = `<div onclick="steal()">Text</div><script>alert(1)</script>
<iframe src="https://evil.example"></iframe><a href="javascript:alert(1)">Klick</a>`;
const clean = sanitizeHtml(dirty);
check('Skript ist entfernt', !clean.includes('<script') && !clean.includes('alert(1)</script'));
check('Rahmen ist entfernt', !clean.toLowerCase().includes('<iframe'));
check('Ereignis-Attribut ist entfernt', !clean.includes('onclick'));
check('javascript:-Adresse ist entfernt', !clean.includes('javascript:'));
check('Nutzinhalt bleibt', clean.includes('Text'));
// ---------------------------------------------------------------------------
group('Namen zerlegen');
const nameCases = [
['Dr. Michael Wagner', 'm.wagner@x.de', 'Michael', 'Wagner'],
['Wolf, Sascha', null, 'Sascha', 'Wolf'],
['Sascha van der Berg', null, 'Sascha', 'van der Berg'],
['Prof. Dr. Klaus Meier, MBA', null, 'Klaus', 'Meier'],
['Wagner, Michael (Beispiel Consulting)', null, 'Michael', 'Wagner'],
[null, 'anna.mueller@x.de', 'Anna', 'Mueller'],
['sascha.wolf@perinco.de', 'sascha.wolf@perinco.de', 'Sascha', 'Wolf'],
];
for (const [display, email, expectedFirst, expectedLast] of nameCases) {
const parsed = parsePersonName(display, email);
check(
`${display ?? '(kein Anzeigename)'} + ${email ?? '(keine Adresse)'}`,
parsed.firstName === expectedFirst && parsed.lastName === expectedLast,
`${parsed.firstName} / ${parsed.lastName}`
);
}
// Umlaut aus dem Anzeigenamen schlaegt die Umschrift aus der Adresse.
const umlaut = parsePersonName('Müller', 'k.mueller@x.de');
check('Umlaut des Anzeigenamens gewinnt', umlaut.lastName === 'Müller', umlaut.lastName ?? '-');
check('Anrede wird erkannt', parsePersonName('Herr Klaus Meier').salutation === 'Herr');
check('Funktionsadresse wird erkannt', isFunctionalAddress('info@x.de') && isFunctionalAddress('no-reply@x.de'));
check('Personenadresse gilt nicht als Funktionsadresse', !isFunctionalAddress('anna.mueller@x.de'));
// ---------------------------------------------------------------------------
group('Signatur lesen');
const signature = parseSignature(text);
check('Festnetz trotz "Tel.:"', signature.phone === '+49 69 1234-567', signature.phone ?? '-');
check('Mobil getrennt erkannt', signature.mobile === '+49 171 9876543', signature.mobile ?? '-');
check('Firma an der Rechtsform', signature.company === 'Beispiel Consulting GmbH', signature.company ?? '-');
check('Position erkannt', signature.position === 'Head of IT Sourcing', signature.position ?? '-');
check('Strasse erkannt', signature.street === 'Musterstraße 12a', signature.street ?? '-');
check('Ort mit Zusatz', signature.city === 'Frankfurt am Main', signature.city ?? '-');
check('PLZ erkannt', signature.zip === '60311', signature.zip ?? '-');
check('LinkedIn erkannt', signature.linkedin === 'https://www.linkedin.com/in/mwagner', signature.linkedin ?? '-');
// ---------------------------------------------------------------------------
group('Kontakte aus einer Mail');
const external = extractCandidates(
{
fromName: 'Wagner, Michael (Beispiel Consulting)',
fromEmail: 'Michael.Wagner@beispiel-consulting.de',
to: [{ name: 'Sascha Wolf', email: 'sascha.wolf@perinco.de' }],
cc: [{ name: null, email: 'crm@perinco.de' }],
bcc: [],
bodyText: text,
},
OPTIONS
);
check('genau ein Vorschlag', external.length === 1, `${external.length}`);
const wagner = byEmail(external, 'michael.wagner@beispiel-consulting.de');
check('Absender gefunden', Boolean(wagner));
check('Signatur bestaetigt den Absender', wagner?.confidence === 95, String(wagner?.confidence));
check('Firma uebernommen', wagner?.company === 'Beispiel Consulting GmbH');
check('Eigene Domain wird uebersprungen', !byEmail(external, 'sascha.wolf@perinco.de'));
check('Postfach selbst wird uebersprungen', !byEmail(external, 'crm@perinco.de'));
// Interne Mail mit dem CRM auf Cc: die externen Empfaenger sind die Kontakte.
const carbonCopy = extractCandidates(
{
fromName: 'Sascha Wolf',
fromEmail: 'sascha.wolf@perinco.de',
to: [
{ name: 'Anna Müller', email: 'a.mueller@kunde-ag.de' },
{ name: null, email: 'thomas.schmidt@kunde-ag.de' },
],
cc: [
{ name: 'CRM', email: 'crm@perinco.de' },
{ name: 'Info', email: 'info@kunde-ag.de' },
],
bcc: [],
bodyText: 'Hallo zusammen,\n\nanbei das Angebot.\n\nViele Grüße\nSascha Wolf\nCEO\nperinco GmbH',
},
OPTIONS
);
check('beide externen Empfaenger', carbonCopy.length === 2, `${carbonCopy.length}`);
check('Anzeigename genutzt', byEmail(carbonCopy, 'a.mueller@kunde-ag.de')?.lastName === 'Müller');
check('Name aus der Adresse abgeleitet', byEmail(carbonCopy, 'thomas.schmidt@kunde-ag.de')?.firstName === 'Thomas');
check('Funktionsadresse verworfen', !byEmail(carbonCopy, 'info@kunde-ag.de'));
check(
'Empfaenger duerfen automatisch uebernommen werden',
carbonCopy.every((entry) => 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);
+15 -2
View File
@@ -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<SettingsTab | undefined>(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<number | null>(null);
const [leads, setLeads] = useState<Lead[]>([]);
const [connections, setConnections] = useState<LeadConnection[]>([]);
@@ -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<Record<ViewState, string>> = {
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' && (
<Mails leads={leads} opportunities={opportunities} reload={() => loadData(true)} />
)}
{currentView === 'Scanner' && <ScannerDashboard />}
{currentView === 'Unpassend' && <ScannerRejected />}
{currentView === 'Suchagenten' && <ScannerAgents />}
+4
View File
@@ -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',
},
];
/**
+64 -3
View File
@@ -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<LeadsProps> = ({ leads, connections, users, isLoading, reload }) => {
export const Leads: React.FC<LeadsProps> = ({
leads,
connections,
users,
isLoading,
reload,
focusLeadId = null,
onFocusHandled,
}) => {
const { addToast } = useToast();
const [viewMode, setViewMode] = useState<LeadViewMode>('grid');
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<LeadStatus | 'Alle'>('Alle');
const [originFilter, setOriginFilter] = useState<LeadOriginFilter>('Alle');
const [sortConfig, setSortConfig] = useState<SortConfig>({ key: 'updated_at', direction: 'desc' });
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingLead, setEditingLead] = useState<Lead | null>(null);
const [leadToDelete, setLeadToDelete] = useState<Lead | null>(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<number, number>();
@@ -71,6 +109,8 @@ export const Leads: React.FC<LeadsProps> = ({ 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<LeadsProps> = ({ 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<LeadsProps> = ({ leads, connections, users, isLoadi
))}
</select>
{mailLeadCount > 0 && (
<select
value={originFilter}
onChange={(event) => setOriginFilter(event.target.value as LeadOriginFilter)}
title="Kontakte, die aus dem Postfach crm@perinco.de übernommen wurden"
className="rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500"
>
{ORIGIN_FILTERS.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.id === 'Mail' ? `${entry.label} (${mailLeadCount})` : entry.label}
</option>
))}
</select>
)}
<div className="flex items-center gap-3 lg:ml-auto">
<div className="flex rounded-xl border border-ink-200 bg-surface p-1">
{VIEW_BUTTONS.map((button) => (
+163
View File
@@ -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<CandidateRowProps> = ({
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 (
<li
className={`rounded-xl border px-4 py-3 transition ${
isOpen ? 'border-ink-200 bg-surface' : 'border-ink-100 bg-ink-50/60'
} ${isBusy ? 'pointer-events-none opacity-60' : ''}`}
>
<div className="flex flex-wrap items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-bold text-ink-900">{candidateName(candidate)}</p>
<Badge className={ORIGIN_STYLES[candidate.origin]}>{ORIGIN_LABELS[candidate.origin]}</Badge>
{!isOpen && (
<Badge className={CANDIDATE_STATUS_STYLES[candidate.status]}>
{CANDIDATE_STATUS_LABELS[candidate.status]}
</Badge>
)}
{willMerge && (
<Badge
className="border-sky-200 bg-sky-50 text-sky-700"
title="Diese Adresse ist bereits als Kontakt vorhanden. Beim Übernehmen werden nur leere Felder ergänzt."
>
Bereits vorhanden
</Badge>
)}
</div>
<p className="mt-0.5 truncate text-xs font-semibold text-ink-500">
{candidate.email || 'Keine E-Mail-Adresse'}
</p>
{details.length > 0 && (
<p className="mt-0.5 truncate text-xs text-ink-400" title={details.join(' · ')}>
{details.join(' · ')}
</p>
)}
{showMessage && (
<button
onClick={() => onOpenMessage?.(candidate.message_id)}
className="mt-1.5 max-w-full truncate text-left text-xs font-bold text-brand-600 transition hover:text-brand-700"
title={candidate.message_subject ?? undefined}
>
aus: {candidate.message_subject || 'Ohne Betreff'}
</button>
)}
{!isOpen && candidate.lead_id && onOpenLead && (
<button
onClick={() => onOpenLead(candidate.lead_id as number)}
className="mt-1.5 text-xs font-bold text-brand-600 transition hover:text-brand-700"
>
Kontakt {candidate.lead_name ? `„${candidate.lead_name}"` : ''} öffnen
</button>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<ConfidenceBar value={candidate.confidence} />
{isOpen && (
<div className="flex gap-2">
<button
onClick={reject}
className="rounded-lg border border-ink-200 px-3 py-1.5 text-xs font-bold text-ink-600 transition hover:bg-ink-100"
>
Verwerfen
</button>
<button
onClick={adopt}
className="rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-bold text-white transition hover:bg-brand-600"
>
{willMerge ? 'Ergänzen' : 'Als Kontakt anlegen'}
</button>
</div>
)}
</div>
</div>
</li>
);
};
+125
View File
@@ -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<MailBodyProps> = ({ 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 && /<img[^>]+src\s*=\s*["']?https?:/i.test(html ?? '');
if (!hasHtml || showSource) {
return (
<div className="space-y-3">
{hasHtml && (
<button
onClick={() => setShowSource(false)}
className="text-xs font-bold text-brand-600 transition hover:text-brand-700"
>
← Formatierte Ansicht
</button>
)}
<pre className="scrollbar-slim max-h-[60vh] overflow-auto whitespace-pre-wrap break-words rounded-xl border border-ink-200 bg-surface p-4 text-sm leading-relaxed text-ink-800">
{text || 'Kein Text vorhanden.'}
</pre>
</div>
);
}
// 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 = `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; ${imagePolicy}; style-src 'unsafe-inline'; font-src data:">
<style>${FRAME_STYLE}</style></head><body>${html}</body></html>`;
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3">
{hasRemoteImages && (
<button
onClick={() => setShowImages((value) => !value)}
className="rounded-lg border border-ink-200 px-3 py-1.5 text-xs font-bold text-ink-600 transition hover:bg-ink-100"
>
{showImages ? 'Externe Bilder wieder sperren' : 'Externe Bilder laden'}
</button>
)}
<button
onClick={() => setExpanded((value) => !value)}
className="rounded-lg border border-ink-200 px-3 py-1.5 text-xs font-bold text-ink-600 transition hover:bg-ink-100"
>
{expanded ? 'Kleiner' : 'Größer'}
</button>
<button
onClick={() => setShowSource(true)}
className="text-xs font-bold text-ink-400 transition hover:text-ink-700"
>
Nur Text anzeigen
</button>
{hasRemoteImages && !showImages && (
<span className="text-xs text-ink-400">
Externe Bilder gesperrt – sie würden dem Absender das Öffnen melden.
</span>
)}
</div>
<iframe
title="Mailinhalt"
sandbox=""
srcDoc={srcDocument}
className={`w-full rounded-xl border border-ink-200 bg-white ${expanded ? 'h-[78vh]' : 'h-[46vh]'}`}
/>
</div>
);
};
+220
View File
@@ -0,0 +1,220 @@
import React from 'react';
import { MailBody } from './MailBody';
import { CandidateRow } from './CandidateRow';
import {
Badge,
avatarColor,
formatAddresses,
initialsFromMail,
} from './mailUi';
import { useToast } from '../Toast';
import { mailApi, formatMailDateTime, type MailCandidate, type MailMessageDetail } from '../../lib/mailApi';
/*
* Eine Mail im Detail: Kopfdaten, Zuordnungen, Kontakt-Vorschlaege, Inhalt.
*
* Die Zuordnungen stehen oben und nicht unten - sie sind der Grund, warum die
* Mail im CRM liegt. Der Mailtext ist beim Lesen zwar das Groesste, aber selten
* das Wichtigste.
*/
interface MailDetailProps {
message: MailMessageDetail;
isLoading: boolean;
onLink: () => void;
onReload: () => void;
onOpenLead: (leadId: number) => void;
}
const AddressRow: React.FC<{ label: string; value: string }> = ({ label, value }) => (
<div className="flex gap-2 text-xs">
<span className="w-12 shrink-0 font-extrabold uppercase tracking-wider text-ink-400">{label}</span>
<span className="min-w-0 break-words font-semibold text-ink-700">{value}</span>
</div>
);
export const MailDetail: React.FC<MailDetailProps> = ({
message,
isLoading,
onLink,
onReload,
onOpenLead,
}) => {
const { addToast } = useToast();
const unlink = async (linkId: number) => {
try {
await mailApi.unlink(linkId);
addToast('Zuordnung entfernt.', 'success');
onReload();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Entfernen fehlgeschlagen.', 'error');
}
};
const openCandidates = message.candidates.filter((candidate) => candidate.status === 'offen');
const decidedCandidates = message.candidates.filter((candidate) => candidate.status !== 'offen');
return (
<div className={`space-y-5 ${isLoading ? 'opacity-60' : ''}`}>
{/* Kopf */}
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
<div className="flex items-start gap-4">
<div
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-sm font-extrabold ${avatarColor(
message.from_email
)}`}
>
{initialsFromMail(message.from_name, message.from_email)}
</div>
<div className="min-w-0 flex-1">
<h2 className="break-words text-lg leading-snug text-ink-900">
{message.subject || 'Ohne Betreff'}
</h2>
<p className="mt-1 break-words text-sm font-semibold text-ink-600">
{message.from_name || message.from_email || 'Unbekannter Absender'}
{message.from_name && message.from_email && (
<span className="font-normal text-ink-400"> · {message.from_email}</span>
)}
</p>
<p className="mt-0.5 text-xs text-ink-400">{formatMailDateTime(message.received_at)}</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<button
onClick={onLink}
className="whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2 text-sm font-bold text-white shadow-card transition hover:bg-brand-600"
>
Zuordnen
</button>
{message.web_link && (
<a
href={message.web_link}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-bold text-ink-400 transition hover:text-brand-600"
>
In Outlook öffnen ↗
</a>
)}
</div>
</div>
<div className="mt-4 space-y-1 border-t border-ink-100 pt-4">
<AddressRow label="An" value={formatAddresses(message.to, 6)} />
{message.cc.length > 0 && <AddressRow label="Cc" value={formatAddresses(message.cc, 6)} />}
{message.bcc.length > 0 && <AddressRow label="Bcc" value={formatAddresses(message.bcc, 6)} />}
</div>
{(message.has_attachments || message.is_deleted || message.importance === 'high') && (
<div className="mt-3 flex flex-wrap gap-2">
{message.has_attachments && (
<Badge className="border-ink-200 bg-ink-100 text-ink-600" title="Anhänge werden in Phase 1 nicht übernommen">
Anhänge vorhanden
</Badge>
)}
{message.importance === 'high' && (
<Badge className="border-red-200 bg-red-50 text-red-700">Wichtig</Badge>
)}
{message.is_deleted && (
<Badge
className="border-amber-200 bg-amber-50 text-amber-700"
title="Die Mail wurde im Postfach gelöscht. Im CRM bleibt sie erhalten, damit Zuordnungen nicht verloren gehen."
>
Im Postfach gelöscht
</Badge>
)}
</div>
)}
</section>
{/* Zuordnungen */}
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base text-ink-900">Zuordnungen</h3>
<span className="text-xs font-bold text-ink-400">{message.links.length}</span>
</div>
{message.links.length === 0 ? (
<p className="mt-3 text-sm text-ink-400">
Noch nicht zugeordnet. Über „Zuordnen" hängt die Mail an einer Opportunity oder einem Kontakt.
</p>
) : (
<ul className="mt-3 space-y-2">
{message.links.map((link) => (
<li
key={link.id}
className="flex items-center gap-3 rounded-xl border border-ink-200 px-4 py-2.5"
>
<span className="shrink-0 text-base" aria-hidden>
{link.opportunity_id ? '◆' : '👤'}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-ink-800">
{link.opportunity_id ? link.opportunity_title : link.lead_name}
</p>
<p className="truncate text-xs text-ink-400">
{link.opportunity_id
? `Opportunity · ${link.opportunity_stage}`
: `Kontakt${link.lead_company ? ` · ${link.lead_company}` : ''}`}
{link.note ? ` · ${link.note}` : ''}
{link.origin === 'automatisch' ? ' · automatisch' : ''}
</p>
</div>
{link.lead_id && (
<button
onClick={() => onOpenLead(link.lead_id as number)}
className="shrink-0 rounded-lg px-2 py-1 text-xs font-bold text-brand-600 transition hover:bg-brand-50"
>
Kontakt öffnen
</button>
)}
<button
onClick={() => unlink(link.id)}
title="Zuordnung entfernen"
className="shrink-0 rounded-lg p-1.5 text-ink-400 transition hover:bg-red-50 hover:text-red-600"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</li>
))}
</ul>
)}
</section>
{/* Kontakt-Vorschlaege */}
{message.candidates.length > 0 && (
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base text-ink-900">Kontakte aus dieser Mail</h3>
{openCandidates.length > 0 && (
<Badge className="border-amber-200 bg-amber-50 text-amber-700">
{openCandidates.length} offen
</Badge>
)}
</div>
<ul className="mt-3 space-y-2">
{[...openCandidates, ...decidedCandidates].map((candidate: MailCandidate) => (
<CandidateRow
key={candidate.id}
candidate={candidate}
onChanged={onReload}
onOpenLead={onOpenLead}
/>
))}
</ul>
</section>
)}
{/* Inhalt */}
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
<h3 className="mb-3 text-base text-ink-900">Inhalt</h3>
<MailBody html={message.body_html} text={message.body_text} />
</section>
</div>
);
};
+240
View File
@@ -0,0 +1,240 @@
import React, { useMemo, useState } from 'react';
import { Modal } from '../ui/Modal';
import { inputClass, labelClass } from '../leads/leadUi';
import { useToast } from '../Toast';
import { mailApi, type MailMessageDetail } from '../../lib/mailApi';
import { leadFullName, type Lead, type Opportunity } from '../../types';
/*
* Mail an eine Opportunity und/oder einen Kontakt haengen.
*
* Der Vorschlag oben ist der eigentliche Nutzen: aus der Absenderadresse laesst
* sich meist direkt der passende Kontakt und daraus die Opportunity ableiten. Im
* Altsystem musste dafuer ein Sprachmodell die komplette Mail bewerten - fuer die
* Zuordnung per Hand genuegt ein Adressvergleich.
*/
interface MailLinkDialogProps {
message: MailMessageDetail;
leads: Lead[];
opportunities: Opportunity[];
onClose: () => void;
onLinked: () => void;
}
export const MailLinkDialog: React.FC<MailLinkDialogProps> = ({
message,
leads,
opportunities,
onClose,
onLinked,
}) => {
const { addToast } = useToast();
const [leadId, setLeadId] = useState<number | ''>('');
const [opportunityId, setOpportunityId] = useState<number | ''>('');
const [note, setNote] = useState('');
const [isSaving, setIsSaving] = useState(false);
const linkedLeadIds = new Set(message.links.map((link) => link.lead_id).filter(Boolean) as number[]);
const linkedOpportunityIds = new Set(
message.links.map((link) => link.opportunity_id).filter(Boolean) as number[]
);
/**
* Alle an der Mail beteiligten Adressen - Absender und Empfaenger. Damit wird
* auch der Fall getroffen, dass eine interne Mail das CRM auf Cc gesetzt hat.
*/
const involvedEmails = useMemo(() => {
const all = [
message.from_email,
...message.to.map((entry) => entry.email),
...message.cc.map((entry) => entry.email),
...message.bcc.map((entry) => entry.email),
];
return new Set(all.filter(Boolean).map((entry) => String(entry).toLowerCase()));
}, [message]);
/** Kontakte, deren Adresse in der Mail vorkommt. */
const suggestedLeads = useMemo(
() =>
leads.filter(
(lead) => lead.email && involvedEmails.has(lead.email.toLowerCase()) && !linkedLeadIds.has(lead.id)
),
[leads, involvedEmails, linkedLeadIds]
);
/**
* Opportunities, die an einem dieser Kontakte haengen - plus solche, deren
* Firma zur Absenderdomain passt.
*/
const suggestedOpportunities = useMemo(() => {
const suggestedLeadIds = new Set(suggestedLeads.map((lead) => lead.id));
const domain = message.from_email?.split('@')[1]?.toLowerCase() ?? null;
const companies = new Set(
leads
.filter((lead) => suggestedLeadIds.has(lead.id) && lead.company)
.map((lead) => String(lead.company).toLowerCase())
);
return opportunities.filter((opportunity) => {
if (linkedOpportunityIds.has(opportunity.id)) return false;
if (opportunity.contacts.some((contact) => suggestedLeadIds.has(contact.lead_id))) return true;
if (opportunity.company && companies.has(opportunity.company.toLowerCase())) return true;
// Grober Domainabgleich: "kunde-ag.de" gegen "Kunde AG".
if (domain && opportunity.company) {
const stem = domain.split('.')[0].replace(/[^a-z0-9]/g, '');
const company = opportunity.company.toLowerCase().replace(/[^a-z0-9]/g, '');
if (stem.length >= 4 && company.includes(stem)) return true;
}
return false;
});
}, [opportunities, suggestedLeads, leads, linkedOpportunityIds, message.from_email]);
const sortedLeads = useMemo(
() => [...leads].sort((a, b) => leadFullName(a).localeCompare(leadFullName(b), 'de')),
[leads]
);
const sortedOpportunities = useMemo(
() => [...opportunities].sort((a, b) => a.title.localeCompare(b.title, 'de')),
[opportunities]
);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
if (leadId === '' && opportunityId === '') {
addToast('Kontakt oder Opportunity auswaehlen.', 'error');
return;
}
setIsSaving(true);
try {
await mailApi.link(message.id, {
lead_id: leadId === '' ? null : leadId,
opportunity_id: opportunityId === '' ? null : opportunityId,
note: note.trim() || undefined,
});
addToast('Mail verknüpft.', 'success');
onLinked();
onClose();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Verknüpfen fehlgeschlagen.', 'error');
} finally {
setIsSaving(false);
}
};
return (
<Modal
title="Mail zuordnen"
subtitle={message.subject ?? 'Ohne Betreff'}
onClose={onClose}
footer={
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="rounded-xl border border-ink-200 px-5 py-2.5 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
>
Abbrechen
</button>
<button
type="submit"
form="mail-link-form"
disabled={isSaving}
className="rounded-xl bg-brand-500 px-6 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-60"
>
{isSaving ? 'Verknüpfe...' : 'Verknüpfen'}
</button>
</div>
}
>
<form id="mail-link-form" onSubmit={submit} className="space-y-5">
{(suggestedLeads.length > 0 || suggestedOpportunities.length > 0) && (
<section className="rounded-xl border border-brand-200 bg-brand-50/60 p-4">
<p className="text-[11px] font-extrabold uppercase tracking-wider text-brand-700">
Vorschläge anhand der Mailadressen
</p>
<div className="mt-3 flex flex-wrap gap-2">
{suggestedLeads.map((lead) => (
<button
key={`lead-${lead.id}`}
type="button"
onClick={() => setLeadId(lead.id)}
className={`rounded-lg border px-3 py-1.5 text-xs font-bold transition ${
leadId === lead.id
? 'border-brand-500 bg-brand-500 text-white'
: 'border-brand-200 bg-surface text-brand-700 hover:bg-brand-100'
}`}
>
{leadFullName(lead)}
{lead.company ? ` · ${lead.company}` : ''}
</button>
))}
{suggestedOpportunities.map((opportunity) => (
<button
key={`opp-${opportunity.id}`}
type="button"
onClick={() => setOpportunityId(opportunity.id)}
className={`rounded-lg border px-3 py-1.5 text-xs font-bold transition ${
opportunityId === opportunity.id
? 'border-brand-500 bg-brand-500 text-white'
: 'border-brand-200 bg-surface text-brand-700 hover:bg-brand-100'
}`}
>
◆ {opportunity.title}
</button>
))}
</div>
</section>
)}
<div>
<label className={labelClass}>Opportunity</label>
<select
value={opportunityId}
onChange={(event) => setOpportunityId(event.target.value === '' ? '' : Number(event.target.value))}
className={inputClass}
>
<option value="">— keine —</option>
{sortedOpportunities.map((opportunity) => (
<option key={opportunity.id} value={opportunity.id} disabled={linkedOpportunityIds.has(opportunity.id)}>
{opportunity.title}
{opportunity.company ? ` · ${opportunity.company}` : ''}
{linkedOpportunityIds.has(opportunity.id) ? ' (bereits verknüpft)' : ''}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Kontakt</label>
<select
value={leadId}
onChange={(event) => setLeadId(event.target.value === '' ? '' : Number(event.target.value))}
className={inputClass}
>
<option value="">— keiner —</option>
{sortedLeads.map((lead) => (
<option key={lead.id} value={lead.id} disabled={linkedLeadIds.has(lead.id)}>
{leadFullName(lead)}
{lead.company ? ` · ${lead.company}` : ''}
{linkedLeadIds.has(lead.id) ? ' (bereits verknüpft)' : ''}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Notiz (optional)</label>
<input
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="z. B. Angebot nachgefragt"
maxLength={500}
className={inputClass}
/>
</div>
</form>
</Modal>
);
};
+84
View File
@@ -0,0 +1,84 @@
import React from 'react';
import { Badge, avatarColor, formatAddresses, initialsFromMail } from './mailUi';
import { formatMailDate, formatMailDateTime, type MailMessageSummary } from '../../lib/mailApi';
interface MailListProps {
messages: MailMessageSummary[];
selectedId: number | null;
onSelect: (id: number) => void;
}
export const MailList: React.FC<MailListProps> = ({ messages, selectedId, onSelect }) => (
<ul className="divide-y divide-ink-100">
{messages.map((message) => {
const isSelected = message.id === selectedId;
return (
<li key={message.id}>
<button
onClick={() => onSelect(message.id)}
aria-current={isSelected ? 'true' : undefined}
className={`flex w-full gap-3 px-4 py-3 text-left transition ${
isSelected ? 'bg-brand-50' : 'hover:bg-ink-50'
}`}
>
<div
className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-[11px] font-extrabold ${avatarColor(
message.from_email
)}`}
>
{initialsFromMail(message.from_name, message.from_email)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<p
className={`min-w-0 flex-1 truncate text-sm ${
isSelected ? 'font-extrabold text-brand-800' : 'font-bold text-ink-800'
}`}
>
{message.from_name || message.from_email || 'Unbekannt'}
</p>
<span
className="shrink-0 text-[11px] font-bold text-ink-400"
title={formatMailDateTime(message.received_at)}
>
{formatMailDate(message.received_at)}
</span>
</div>
<p className="mt-0.5 truncate text-[13px] font-semibold text-ink-700">
{message.subject || 'Ohne Betreff'}
</p>
<p className="mt-0.5 truncate text-xs text-ink-400">
{message.body_preview || `An ${formatAddresses(message.to)}`}
</p>
{(message.link_count > 0 || message.open_candidates > 0 || message.has_attachments) && (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{message.link_count > 0 && (
<Badge className="border-brand-200 bg-brand-50 text-brand-700" title="Zuordnungen">
{message.link_count}× zugeordnet
</Badge>
)}
{message.open_candidates > 0 && (
<Badge
className="border-amber-200 bg-amber-50 text-amber-700"
title="Offene Kontakt-Vorschläge"
>
{message.open_candidates} Kontakt{message.open_candidates === 1 ? '' : 'e'}
</Badge>
)}
{message.has_attachments && (
<span className="text-xs text-ink-300" title="Anhänge vorhanden" aria-hidden>
📎
</span>
)}
</div>
)}
</div>
</button>
</li>
);
})}
</ul>
);
+497
View File
@@ -0,0 +1,497 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MailList } from './MailList';
import { MailDetail } from './MailDetail';
import { MailLinkDialog } from './MailLinkDialog';
import { CandidateRow } from './CandidateRow';
import { Badge, emptyStateClass } from './mailUi';
import { useToast } from '../Toast';
import { useAuth } from '../../lib/auth';
import { useNavigation } from '../../lib/navigation';
import {
mailApi,
formatMailDateTime,
type MailCandidate,
type MailListFilter,
type MailMessageDetail,
type MailMessageSummary,
type MailStatus,
} from '../../lib/mailApi';
import type { Lead, Opportunity } from '../../types';
/*
* Postfach crm@perinco.de im CRM.
*
* Zwei Bereiche: die Mailliste mit Detailansicht und die Sammelliste der offenen
* Kontakt-Vorschlaege. Letztere ist die eigentliche Arbeitsliste - dort wird
* entschieden, was Kontakt wird und was nicht.
*/
interface MailsProps {
leads: Lead[];
opportunities: Opportunity[];
/** Kontakte im CRM neu laden - nach Uebernahme eines Vorschlags. */
reload: () => void;
}
type Tab = 'postfach' | 'vorschlaege';
const FILTERS: { id: MailListFilter; label: string; hint: string }[] = [
{ id: 'alle', label: 'Alle', hint: 'Alle abgeglichenen Mails' },
{ id: 'unlinked', label: 'Nicht zugeordnet', hint: 'Hängen noch an keiner Opportunity und keinem Kontakt' },
{ id: 'candidates', label: 'Mit Vorschlägen', hint: 'Enthalten offene Kontakt-Vorschläge' },
{ id: 'linked', label: 'Zugeordnet', hint: 'Bereits verknüpfte Mails' },
];
const PAGE_SIZE = 50;
/** Hinweisbalken oben - erklaert Zustand und naechsten Schritt. */
const StatusBanner: React.FC<{ status: MailStatus; isAdmin: boolean; onOpenSettings: () => void }> = ({
status,
isAdmin,
onOpenSettings,
}) => {
if (!status.configured) {
return (
<div className="rounded-2xl border-l-4 border-l-amber-500 border border-ink-200 bg-amber-50/70 p-4">
<p className="text-sm font-bold text-amber-900">Postfachanbindung ist noch nicht eingerichtet.</p>
<p className="mt-1 text-xs text-amber-800">
In <code className="rounded bg-amber-100 px-1">server/.env</code> fehlt:{' '}
<strong>{status.missing.join(', ')}</strong>. Danach den Dienst neu starten.
{isAdmin && (
<>
{' '}
<button onClick={onOpenSettings} className="font-bold underline">
Zu den Einstellungen
</button>
</>
)}
</p>
</div>
);
}
if (status.last_error) {
return (
<div className="rounded-2xl border-l-4 border-l-red-500 border border-ink-200 bg-red-50/70 p-4">
<p className="text-sm font-bold text-red-900">Der letzte Abgleich ist fehlgeschlagen.</p>
<p className="mt-1 break-words text-xs text-red-800">{status.last_error}</p>
{isAdmin && (
<button onClick={onOpenSettings} className="mt-1 text-xs font-bold text-red-900 underline">
In den Einstellungen prüfen
</button>
)}
</div>
);
}
if (!status.active) {
return (
<div className="rounded-2xl border-l-4 border-l-ink-300 border border-ink-200 bg-ink-50 p-4">
<p className="text-sm font-bold text-ink-700">Der Abgleich ist pausiert.</p>
<p className="mt-1 text-xs text-ink-500">
Vorhandene Mails bleiben sichtbar, neue werden nicht geholt.
{isAdmin && (
<>
{' '}
<button onClick={onOpenSettings} className="font-bold underline">
Einstellungen
</button>
</>
)}
</p>
</div>
);
}
if (status.has_more) {
return (
<div className="rounded-2xl border-l-4 border-l-sky-500 border border-ink-200 bg-sky-50/70 p-4">
<p className="text-sm font-bold text-sky-900">Erstabgleich läuft noch.</p>
<p className="mt-1 text-xs text-sky-800">
Ältere Mails werden in mehreren Durchläufen nachgeholt – die Liste wächst also noch.
</p>
</div>
);
}
return null;
};
export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) => {
const { addToast } = useToast();
const { isAdmin } = useAuth();
const { goTo } = useNavigation();
const [tab, setTab] = useState<Tab>('postfach');
const [status, setStatus] = useState<MailStatus | null>(null);
const [messages, setMessages] = useState<MailMessageSummary[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [filter, setFilter] = useState<MailListFilter>('alle');
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [selectedId, setSelectedId] = useState<number | null>(null);
const [detail, setDetail] = useState<MailMessageDetail | null>(null);
const [isDetailLoading, setIsDetailLoading] = useState(false);
const [isListLoading, setIsListLoading] = useState(true);
const [isLinkOpen, setIsLinkOpen] = useState(false);
const [candidates, setCandidates] = useState<MailCandidate[]>([]);
const [isCandidatesLoading, setIsCandidatesLoading] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
// Die Suche soll nicht bei jedem Tastendruck eine Abfrage aufloesen.
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search.trim()), 350);
return () => clearTimeout(timer);
}, [search]);
const loadStatus = useCallback(async () => {
try {
setStatus(await mailApi.status());
} catch (err) {
addToast(err instanceof Error ? err.message : 'Status nicht abrufbar.', 'error');
}
}, [addToast]);
const loadMessages = useCallback(
async (nextOffset = 0, silent = false) => {
if (!silent) setIsListLoading(true);
try {
const data = await mailApi.messages({
search: debouncedSearch || undefined,
filter,
limit: PAGE_SIZE,
offset: nextOffset,
});
setMessages(data.messages);
setTotal(data.total);
setOffset(nextOffset);
} catch (err) {
addToast(err instanceof Error ? err.message : 'Mails nicht abrufbar.', 'error');
} finally {
setIsListLoading(false);
}
},
[debouncedSearch, filter, addToast]
);
const loadCandidates = useCallback(async () => {
setIsCandidatesLoading(true);
try {
setCandidates(await mailApi.candidates({ status: 'offen', limit: 300 }));
} catch (err) {
addToast(err instanceof Error ? err.message : 'Vorschläge nicht abrufbar.', 'error');
} finally {
setIsCandidatesLoading(false);
}
}, [addToast]);
const loadDetail = useCallback(
async (id: number) => {
setIsDetailLoading(true);
try {
setDetail(await mailApi.message(id));
} catch (err) {
addToast(err instanceof Error ? err.message : 'Mail nicht abrufbar.', 'error');
setDetail(null);
} finally {
setIsDetailLoading(false);
}
},
[addToast]
);
useEffect(() => {
loadStatus();
loadCandidates();
// Einmal beim Oeffnen; die Callbacks sind stabil.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
loadMessages(0);
}, [loadMessages]);
useEffect(() => {
if (selectedId !== null) loadDetail(selectedId);
else setDetail(null);
}, [selectedId, loadDetail]);
// Erste Mail der Liste vorauswaehlen, solange nichts gewaehlt ist.
const didAutoSelect = useRef(false);
useEffect(() => {
if (didAutoSelect.current) return;
if (messages.length > 0 && selectedId === null) {
didAutoSelect.current = true;
setSelectedId(messages[0].id);
}
}, [messages, selectedId]);
/** Nach jeder Aenderung an Vorschlaegen oder Zuordnungen alles nachziehen. */
const refreshAll = useCallback(async () => {
await Promise.all([
loadStatus(),
loadCandidates(),
loadMessages(offset, true),
selectedId !== null ? loadDetail(selectedId) : Promise.resolve(),
]);
// Die Kontaktliste der App kennt den neuen Kontakt noch nicht.
reload();
}, [loadStatus, loadCandidates, loadMessages, loadDetail, offset, selectedId, reload]);
const runSync = async () => {
setIsSyncing(true);
try {
const result = await mailApi.sync();
addToast(
`Abgleich fertig: ${result.newMessages ?? 0} neue Mails, ${result.newCandidates ?? 0} Kontakt-Vorschläge.`,
'success'
);
await refreshAll();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Abgleich fehlgeschlagen.', 'error');
await loadStatus();
} finally {
setIsSyncing(false);
}
};
const openLead = (leadId: number) => goTo('Kontakte', { leadId });
const openCandidateCount = status?.counts.open_candidates ?? candidates.length;
const pageInfo = useMemo(() => {
if (total === 0) return 'Keine Mails';
const from = offset + 1;
const to = Math.min(offset + messages.length, total);
return `${from}–${to} von ${total}`;
}, [offset, messages.length, total]);
return (
<div className="space-y-4">
{status && (
<StatusBanner
status={status}
isAdmin={isAdmin}
onOpenSettings={() => goTo('Einstellungen', { tab: 'mail' })}
/>
)}
{/* Steuerleiste */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex rounded-xl border border-ink-200 bg-surface p-1">
<button
onClick={() => setTab('postfach')}
className={`rounded-lg px-4 py-2 text-sm font-bold transition ${
tab === 'postfach' ? 'bg-brand-500 text-white' : 'text-ink-500 hover:bg-brand-50 hover:text-brand-700'
}`}
>
Postfach
</button>
<button
onClick={() => setTab('vorschlaege')}
className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-bold transition ${
tab === 'vorschlaege'
? 'bg-brand-500 text-white'
: 'text-ink-500 hover:bg-brand-50 hover:text-brand-700'
}`}
>
Kontakt-Vorschläge
{openCandidateCount > 0 && (
<span
className={`rounded-md px-1.5 py-0.5 text-[10px] font-extrabold ${
tab === 'vorschlaege' ? 'bg-white/25 text-white' : 'bg-amber-100 text-amber-700'
}`}
>
{openCandidateCount}
</span>
)}
</button>
</div>
{tab === 'postfach' && (
<>
<div className="relative w-full lg:max-w-sm">
<input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Betreff, Absender, Inhalt suchen"
className="w-full rounded-xl border border-ink-200 bg-surface py-2.5 pl-10 pr-4 text-sm text-ink-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25"
/>
<svg
className="absolute left-3.5 top-3 h-4 w-4 text-ink-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<select
value={filter}
onChange={(event) => setFilter(event.target.value as MailListFilter)}
title={FILTERS.find((entry) => entry.id === filter)?.hint}
className="rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500"
>
{FILTERS.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.label}
</option>
))}
</select>
</>
)}
<div className="flex items-center gap-3 lg:ml-auto">
{status?.last_success_at && (
<span className="hidden text-xs text-ink-400 xl:inline" title="Letzter erfolgreicher Abgleich">
Stand {formatMailDateTime(status.last_success_at)}
</span>
)}
{isAdmin && status?.configured && (
<button
onClick={runSync}
disabled={isSyncing || status.running}
className="flex items-center gap-2 whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-60"
>
<svg
className={`h-4 w-4 ${isSyncing || status.running ? 'animate-spin' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{isSyncing || status.running ? 'Gleiche ab...' : 'Jetzt abgleichen'}
</button>
)}
</div>
</div>
{tab === 'postfach' ? (
isListLoading && messages.length === 0 ? (
<div className="flex items-center justify-center rounded-2xl border border-ink-200 bg-surface py-24">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
</div>
) : messages.length === 0 ? (
<div className={emptyStateClass}>
{status?.configured
? debouncedSearch || filter !== 'alle'
? 'Keine Mails passen zur Auswahl.'
: 'Noch keine Mails abgeglichen.'
: 'Sobald die Anbindung eingerichtet ist, erscheinen hier die Mails aus crm@perinco.de.'}
</div>
) : (
<div className="grid gap-4 xl:grid-cols-[380px_1fr]">
<div className="flex max-h-[calc(100vh-260px)] flex-col overflow-hidden rounded-2xl border border-ink-200 bg-surface">
<div className="scrollbar-slim flex-1 overflow-y-auto">
<MailList messages={messages} selectedId={selectedId} onSelect={setSelectedId} />
</div>
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-ink-100 px-4 py-2.5">
<span className="text-xs font-bold text-ink-400">{pageInfo}</span>
<div className="flex gap-1">
<button
onClick={() => loadMessages(Math.max(0, offset - PAGE_SIZE))}
disabled={offset === 0}
className="rounded-lg border border-ink-200 px-2.5 py-1 text-xs font-bold text-ink-600 transition hover:bg-ink-100 disabled:opacity-40"
>
Zurück
</button>
<button
onClick={() => loadMessages(offset + PAGE_SIZE)}
disabled={offset + messages.length >= total}
className="rounded-lg border border-ink-200 px-2.5 py-1 text-xs font-bold text-ink-600 transition hover:bg-ink-100 disabled:opacity-40"
>
Weiter
</button>
</div>
</div>
</div>
<div className="min-w-0">
{detail ? (
<MailDetail
message={detail}
isLoading={isDetailLoading}
onLink={() => setIsLinkOpen(true)}
onReload={refreshAll}
onOpenLead={openLead}
/>
) : (
<div className={emptyStateClass}>Eine Mail auswählen.</div>
)}
</div>
</div>
)
) : isCandidatesLoading && candidates.length === 0 ? (
<div className="flex items-center justify-center rounded-2xl border border-ink-200 bg-surface py-24">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
</div>
) : candidates.length === 0 ? (
<div className={emptyStateClass}>
Keine offenen Vorschläge.
{status?.auto_contacts && (
<p className="mx-auto mt-2 max-w-md text-xs font-normal text-ink-400">
Vorschläge mit gesicherter E-Mail-Adresse werden automatisch übernommen. Hier landet nur, was
eine Entscheidung braucht – etwa Personen, die nur mit Telefonnummer im Text stehen.
</p>
)}
</div>
) : (
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-base text-ink-900">Offene Kontakt-Vorschläge</h3>
<p className="mt-0.5 text-xs text-ink-400">
Übernommene Kontakte erscheinen unter Kontakte mit der Herkunft „Mail".
</p>
</div>
<Badge className="border-amber-200 bg-amber-50 text-amber-700">{candidates.length} offen</Badge>
</div>
<ul className="space-y-2">
{candidates.map((candidate) => (
<CandidateRow
key={candidate.id}
candidate={candidate}
onChanged={refreshAll}
onOpenLead={openLead}
showMessage
onOpenMessage={(messageId) => {
setTab('postfach');
setSelectedId(messageId);
}}
/>
))}
</ul>
</section>
)}
{isLinkOpen && detail && (
<MailLinkDialog
message={detail}
leads={leads}
opportunities={opportunities}
onClose={() => setIsLinkOpen(false)}
onLinked={refreshAll}
/>
)}
</div>
);
};
+92
View File
@@ -0,0 +1,92 @@
import React from 'react';
import type { CandidateOrigin, CandidateStatus, MailAddress } from '../../lib/mailApi';
/** Herkunft eines Vorschlags - je hoeher die Zuversicht, je kraeftiger die Farbe. */
export const ORIGIN_LABELS: Record<CandidateOrigin, string> = {
absender: 'Absender',
empfaenger: 'Empfänger',
signatur: 'Signatur',
text: 'Im Text genannt',
};
export const ORIGIN_STYLES: Record<CandidateOrigin, string> = {
absender: 'bg-brand-50 text-brand-700 border-brand-200',
empfaenger: 'bg-sky-50 text-sky-700 border-sky-200',
signatur: 'bg-amber-50 text-amber-700 border-amber-200',
text: 'bg-ink-100 text-ink-600 border-ink-200',
};
export const CANDIDATE_STATUS_LABELS: Record<CandidateStatus, string> = {
offen: 'Offen',
uebernommen: 'Übernommen',
verworfen: 'Verworfen',
dublette: 'Ergänzt',
};
export const CANDIDATE_STATUS_STYLES: Record<CandidateStatus, string> = {
offen: 'bg-amber-50 text-amber-700 border-amber-200',
uebernommen: 'bg-brand-50 text-brand-700 border-brand-200',
verworfen: 'bg-ink-100 text-ink-500 border-ink-200',
dublette: 'bg-sky-50 text-sky-700 border-sky-200',
};
export const Badge: React.FC<{ className?: string; children: React.ReactNode; title?: string }> = ({
className = '',
children,
title,
}) => (
<span
title={title}
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider ${className}`}
>
{children}
</span>
);
/** Balken fuer die Zuversicht eines Vorschlags. */
export const ConfidenceBar: React.FC<{ value: number }> = ({ value }) => {
const tone = value >= 78 ? 'bg-brand-500' : value >= 50 ? 'bg-amber-500' : 'bg-ink-300';
return (
<div className="flex items-center gap-2" title={`Zuversicht ${value} von 100`}>
<div className="h-1.5 w-14 overflow-hidden rounded-full bg-ink-100">
<div className={`h-full rounded-full ${tone}`} style={{ width: `${Math.max(4, value)}%` }} />
</div>
<span className="text-[11px] font-bold text-ink-400">{value}</span>
</div>
);
};
/** Adressliste kompakt: "Anna Müller, Thomas Schmidt +2". */
export function formatAddresses(list: MailAddress[], max = 2): string {
if (list.length === 0) return '—';
const names = list.map((entry) => entry.name || entry.email);
if (names.length <= max) return names.join(', ');
return `${names.slice(0, max).join(', ')} +${names.length - max}`;
}
export function initialsFromMail(name: string | null, email: string | null): string {
const source = (name || email || '?').trim();
const parts = source.split(/[\s.@_-]+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return source.slice(0, 2).toUpperCase();
}
/** Stabile Farbe je Absender, damit die Liste schneller lesbar ist. */
const AVATAR_COLORS = [
'bg-brand-100 text-brand-700',
'bg-sky-100 text-sky-700',
'bg-amber-100 text-amber-700',
'bg-violet-100 text-violet-700',
'bg-rose-100 text-rose-700',
'bg-teal-100 text-teal-700',
];
export function avatarColor(seed: string | null): string {
if (!seed) return 'bg-ink-100 text-ink-500';
let hash = 0;
for (let i = 0; i < seed.length; i += 1) hash = (hash * 31 + seed.charCodeAt(i)) % 997;
return AVATAR_COLORS[hash % AVATAR_COLORS.length];
}
export const emptyStateClass =
'rounded-2xl border-2 border-dashed border-ink-200 bg-surface py-16 text-center text-sm font-bold text-ink-500';
+346
View File
@@ -0,0 +1,346 @@
import React, { useEffect, useState } from 'react';
import { inputClass, labelClass } from '../leads/leadUi';
import { useToast } from '../Toast';
import {
MAIL_SETTINGS_DEFAULTS,
mailApi,
formatMailDateTime,
type MailConnectionTest,
type MailSettingsValues,
type MailStatus,
} from '../../lib/mailApi';
/*
* Einstellungen der Postfachanbindung.
*
* Zugangsdaten stehen bewusst nicht hier, sondern in server/.env - sie tauchen
* damit weder in der Oberflaeche noch in einem Datenbank-Dump auf. Diese Seite
* zeigt nur, ob sie vorhanden sind, und laesst den Betrieb steuern.
*/
const Section: React.FC<{
title: string;
description?: string;
children: React.ReactNode;
accent?: string;
}> = ({ title, description, children, accent }) => (
<section className={`rounded-2xl border border-ink-200 bg-surface p-5 ${accent ? `border-l-4 ${accent}` : ''}`}>
<h3 className="text-base text-ink-900">{title}</h3>
{description && <p className="mt-1 text-xs text-ink-400">{description}</p>}
<div className="mt-4">{children}</div>
</section>
);
const Checkbox: React.FC<{ checked: boolean; onChange: (checked: boolean) => void; label: string }> = ({
checked,
onChange,
label,
}) => (
<label className="flex cursor-pointer items-center gap-3 rounded-xl border border-ink-200 px-4 py-3">
<input
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="h-4 w-4 accent-brand-500"
/>
<span className="text-sm font-semibold text-ink-700">{label}</span>
</label>
);
const Fact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<div className="flex justify-between gap-3 text-sm">
<dt className="text-ink-400">{label}</dt>
<dd className="truncate text-right font-semibold text-ink-800">{value}</dd>
</div>
);
export const MailSettings: React.FC = () => {
const { addToast } = useToast();
const [values, setValues] = useState<MailSettingsValues>(MAIL_SETTINGS_DEFAULTS);
const [status, setStatus] = useState<MailStatus | null>(null);
const [test, setTest] = useState<MailConnectionTest | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const load = async () => {
setIsLoading(true);
try {
const nextStatus = await mailApi.status();
setStatus(nextStatus);
// Ohne Zugangsdaten liefert der Einstellungs-Endpunkt zwar Werte, sie
// haetten aber keine Wirkung - dann bleibt es bei den Vorgaben.
const data = await mailApi.settings();
setValues({ ...MAIL_SETTINGS_DEFAULTS, ...data });
} catch (err) {
addToast(err instanceof Error ? err.message : 'Einstellungen nicht erreichbar.', 'error');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const set = <K extends keyof MailSettingsValues>(key: K, value: string) =>
setValues((prev) => ({ ...prev, [key]: value }));
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setIsSaving(true);
try {
const saved = await mailApi.saveSettings(values);
setValues({ ...MAIL_SETTINGS_DEFAULTS, ...saved });
addToast('Einstellungen gespeichert.', 'success');
setStatus(await mailApi.status());
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setIsSaving(false);
}
};
const runTest = async () => {
setIsTesting(true);
setTest(null);
try {
const result = await mailApi.test();
setTest(result);
addToast(result.ok ? 'Verbindung steht.' : 'Verbindung fehlgeschlagen.', result.ok ? 'success' : 'error');
} catch (err) {
addToast(err instanceof Error ? err.message : 'Test fehlgeschlagen.', 'error');
} finally {
setIsTesting(false);
}
};
const runSync = async (reset = false) => {
setIsSyncing(true);
try {
const result = await mailApi.sync({ reset });
addToast(
`Abgleich fertig: ${result.newMessages ?? 0} neue Mails, ${result.newCandidates ?? 0} Kontakt-Vorschläge.`,
'success'
);
setStatus(await mailApi.status());
} catch (err) {
addToast(err instanceof Error ? err.message : 'Abgleich fehlgeschlagen.', 'error');
setStatus(await mailApi.status().catch(() => null));
} finally {
setIsSyncing(false);
}
};
if (isLoading) {
return (
<div className="flex justify-center py-14">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
</div>
);
}
const isActive = values.mail_sync_active === 'true';
const autoContacts = values.mail_auto_contacts === 'true';
const configured = status?.configured ?? false;
return (
<form onSubmit={handleSubmit} className="space-y-5">
<Section
title="Verbindung zu Microsoft 365"
description="Lesender Zugriff über Microsoft Graph mit App-only-Berechtigung. Zugangsdaten stehen in server/.env und werden nie an den Browser gegeben."
accent={configured ? (status?.last_error ? 'border-l-red-500' : 'border-l-brand-500') : 'border-l-amber-500'}
>
{!configured ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
<p className="text-sm font-bold text-amber-900">Zugangsdaten fehlen.</p>
<p className="mt-1 text-xs text-amber-800">
In <code className="rounded bg-amber-100 px-1">server/.env</code> setzen:{' '}
<strong>{status?.missing.join(', ')}</strong>. Anschließend den Dienst neu starten – die Werte
werden beim Start gelesen.
</p>
<p className="mt-2 text-xs text-amber-800">
In Entra genügt eine Anwendungsberechtigung: <strong>Mail.Read</strong> mit
Administratorzustimmung. In Exchange Online zusätzlich den Zugriff per
<code className="mx-1 rounded bg-amber-100 px-1">New-ApplicationAccessPolicy</code>
auf dieses Postfach begrenzen.
</p>
</div>
) : (
<>
<dl className="space-y-2">
<Fact label="Postfach" value={status?.mailbox} />
<Fact label="Ordner" value={status?.folder} />
<Fact
label="Hintergrundabgleich"
value={
status?.scheduler_enabled
? isActive
? `alle ${status?.interval_minutes} Min`
: 'pausiert'
: 'per MAIL_SYNC=false aus'
}
/>
<Fact label="Letzter Lauf" value={formatMailDateTime(status?.last_run_at ?? null)} />
<Fact label="Letzter Erfolg" value={formatMailDateTime(status?.last_success_at ?? null)} />
<Fact label="Mails im CRM" value={status?.counts.messages ?? 0} />
<Fact label="Offene Vorschläge" value={status?.counts.open_candidates ?? 0} />
<Fact label="Angelegte Kontakte" value={status?.counts.adopted_candidates ?? 0} />
</dl>
{status?.last_error && (
<div className="mt-4 rounded-xl border border-red-200 bg-red-50 p-3">
<p className="text-xs font-bold uppercase tracking-wider text-red-700">Letzter Fehler</p>
<p className="mt-1 break-words text-xs text-red-800">{status.last_error}</p>
</div>
)}
<div className="mt-4 flex flex-wrap gap-3">
<button
type="button"
onClick={runTest}
disabled={isTesting}
className="rounded-xl border border-ink-200 px-4 py-2 text-sm font-bold text-ink-700 transition hover:bg-ink-100 disabled:opacity-60"
>
{isTesting ? 'Teste...' : 'Verbindung testen'}
</button>
<button
type="button"
onClick={() => runSync(false)}
disabled={isSyncing || status?.running}
className="rounded-xl border border-ink-200 px-4 py-2 text-sm font-bold text-ink-700 transition hover:bg-ink-100 disabled:opacity-60"
>
{isSyncing || status?.running ? 'Gleiche ab...' : 'Jetzt abgleichen'}
</button>
<button
type="button"
onClick={() => runSync(true)}
disabled={isSyncing || status?.running}
title="Verwirft den Delta-Verweis und liest den eingestellten Zeitraum erneut. Vorhandene Mails werden erkannt, es entstehen keine Doppelten."
className="rounded-xl border border-ink-200 px-4 py-2 text-sm font-bold text-ink-500 transition hover:bg-ink-100 disabled:opacity-60"
>
Vollständig neu einlesen
</button>
</div>
{test && (
<div
className={`mt-4 rounded-xl border p-4 ${
test.ok ? 'border-brand-200 bg-brand-50' : 'border-red-200 bg-red-50'
}`}
>
{test.ok ? (
<>
<p className="text-sm font-bold text-brand-800">
Zugriff auf {test.mailbox} bestätigt.
</p>
<p className="mt-1 text-xs text-brand-700">
Ordner „{test.folder}" · {test.totalItems ?? '?'} Nachrichten, davon{' '}
{test.unreadItems ?? '?'} ungelesen. Berechtigung und Zugriffsrichtlinie passen.
</p>
</>
) : (
<>
<p className="text-sm font-bold text-red-900">
Fehlgeschlagen{test.stage === 'token' ? ' bei der Anmeldung' : ' beim Postfachzugriff'}.
</p>
<p className="mt-1 break-words text-xs text-red-800">{test.error}</p>
{test.hint && <p className="mt-2 text-xs font-semibold text-red-900">{test.hint}</p>}
</>
)}
</div>
)}
</>
)}
</Section>
<Section
title="Abgleich"
description="Der Abgleich holt neue und geänderte Mails über eine Delta-Abfrage. Dafür genügt Leserecht – im Postfach wird nichts verändert, insbesondere wird keine Mail als gelesen markiert."
>
<Checkbox
checked={isActive}
onChange={(checked) => set('mail_sync_active', checked ? 'true' : 'false')}
label="Abgleich aktiviert"
/>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<label className={labelClass}>Intervall (Minuten)</label>
<input
type="number"
min={2}
max={1440}
value={values.mail_sync_interval}
onChange={(event) => set('mail_sync_interval', event.target.value)}
className={inputClass}
/>
<p className="mt-1 text-xs text-ink-400">
Wie oft im Hintergrund nachgesehen wird. 10 Minuten sind ein guter Wert; kürzer bringt wenig,
weil Mails ohnehin nicht sekundengenau bearbeitet werden.
</p>
</div>
<div>
<label className={labelClass}>Erstabgleich: Zeitraum (Tage)</label>
<input
type="number"
min={1}
max={3650}
value={values.mail_since_days}
onChange={(event) => set('mail_since_days', event.target.value)}
className={inputClass}
/>
<p className="mt-1 text-xs text-ink-400">
Gilt nur für den ersten Durchlauf: so weit wird zurückgelesen. Danach kommt jede Änderung
ohnehin über die Delta-Abfrage.
</p>
</div>
</div>
</Section>
<Section
title="Kontakte aus Mails"
description="Aus Absender, Empfängerliste und Signatur werden Kontaktdaten gelesen – ohne Sprachmodell, die Mailinhalte verlassen das Haus nicht."
>
<Checkbox
checked={autoContacts}
onChange={(checked) => set('mail_auto_contacts', checked ? 'true' : 'false')}
label="Gesicherte Vorschläge automatisch übernehmen"
/>
<p className="mt-2 text-xs text-ink-400">
Automatisch übernommen wird nur, wer eine echte E-Mail-Adresse hat und aus Absender oder
Empfängerliste stammt. Alles Unsichere – etwa nur im Text genannte Personen – bleibt zur Durchsicht
liegen. Neue Kontakte erhalten die Herkunft <strong>Mail</strong> und lassen sich in der
Kontaktliste darüber filtern.
</p>
<div className="mt-4">
<label className={labelClass}>Eigene Domains (nicht als Kontakt vorschlagen)</label>
<input
value={values.mail_internal_domains}
onChange={(event) => set('mail_internal_domains', event.target.value)}
placeholder="perinco.de,perinco.onmicrosoft.com"
className={inputClass}
/>
<p className="mt-1 text-xs text-ink-400">
Kommagetrennt. Kollegen stehen in der Benutzerverwaltung und sollen nicht zusätzlich als Kontakt
auftauchen. Funktionsadressen wie info@ oder no-reply@ werden immer übersprungen.
</p>
</div>
</Section>
<div className="flex justify-end">
<button
type="submit"
disabled={isSaving}
className="rounded-xl bg-brand-500 px-6 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-60"
>
{isSaving ? 'Speichere...' : 'Einstellungen speichern'}
</button>
</div>
</form>
);
};
+3
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { UserManagement } from './UserManagement';
import { MailSettings } from './MailSettings';
import { ScannerSettings } from './ScannerSettings';
import { ColorPicker } from './ColorPicker';
import { AvatarPicker } from '../ui/AvatarPicker';
@@ -13,6 +14,7 @@ import { ADMIN_SETTINGS_TABS, userColor, type SettingsTab } from '../../types';
const TABS: { id: SettingsTab; label: string }[] = [
{ id: 'users', label: 'Nutzerverwaltung' },
{ id: 'account', label: 'Mein Konto' },
{ id: 'mail', label: 'Postfach' },
{ id: 'scanner', label: 'Scanner' },
{ id: 'whisper', label: 'Whisper' },
];
@@ -215,6 +217,7 @@ export const Settings: React.FC<{ initialTab?: SettingsTab }> = ({ initialTab })
{tab === 'users' && isAdmin && <UserManagement />}
{tab === 'account' && <AccountTab />}
{tab === 'mail' && isAdmin && <MailSettings />}
{tab === 'scanner' && isAdmin && <ScannerSettings />}
{tab === 'whisper' && <WhisperSettings />}
</div>
+264
View File
@@ -0,0 +1,264 @@
import { apiRequest } from './api';
/*
* Postfachanbindung crm@perinco.de. Alle Endpunkte liegen unter /api/mail und
* haengen an derselben CRM-Anmeldung wie der Rest der App.
*
* Zeitangaben kommen als MariaDB-DATETIME in UTC (`YYYY-MM-DD HH:MM:SS`), so wie
* Microsoft Graph sie liefert. Fuer die Anzeige gibt es parseMailDate().
*/
export interface MailAddress {
name: string | null;
email: string;
}
export interface MailMessageSummary {
id: number;
subject: string | null;
from_name: string | null;
from_email: string | null;
to: MailAddress[];
cc: MailAddress[];
received_at: string;
body_preview: string | null;
has_attachments: boolean;
is_deleted: boolean;
web_link: string | null;
link_count: number;
open_candidates: number;
}
export interface MailLink {
id: number;
lead_id: number | null;
opportunity_id: number | null;
origin: 'manuell' | 'automatisch';
note: string | null;
created_at: string;
created_by: number | null;
created_by_name: string | null;
lead_name: string | null;
lead_company: string | null;
opportunity_title: string | null;
opportunity_stage: string | null;
}
export type CandidateOrigin = 'absender' | 'empfaenger' | 'signatur' | 'text';
export type CandidateStatus = 'offen' | 'uebernommen' | 'verworfen' | 'dublette';
export interface MailCandidate {
id: number;
message_id: number;
first_name: string | null;
last_name: string | null;
salutation: string | null;
email: string | null;
phone: string | null;
mobile: string | null;
company: string | null;
position: string | null;
street: string | null;
zip: string | null;
city: string | null;
website: string | null;
linkedin: string | null;
origin: CandidateOrigin;
confidence: number;
status: CandidateStatus;
lead_id: number | null;
lead_name: string | null;
decided_at: string | null;
decided_by_name?: string | null;
created_at: string;
/** Aus dem Join: nur in der Vorschlagsliste gesetzt. */
message_subject?: string | null;
message_from?: string | null;
message_received_at?: string;
/** Adresse existiert schon als Kontakt - dann wird ergaenzt, nicht angelegt. */
existing_lead_id?: number | null;
}
export interface MailMessageDetail extends MailMessageSummary {
bcc: MailAddress[];
sent_at: string | null;
importance: string | null;
conversation_id: string | null;
internet_message_id: string | null;
is_read_in_mailbox: boolean;
body_text: string | null;
body_html: string | null;
links: MailLink[];
candidates: MailCandidate[];
}
export interface MailStatus {
configured: boolean;
/** Namen der fehlenden .env-Werte, wenn nicht konfiguriert. */
missing: string[];
mailbox: string;
folder: string;
scheduler_enabled: boolean;
active: boolean;
interval_minutes: number;
auto_contacts: boolean;
since_days: number;
running: boolean;
/** Es sind noch Delta-Seiten offen - der Erstabgleich laeuft noch. */
has_more: boolean;
/** Ein erster Abgleich hat stattgefunden. */
initialized: boolean;
last_run_at: string | null;
last_success_at: string | null;
last_error: string | null;
last_new_messages: number;
last_candidates: number;
counts: {
messages: number;
open_candidates: number;
adopted_candidates: number;
links: number;
};
last_result: MailSyncResult | null;
}
export interface MailSyncResult {
ok: boolean;
trigger: string;
startedAt: string;
durationMs: number;
pages?: number;
newMessages?: number;
updatedMessages?: number;
deletedMessages?: number;
newCandidates?: number;
adoptedContacts?: number;
hasMore?: boolean;
error?: string;
}
export interface MailConnectionTest {
ok: boolean;
stage: 'konfiguration' | 'token' | 'mailbox';
error?: string;
code?: string | null;
hint?: string;
mailbox?: string;
folder?: string | null;
totalItems?: number | null;
unreadItems?: number | null;
}
/** Alle Werte liegen als Text in crm_settings - auch Zahlen und Schalter. */
export interface MailSettingsValues {
mail_sync_active: string;
mail_sync_interval: string;
mail_auto_contacts: string;
mail_since_days: string;
mail_internal_domains: string;
}
export const MAIL_SETTINGS_DEFAULTS: MailSettingsValues = {
mail_sync_active: 'true',
mail_sync_interval: '10',
mail_auto_contacts: 'true',
mail_since_days: '30',
mail_internal_domains: 'perinco.de,perinco.onmicrosoft.com',
};
export type MailListFilter = 'alle' | 'unlinked' | 'linked' | 'candidates';
export const mailApi = {
status: () => apiRequest<MailStatus>('/mail/status'),
messages: (params: { search?: string; filter?: MailListFilter; limit?: number; offset?: number } = {}) => {
const query = new URLSearchParams();
if (params.search) query.set('search', params.search);
if (params.filter && params.filter !== 'alle') query.set('filter', params.filter);
if (params.limit) query.set('limit', String(params.limit));
if (params.offset) query.set('offset', String(params.offset));
const suffix = query.toString() ? `?${query}` : '';
return apiRequest<{ total: number; limit: number; offset: number; messages: MailMessageSummary[] }>(
`/mail/messages${suffix}`
);
},
message: (id: number) => apiRequest<MailMessageDetail>(`/mail/messages/${id}`),
link: (messageId: number, input: { lead_id?: number | null; opportunity_id?: number | null; note?: string }) =>
apiRequest<{ ok: true; created: number[] }>(`/mail/messages/${messageId}/links`, {
method: 'POST',
body: JSON.stringify(input),
}),
unlink: (linkId: number) => apiRequest<{ ok: true }>(`/mail/links/${linkId}`, { method: 'DELETE' }),
candidates: (params: { status?: CandidateStatus | 'alle'; limit?: number } = {}) => {
const query = new URLSearchParams();
query.set('status', params.status ?? 'offen');
if (params.limit) query.set('limit', String(params.limit));
return apiRequest<MailCandidate[]>(`/mail/candidates?${query}`);
},
adoptCandidate: (id: number, input: { owner_id?: number | null } = {}) =>
apiRequest<{ status: 'uebernommen' | 'dublette'; leadId: number; enrichedFields: number }>(
`/mail/candidates/${id}/adopt`,
{ method: 'POST', body: JSON.stringify(input) }
),
rejectCandidate: (id: number) =>
apiRequest<{ ok: true; status: 'verworfen' }>(`/mail/candidates/${id}/reject`, { method: 'POST' }),
// ---------- nur Administratoren ----------
settings: () => apiRequest<Partial<MailSettingsValues>>('/mail/settings'),
saveSettings: (input: Partial<MailSettingsValues>) =>
apiRequest<MailSettingsValues>('/mail/settings', { method: 'POST', body: JSON.stringify(input) }),
test: () => apiRequest<MailConnectionTest>('/mail/test', { method: 'POST' }),
sync: (options: { reset?: boolean } = {}) =>
apiRequest<MailSyncResult>('/mail/sync', { method: 'POST', body: JSON.stringify(options) }),
};
/**
* MariaDB-DATETIME in UTC nach Date. Ohne das angehaengte Z liest der Browser
* den Wert als Ortszeit und die Anzeige waere je Zeitzone um Stunden verschoben.
*/
export function parseMailDate(value: string | null): Date | null {
if (!value) return null;
const date = new Date(`${value.replace(' ', 'T')}Z`);
return Number.isNaN(date.getTime()) ? null : date;
}
/** Heute nur Uhrzeit, dieses Jahr Tag und Monat, sonst mit Jahr. */
export function formatMailDate(value: string | null): string {
const date = parseMailDate(value);
if (!date) return '—';
const now = new Date();
const sameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
if (sameDay) return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
if (date.getFullYear() === now.getFullYear()) {
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short' });
}
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' });
}
/** Vollstaendige Angabe fuer die Detailansicht und Titelhinweise. */
export function formatMailDateTime(value: string | null): string {
const date = parseMailDate(value);
if (!date) return '—';
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function candidateName(candidate: MailCandidate): string {
return [candidate.first_name, candidate.last_name].filter(Boolean).join(' ').trim() || 'Ohne Namen';
}
+5 -1
View File
@@ -8,10 +8,14 @@ import type { SettingsTab, ViewState } from '../types';
*
* `tab` waehlt beim Sprung in die Einstellungen gleich den richtigen Reiter aus;
* Whisper ist genau so ein Ziel und hat deshalb keinen eigenen Menuepunkt mehr.
*
* `leadId` oeffnet in der Kontaktansicht direkt den passenden Kontakt - die
* Mailansicht springt so von einer Zuordnung zum Kontakt, ohne dass der Benutzer
* ihn in der Liste suchen muss.
*/
interface NavigationContextValue {
currentView: ViewState;
goTo: (view: ViewState, options?: { tab?: SettingsTab }) => void;
goTo: (view: ViewState, options?: { tab?: SettingsTab; leadId?: number }) => void;
}
const NavigationContext = createContext<NavigationContextValue | null>(null);
+16 -5
View File
@@ -2,6 +2,7 @@ export type ViewState =
| 'Opportunities'
| 'Kalender'
| 'Kontakte'
| 'Mails'
| 'Scanner'
| 'Unpassend'
| 'Suchagenten'
@@ -12,13 +13,13 @@ export type ViewState =
* Reiter der Einstellungen. Whisper sitzt hier und nicht mehr im Menue - es sind
* Geraeteeinstellungen, die man einmal einrichtet.
*
* Rollen: `users` und `scanner` sind Administratoren vorbehalten, `account` und
* `whisper` stehen jedem offen - jeder pflegt sein eigenes Konto und richtet sein
* eigenes Diktat ein.
* Rollen: `users`, `scanner` und `mail` sind Administratoren vorbehalten,
* `account` und `whisper` stehen jedem offen - jeder pflegt sein eigenes Konto
* und richtet sein eigenes Diktat ein.
*/
export type SettingsTab = 'users' | 'account' | 'scanner' | 'whisper';
export type SettingsTab = 'users' | 'account' | 'mail' | 'scanner' | 'whisper';
export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['users', 'scanner'];
export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['users', 'mail', 'scanner'];
/**
* Reiter des Projekt Scanners. "Scanner" selbst ist das Dashboard und damit die
@@ -80,6 +81,16 @@ export const LEAD_STATUSES = [
export type LeadStatus = (typeof LEAD_STATUSES)[number];
/**
* Herkunft von Kontakten, die aus dem Postfach entstanden sind. Steht in
* `Lead.source` und traegt damit die Unterkategorie in der Kontaktliste - es
* braucht dafuer kein eigenes Feld.
*/
export const MAIL_LEAD_SOURCE = 'Mail';
/** Filter der Kontaktliste nach Herkunft. */
export type LeadOriginFilter = 'Alle' | 'Mail' | 'Manuell';
export interface Lead {
id: number;
salutation: string | null;