diff --git a/.gitignore b/.gitignore
index 08ff838..ad6b7c9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,5 +8,8 @@ server/node_modules/
server/uploads/
# Urspruengliche Aufgabenstellung - enthaelt Server- und Admin-Passwoerter im Klartext
prompt.txt
+# Erzeugte Analysedokumente (aus HTML gedruckt, nicht die Quelle der Wahrheit).
+# Fuer die Postfachanbindung gilt docs/11-postfach.md.
+M365-Mailanbindung.pdf
*.log
.DS_Store
diff --git a/README.md b/README.md
index 895b29e..13d5662 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,12 @@
# perinco CRM
CRM im perinco Corporate Design. Vite/React-Frontend, Express-Backend, MariaDB.
-Enthaelt den portierten *Projekt Scanner*.
+Enthaelt den portierten *Projekt Scanner* und die lesende Anbindung des
+Vertriebspostfachs `crm@perinco.de` ueber Microsoft Graph.
> **Dokumentation mit Funktionszeichnungen:** [`docs/`](docs/README.md)
-> — Architektur, Datenmodell, Scanner-Ablauf, Diktat, Frontend, Betrieb.
+> — Architektur, Datenmodell, Scanner-Ablauf, Diktat, Frontend, Betrieb,
+> [Postfachanbindung](docs/11-postfach.md).
> Zum Einstieg: [Anleitung fuer Benutzer](docs/07-anleitung-benutzer.md) ·
> [Anleitung fuer Entwickler](docs/08-anleitung-entwickler.md) ·
> [Quellcode-Referenz](docs/09-quellcode.md)
@@ -55,12 +57,13 @@ blitzt beim Laden die helle Variante auf.
Browser ──► Apache (:443/:80, pcrm.perinco.de)
├── / → /var/www/perinco_CRM/dist (SPA, FallbackResource)
└── /api → 127.0.0.1:5006 (Express)
- ├──► MariaDB perinco_CRM (CRM)
- └──► MariaDB project_scanner (Scanner)
+ ├──► MariaDB perinco_CRM (CRM + Postfach)
+ ├──► MariaDB perinco_scanner (Scanner)
+ └──► Microsoft Graph (crm@perinco.de, nur lesend)
```
-Der Scanner haengt unter `/api/scanner` und braucht damit **keine** eigene
-Apache-Regel.
+Der Scanner haengt unter `/api/scanner`, die Postfachanbindung unter `/api/mail` -
+beide brauchen damit **keine** eigene Apache-Regel.
Der Browser spricht **nie** direkt mit MariaDB - deshalb das Express-Backend.
Das Backend bindet ausschliesslich auf `127.0.0.1` und ist von aussen nur ueber
diff --git a/docs/01-architektur.md b/docs/01-architektur.md
index 671205d..fddcfe0 100644
--- a/docs/01-architektur.md
+++ b/docs/01-architektur.md
@@ -34,7 +34,25 @@ flowchart LR
```
Der Scanner im CRM haengt unter `/api/scanner` und braucht damit **keine eigene
-Apache-Regel**: alles laeuft ueber den bestehenden `/api`-Proxy.
+Apache-Regel**: alles laeuft ueber den bestehenden `/api`-Proxy. Dasselbe gilt fuer
+die Postfachanbindung unter `/api/mail` (siehe
+[11 Postfachanbindung](11-postfach.md)).
+
+## Ausgehende Verbindungen
+
+Das Backend spricht von sich aus mit drei fremden Diensten. Alle drei sind
+optional - fehlen die Zugangsdaten, laeuft die Anwendung ohne den jeweiligen
+Teil weiter.
+
+| Ziel | Wofuer | Ausgeloest von |
+| --- | --- | --- |
+| Projektboersen | Scraping der Projektangebote | Scanner-Scheduler |
+| `api.openai.com` | Bewertung Lebenslauf gegen Projekt | Scanner-Pipeline |
+| `login.microsoftonline.com`, `graph.microsoft.com` | Postfach `crm@perinco.de` lesen | Mail-Scheduler |
+
+Die Postfachanbindung schickt **keine** Mailinhalte an Dritte: Kontaktdaten
+werden auf dem Server aus Graph-Feldern und Signaturen gelesen, ohne
+Sprachmodell.
## Anfrageweg mit Anmeldung
@@ -72,10 +90,14 @@ sofort draussen, ohne auf den Ablauf des Tokens zu warten.
```mermaid
flowchart TD
L["Anmeldung"] --> R{"Rolle"}
- R -->|admin| A["Opportunities, Kalender, Kontakte, Scanner Einstellungen: Nutzerverwaltung, Mein Konto, Scanner, Whisper"]
- R -->|user| U["Opportunities, Kalender, Kontakte, Scanner Einstellungen: Mein Konto, Whisper"]
+ R -->|admin| A["Opportunities, Kalender, Kontakte, Mails, Scanner Einstellungen: Nutzerverwaltung, Mein Konto, Postfach, Scanner, Whisper"]
+ R -->|user| U["Opportunities, Kalender, Kontakte, Mails, Scanner Einstellungen: Mein Konto, Whisper"]
```
+Bei den Mails darf jede Rolle lesen und zuordnen - `crm@perinco.de` ist ein
+gemeinsames Vertriebspostfach. Einrichten, Verbindungstest und manueller Abgleich
+sind Administratoren vorbehalten.
+
Die Rollenpruefung sitzt **doppelt**: das Menue blendet aus, was nicht erlaubt
ist, und das Backend weist Zugriffe zusaetzlich mit `403` ab (`requireAdmin`).
Ausnahme ist `GET /api/users/directory` - die Benutzerliste brauchen alle Rollen
diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md
index ca3b993..8c23c83 100644
--- a/docs/02-datenmodell.md
+++ b/docs/02-datenmodell.md
@@ -4,7 +4,7 @@ Zwei Datenbanken, absichtlich getrennt:
| Datenbank | Inhalt | Zugriff |
| --- | --- | --- |
-| `perinco_CRM` | Benutzer, Kontakte, Opportunities, Termine | `mysql2` direkt, Schema aus `server/src/schema.sql` |
+| `perinco_CRM` | Benutzer, Kontakte, Opportunities, Termine, Postfach, Einstellungen | `mysql2` direkt, Schema aus `server/src/schema.sql` |
| `perinco_scanner` | Suchagenten, Portale, Projekte, Treffer, Scanner-Einstellungen | Sequelize, Schema aus den Modellen (`sequelize.sync()`) |
Die Trennung ist kein Zufall: die Scanner-Tabellen sind eine 1:1-Uebernahme aus
@@ -95,6 +95,88 @@ Drei Eigenheiten, die man kennen muss:
Bild vor dem Upload auf 256 px (`src/lib/imageCompress.ts`), aus 2,9 MB werden
wenige Kilobyte.
+### Postfach crm@perinco.de
+
+Diese Tabellen liegen in derselben Datenbank, haengen aber nur ueber
+`crm_mail_links` und `crm_mail_contact_candidates.lead_id` am Rest. Fachliche
+Beschreibung in [11 Postfachanbindung](11-postfach.md).
+
+```mermaid
+erDiagram
+ crm_mail_messages ||--o{ crm_mail_links : hat
+ crm_mail_messages ||--o{ crm_mail_contact_candidates : liefert
+ crm_leads ||--o{ crm_mail_links : "lead_id"
+ crm_opportunities ||--o{ crm_mail_links : "opportunity_id"
+ crm_leads ||--o{ crm_mail_contact_candidates : "lead_id, nach Uebernahme"
+
+ crm_mail_messages {
+ int id PK
+ varchar graph_id "Id aus Microsoft Graph"
+ char graph_id_hash UK "SHA-256 hex, traegt die Eindeutigkeit"
+ varchar conversation_id "Unterhaltung"
+ varchar subject
+ varchar from_email
+ longtext to_json "[{name,email}] als Text"
+ datetime received_at "UTC"
+ longtext body_text "aus HTML gewandelt, Zeilen erhalten"
+ longtext body_html "entschaerft fuer die Anzeige"
+ tinyint has_attachments
+ tinyint is_deleted "im Postfach geloescht"
+ }
+ crm_mail_links {
+ int id PK
+ int message_id FK
+ int lead_id FK "NULL erlaubt"
+ int opportunity_id FK "NULL erlaubt"
+ enum origin "manuell | automatisch"
+ varchar note
+ int created_by FK
+ }
+ crm_mail_contact_candidates {
+ int id PK
+ int message_id FK
+ char dedupe_hash "aus E-Mail bzw. Name+Telefon"
+ varchar first_name
+ varchar last_name
+ varchar email
+ varchar company
+ enum origin "absender | empfaenger | signatur | text"
+ tinyint confidence "0-100"
+ enum status "offen | uebernommen | verworfen | dublette"
+ int lead_id FK "gesetzt ab uebernommen"
+ }
+ crm_mail_sync_state {
+ tinyint id PK "immer 1"
+ varchar mailbox
+ text delta_link "naechster Graph-Aufruf"
+ tinyint has_more "nextLink offen"
+ datetime last_run_at
+ datetime last_success_at
+ text last_error
+ }
+ crm_settings {
+ varchar key_name PK
+ text value "alles als Text, auch Zahlen und Schalter"
+ }
+```
+
+Vier Eigenheiten:
+
+- **Die Graph-ID hat einen Hash daneben.** Sie ist mit ueber 150 Zeichen zu lang
+ fuer einen Unique-Index unter `utf8mb4`; die Eindeutigkeit traegt deshalb
+ `graph_id_hash` (SHA-256 als Hex, `CHAR(64)`).
+- **`received_at` steht in UTC**, anders als `crm_events.starts_at` (lokale
+ Wandzeit). Graph liefert UTC, und eine stille Umrechnung beim Speichern waere
+ eine Fehlerquelle. Das Frontend rechnet um (`parseMailDate` in
+ `src/lib/mailApi.ts`).
+- **`crm_mail_links` erlaubt NULL in beiden Fremdschluesseln.** MariaDB laesst in
+ einem Unique-Index mehrere NULL zu - die beiden Schluessel sperren damit genau
+ die Doppelung desselben Paares, und eine Mail kann an mehreren Kontakten und
+ mehreren Opportunities haengen.
+- **`crm_settings` enthaelt keine Geheimnisse.** Zugangsdaten fuer Microsoft 365
+ stehen ausschliesslich in `server/.env`, damit ein Datenbank-Dump keine Secrets
+ enthaelt.
+
## perinco_scanner
```mermaid
diff --git a/docs/05-frontend.md b/docs/05-frontend.md
index 4882093..b952fb1 100644
--- a/docs/05-frontend.md
+++ b/docs/05-frontend.md
@@ -12,6 +12,9 @@ flowchart TD
K --> O["Opportunities (Startansicht)"]
K --> C["Kalender"]
K --> L["Kontakte"]
+ K --> M["Mails"]
+ M --> M1["Postfach"]
+ M --> M2["Kontakt-Vorschlaege"]
K --> SC["Scanner"]
SC --> SC1["Dashboard"]
SC --> SC2["Unpassend"]
@@ -20,10 +23,14 @@ flowchart TD
SB --> E["Einstellungen"]
E --> E1["Nutzerverwaltung (admin)"]
E --> E2["Mein Konto"]
- E --> E3["Scanner (admin)"]
- E --> E4["Whisper"]
+ E --> E3["Postfach (admin)"]
+ E --> E4["Scanner (admin)"]
+ E --> E5["Whisper"]
```
+*Postfach* und *Kontakt-Vorschlaege* sind Reiter **innerhalb** der Ansicht
+*Mails*, keine Menuepunkte - sie gehoeren zusammen und wechseln oft hin und her.
+
Der Menuepunkt *Scanner* klappt seine Unterpunkte auf; *Scanner* selbst ist das
Dashboard. Bei eingeklappter Leiste (Symbolbreite) erscheinen nur die Symbole,
die Unterpunkte kommen mit den Beschriftungen zurueck.
@@ -39,8 +46,8 @@ flowchart TB
WH["WhisperProvider Modell, Worker"]
end
- APP["App.tsx currentView, settingsTab"]
- NAV["NavigationProvider goTo(view, {tab})"]
+ APP["App.tsx currentView, settingsTab, focusLeadId"]
+ NAV["NavigationProvider goTo(view, {tab, leadId})"]
DATA["loadData() leads, connections, events, users, opportunities"]
Kontexte --> APP
@@ -49,14 +56,25 @@ flowchart TB
DATA --> V1["Opportunities"]
DATA --> V2["Kalender"]
DATA --> V3["Kontakte"]
+ DATA --> V4["Mails leads + opportunities fuer den Zuordnungsdialog"]
NAV --> DF["DictationField springt nach Whisper"]
+ NAV --> ML["Mails springt zum Kontakt einer Zuordnung"]
SCV["Scanner-Ansichten"] -->|"laden selbst"| API["/api/scanner"]
+ MV["Mailansicht"] -->|"laedt selbst"| MAPI["/api/mail"]
```
Die CRM-Daten werden **einmal zentral** geladen und als Props durchgereicht; die
-Scanner-Ansichten laden dagegen selbst, weil sie eine andere Datenbank und eine
-eigene Auffrischung im 30-Sekunden-Takt haben.
+Scanner- und Mailansichten laden ihre eigenen Daten selbst - der Scanner, weil er
+eine andere Datenbank und eine Auffrischung im 30-Sekunden-Takt hat; die Mails,
+weil sie blaettern, suchen und filtern und dabei nicht die ganze Liste im Speicher
+halten sollen. Kontakte und Opportunities bekommt die Mailansicht dagegen als
+Props: der Zuordnungsdialog braucht sie beide zum Vorschlagen.
+
+`goTo('Kontakte', { leadId })` ist der Sprung aus einer Mailzuordnung zum
+Kontakt. `App.tsx` haelt die Id in `focusLeadId`, die Kontaktansicht oeffnet den
+Kontakt und meldet mit `onFocusHandled()` zurueck - der Effekt haengt an `leads`
+und wartet damit auf einen gerade erst angelegten Kontakt.
`reload={() => loadData(true)}` laedt still nach - ohne Ladeanzeige, damit ein
Speichern im Dialog nicht die ganze Liste flackern laesst.
@@ -70,6 +88,7 @@ src/
lib/
api.ts Fetch-Wrapper (JWT, 401-Behandlung) + CRM-Endpunkte
scannerApi.ts Typisierte Endpunkte unter /api/scanner
+ mailApi.ts Endpunkte unter /api/mail, UTC-Datumshelfer
auth.tsx Anmeldung, Benutzer, Rolle
theme.tsx Hell/Dunkel/System
navigation.tsx Ansichtswechsel fuer tiefe Komponenten
@@ -83,8 +102,9 @@ src/
opportunities/ Kachel, Liste, Pipeline (Drag & Drop), Formular
calendar/ Monat/Woche/Tag, Termine (Drag & Drop), @-Mentions
leads/ Kachel, Liste, Netzwerkdiagramm (d3-force)
+ mail/ Postfachliste, Detail, Zuordnung, Kontakt-Vorschlaege
scanner/ Dashboard, Unpassend, Suchagenten, Boersen-Konten
- settings/ Reiter: Nutzerverwaltung, Konto, Scanner, Whisper
+ settings/ Reiter: Nutzerverwaltung, Konto, Postfach, Scanner, Whisper
whisper/ Einrichtung der Spracherkennung
ui/ Modal, Avatare, Diktatfeld
```
@@ -109,3 +129,8 @@ Ein Inline-Skript in `index.html` setzt die Klasse vor dem ersten Frame, sonst
blitzt beim Laden die helle Variante auf. Drei Werte bleiben in beiden Modi
gleich: `ink-950` (liegt nur unter Dialog-Overlays), `text-white` (steht immer auf
farbigen Flaechen) und `brand-500` (das CI-Gruen der Buttons).
+
+Eine bewusste Ausnahme: der **Mailinhalt** bleibt auch im Dunkelmodus auf weissem
+Grund. Fremdes Mail-HTML bringt seine eigenen Farben mit; ein dunkler Hintergrund
+darunter macht schwarze Schrift auf schwarzer Flaeche. Der Rahmen liest sich damit
+wie ein Blatt Papier im dunklen Fenster - gewollt.
diff --git a/docs/06-betrieb.md b/docs/06-betrieb.md
index c88265b..580a824 100644
--- a/docs/06-betrieb.md
+++ b/docs/06-betrieb.md
@@ -60,11 +60,25 @@ mitten im Scrapen beenden.
| `SCANNER_SCHEDULER` | Hintergrund-Scraper, Standard an, nur `false` schaltet ab |
| `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz fuer den ersten Lauf, Standard 30 |
| `LEGACY_*` | Quelle fuer `npm run scanner:import` |
+| `MS_TENANT_ID`, `MS_CLIENT_ID`, `MS_CLIENT_SECRET` | App-only-Zugriff auf Microsoft Graph |
+| `MS_MAILBOX` | abzugleichendes Postfach, Standard `crm@perinco.de` |
+| `MS_MAIL_FOLDER` | Ordner, Standard `inbox` |
+| `MAIL_SYNC` | Postfachabgleich, Standard an, nur `false` schaltet ab |
+| `MAIL_MAX_PAGES`, `MAIL_MAX_BODY_CHARS` | Bremsen fuer Erstabgleich und Textgroesse |
Die Scanner-Datenbank wird beim Start automatisch angelegt
(`CREATE DATABASE IF NOT EXISTS`), Tabellen kommen aus `sequelize.sync()`, Portale
und Standardeinstellungen werden geseedet.
+Fehlen die `MS_*`-Werte, startet das Backend normal und protokolliert
+`[Mail] Zugangsdaten fehlen`. Die Ansicht *Mails* und der Einstellungsreiter
+*Postfach* nennen die fehlende Variable beim Namen. Einrichtung in Entra und
+Exchange Online: [11 Postfachanbindung](11-postfach.md).
+
+> Die `MS_*`-Werte werden **beim Start** gelesen. Nach einer Aenderung an der
+> `.env` muss der Dienst neu starten, ein Speichern in der Oberflaeche genuegt
+> nicht - dort liegen nur Intervall, Schalter und Zeitraum (`crm_settings`).
+
## Playwright
`playwright` ist auf **exakt 1.60.0** gepinnt - dieselbe Version wie die alte App.
@@ -105,6 +119,8 @@ systemctl restart perinco-crm-backend
| `Target page ... has been closed` | Dienst wurde mitten im Scrapen neu gestartet |
| Backend stirbt beim Scrapen | `MemoryMax` zu klein, `systemctl show ... -p MemoryCurrent` |
| Ton bleibt aus | Schwellenwert, Dashboard-Ansicht offen, einmal auf der Seite geklickt? |
+| Keine Mails im Postfachreiter | `journalctl -u perinco-crm-backend \| grep '\[Mail\]'`, dann Verbindungstest in den Einstellungen |
+| Mail-Abgleich meldet `ErrorAccessDenied` | Administratorzustimmung fuer `Mail.Read` fehlt, oder die ApplicationAccessPolicy erlaubt das Postfach nicht |
Nuetzliche Abfragen:
@@ -116,6 +132,12 @@ curl -s http://127.0.0.1:5006/api/health
set -a; . /var/www/perinco_CRM/server/.env; set +a
mysql -u"$DB_USER" -p"$DB_PASSWORD" "$SCANNER_DB_NAME" -e \
"SELECT COUNT(*) FROM matches; SELECT key_name, value FROM settings"
+
+# Zustand des Postfachabgleichs
+mysql -u"$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" -e \
+ "SELECT mailbox, has_more, last_run_at, last_success_at, last_error FROM crm_mail_sync_state;
+ SELECT COUNT(*) AS mails FROM crm_mail_messages;
+ SELECT status, COUNT(*) FROM crm_mail_contact_candidates GROUP BY status"
```
## Sicherung
diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md
index 62cfcaa..d2513ee 100644
--- a/docs/07-anleitung-benutzer.md
+++ b/docs/07-anleitung-benutzer.md
@@ -34,6 +34,7 @@ die Scanner-Einstellungen. Alles andere ist fuer beide Rollen gleich.
│ Opportunities├──────────────────────────────────────────┤
│ Kalender │ │
│ Kontakte │ Inhalt │
+│ Mails │ │
│ Scanner ▾ │ │
│ Dashboard │ │
│ Unpassend │ │
@@ -126,6 +127,72 @@ setzt ihn fest (gruener Punkt), Doppelklick loest ihn wieder. Ueber *Verbindung
erstellen* zwei Kontakte anklicken, um eine Verbindung anzulegen; `Esc` bricht ab.
Suche und Statusfilter wirken auch hier.
+Sobald Kontakte aus dem Postfach uebernommen wurden, erscheint neben dem
+Statusfilter ein **Herkunftsfilter**: *Aus Mails* zeigt nur die automatisch
+entstandenen, *Selbst angelegt* nur die von Hand erfassten.
+
+## Mails
+
+Das gemeinsame Postfach `crm@perinco.de` liegt hier im CRM. Der Zugriff ist
+**nur lesend** - im Postfach selbst wird nichts veraendert, keine Mail als gelesen
+markiert, nichts geloescht. Antworten geht weiter in Outlook; der Link *In Outlook
+oeffnen* springt direkt zur Mail.
+
+Zwei Reiter oben:
+
+### Postfach
+
+Links die Mailliste, rechts die gewaehlte Mail. Die Kennzeichen an einem
+Listeneintrag sagen, was noch zu tun ist:
+
+| Kennzeichen | Bedeutung |
+| --- | --- |
+| **1x zugeordnet** | haengt an einer Opportunity oder einem Kontakt |
+| **2 Kontakte** | so viele Kontakt-Vorschlaege warten noch auf eine Entscheidung |
+| 📎 | Anhaenge vorhanden (im Postfach, nicht im CRM) |
+
+Das Auswahlfeld filtert die Liste. **Nicht zugeordnet** ist die eigentliche
+Arbeitsliste: alles, was noch nirgends haengt. Die Suche geht ueber Betreff,
+Absender und den kompletten Mailtext; mehrere Woerter wirken als ein Suchbegriff.
+
+**Zuordnen** haengt die Mail an eine Opportunity und/oder einen Kontakt. Der
+Dialog schlaegt oben vor, was zu den Mailadressen passt - ein Klick auf den
+Vorschlag genuegt. Beides gleichzeitig ist moeglich, jede Zuordnung laesst sich
+einzeln wieder mit dem **x** loesen.
+
+Beim Mailinhalt sind externe Bilder zunaechst gesperrt. Das ist Absicht: geladene
+Bilder melden dem Absender, dass die Mail geoeffnet wurde. Ueber *Externe Bilder
+laden* geht es pro Mail. *Groesser* klappt den Inhalt auf, *Nur Text anzeigen*
+zeigt die Rohfassung ohne Formatierung.
+
+Steht an einer Mail **Im Postfach geloescht**, wurde sie in Outlook entfernt. Im
+CRM bleibt sie erhalten, damit Zuordnungen nicht verloren gehen.
+
+### Kontakt-Vorschlaege
+
+Aus Absender, Empfaengerliste und Signatur jeder Mail liest die Anwendung
+Kontaktdaten. Wer eine echte Mailadresse hat und aus Absender oder
+Empfaengerliste stammt, wird **automatisch** Kontakt. Alles Unsichere landet
+hier - typischerweise Personen, die nur mit Telefonnummer im Text genannt werden.
+
+Der Balken rechts ist die Zuversicht: je voller und gruener, je verlaesslicher die
+Angaben. Das Kennzeichen darunter sagt, woher sie stammen (*Absender*,
+*Empfaenger*, *Signatur*, *Im Text genannt*).
+
+Zwei Knoepfe:
+
+* **Als Kontakt anlegen** - es entsteht ein neuer Kontakt mit Herkunft „Mail".
+* **Ergaenzen** (statt „anlegen", wenn **Bereits vorhanden** dransteht) - der
+ vorhandene Kontakt wird um die fehlenden Angaben erweitert. Was schon gepflegt
+ ist, bleibt unangetastet.
+* **Verwerfen** - der Vorschlag verschwindet aus der Liste.
+
+Eigene Kollegen (`@perinco.de`) und Funktionsadressen wie `info@` oder
+`no-reply@` werden nie vorgeschlagen.
+
+Ist die Liste leer und die automatische Uebernahme an, ist das der Normalzustand:
+es gab nichts zu entscheiden.
+
## Scanner
Durchsucht Projektboersen und bewertet jeden Fund mit KI gegen die Lebenslaeufe
@@ -223,6 +290,7 @@ Datenbank.
| --- | --- | --- |
| **Nutzerverwaltung** | Admin | Benutzer anlegen, bearbeiten, deaktivieren, loeschen |
| **Mein Konto** | alle | Foto, Kalenderfarbe, Passwort aendern |
+| **Postfach** | Admin | Verbindung zu Microsoft 365, Abgleich, Kontaktuebernahme |
| **Scanner** | Admin | Suchintervall, Schwellenwerte, KI-Schluessel, Ton, E-Mail |
| **Whisper** | alle | Spracherkennung fuer das Diktat einrichten |
@@ -234,6 +302,16 @@ nicht mehr: kein Lauf im Hintergrund, und *Jetzt scannen* antwortet mit einem
Hinweis statt zu starten. Der Zustand steht auch oben im Scanner-Dashboard, damit
man nicht raetselt, warum keine neuen Treffer kommen.
+Im Reiter **Postfach** steht oben, ob die Verbindung zu Microsoft 365 steht.
+**Verbindung testen** prueft beides: die Anmeldung *und* den tatsaechlichen
+Zugriff auf `crm@perinco.de`. Schlaegt etwas fehl, nennt die Meldung den
+wahrscheinlichen Grund. Darunter lassen sich Intervall, Zeitraum des ersten
+Durchlaufs und die automatische Kontaktuebernahme einstellen.
+
+Die Zugangsdaten selbst stehen **nicht** in der Oberflaeche, sondern in der
+Serverkonfiguration - sie werden beim Start gelesen. Fehlt einer, sagt die Seite
+welcher.
+
Foto und Kalenderfarbe werden **sofort** gespeichert, ohne Speichern-Knopf. Der
letzte aktive Administrator kann sich nicht selbst degradieren oder loeschen -
das verhindert der Server.
@@ -261,3 +339,7 @@ nicht zum Benutzerkonto.
| Boerse zeigt „Abgelaufen" | Cookies neu importieren |
| „Ein Suchlauf laeuft bereits" | kurz warten, es laeuft nur einer zur Zeit |
| Aendern nicht erlaubt | bei Opportunities darf nur Ersteller, Zustaendiger oder Admin |
+| Keine Mails sichtbar | steht oben ein Hinweisbalken? Sonst Admin bitten, in *Einstellungen → Postfach* die Verbindung zu testen |
+| „Erstabgleich laeuft noch" | aeltere Mails werden in mehreren Durchlaeufen nachgeholt, die Liste waechst noch |
+| Bilder in einer Mail fehlen | *Externe Bilder laden* klicken - sie sind absichtlich gesperrt |
+| Kontakt-Vorschlaege bleiben leer | Normalfall: sichere Vorschlaege werden automatisch uebernommen und tauchen hier nicht auf |
diff --git a/docs/08-anleitung-entwickler.md b/docs/08-anleitung-entwickler.md
index 95e56dd..48c77a1 100644
--- a/docs/08-anleitung-entwickler.md
+++ b/docs/08-anleitung-entwickler.md
@@ -53,10 +53,18 @@ ansehen - siehe *Oberflaeche pruefen ohne Datenbank* weiter unten.
```bash
npm run lint # = tsc --noEmit, das ist die Wahrheit
npm run build # muss ohne Fehler durchlaufen
+cd server && npm test # Pruefungen der Postfachanbindung
```
-Es gibt **keine automatischen Tests**. Wer etwas aendert, prueft mit `tsc`, einem
-Build und einem Blick in die Oberflaeche.
+Automatische Tests gibt es nur fuer **einen** Bereich: `server/test/mail.test.mjs`
+prueft die Kontaktextraktion aus Mails und den Graph-Client. Beides laeuft
+unbeaufsichtigt im Hintergrund und laesst sich von Hand kaum nachstellen - ein
+falsch erkannter Name landet still in der Kontaktliste, und ein 429 vom echten
+Dienst kommt genau dann, wenn niemand hinsieht. Kein Test-Framework, reines Node,
+keine Datenbank.
+
+Fuer alles andere gilt: `tsc`, ein Build und ein Blick in die Oberflaeche - notfalls
+mit dem Stub aus [Oberflaeche pruefen ohne Datenbank](#oberflaeche-pruefen-ohne-datenbank).
## Aufbau
@@ -69,8 +77,10 @@ src/ Frontend
server/src/ Backend
server.js Express-App, Reihenfolge der Routen
db.js Verbindungspool, Schema-Init, Admin-Seed
+ settings.js crm_settings lesen und schreiben (ohne Geheimnisse)
routes/ ein Router je Ressource
middleware/auth.js JWT pruefen, Adminrechte
+ mail/ Postfach crm@perinco.de ueber Microsoft Graph
scanner/ portierter Projekt Scanner (Sequelize, Playwright, OpenAI)
docs/ diese Dokumentation
deploy/ Apache-Vhosts, systemd-Unit, Deployskript
@@ -141,6 +151,22 @@ Ein Sprung dorthin aus einer beliebigen Tiefe:
2. In `server/src/server.js` einhaengen - **vor** dem `/api`-404-Fallback.
3. In `src/lib/api.ts` eine typisierte Funktion ergaenzen.
+### Neue Einstellung im CRM
+
+Nur fuer Werte, die im Betrieb verstellbar sein sollen. **Zugangsdaten gehoeren
+nicht dazu** - die stehen in `server/.env` und werden beim Start gelesen, damit ein
+Datenbank-Dump keine Geheimnisse enthaelt.
+
+1. Schluessel samt Vorgabe in das Vorgabenobjekt des Bereichs eintragen (Muster:
+ `MAIL_SETTING_DEFAULTS` in `server/src/mail/config.js`). Es ist gleichzeitig die
+ Positivliste des Speicher-Endpunkts.
+2. Lesen mit `getSettings(defaults)` aus `server/src/settings.js`, schreiben mit
+ `setSetting()`. Zahlen und Schalter mit `asInt`/`asBool` wandeln.
+3. Feld im passenden Einstellungsreiter ergaenzen.
+
+Die Tabelle `crm_settings` legt `schema.sql` an; ein neuer Schluessel braucht keine
+Migration.
+
## Fallen
Diese Punkte haben real Zeit gekostet:
@@ -157,6 +183,10 @@ Diese Punkte haben real Zeit gekostet:
| **`sequelize.sync({ alter: true })`** | Legt bei jedem Start Unique-Indizes neu an und sprengt irgendwann MySQLs Grenze von 64 Keys. Nur `sync()` benutzen. |
| **Kaskaden beim Loeschen** | Einen Suchagenten loeschen loescht alle seine Treffer mit. Inaktiv setzen ist meist gemeint. |
| **Doppelte Scanner-Laeufe** | Scheduler und *Jetzt scannen* teilen eine Sperre. Ohne sie zahlt man jede KI-Bewertung zweimal. |
+| **`allow-same-origin` bei `srcDoc`** | Ein so eingebettetes Dokument erbt die Origin der Anwendung und kann `localStorage` lesen - also das Anmeldetoken. Fremdes HTML gehoert in `sandbox=""` ohne jedes Flag. Der Preis: die Inhaltshoehe ist nicht auslesbar, deshalb hat der Mailrahmen eine feste Hoehe. |
+| **`/me` unter App-only** | Es gibt keinen Benutzer im Token, also auch kein `/me` - jeder Graph-Aufruf muss `/users/{postfach}/…` lauten. Genau daran ist das Altsystem gescheitert, still, weil die Antwort nicht geprueft wurde. |
+| **UTC neben Wandzeit** | `crm_mail_messages.received_at` steht in UTC (so liefert Graph es), `crm_events.starts_at` in lokaler Wandzeit. Beim Lesen im Frontend `parseMailDate()` benutzen, sonst ist die Anzeige um Stunden verschoben. |
+| **Aufzaehlungen in der Datenbank sind deutsch** | `crm_mail_links.origin` (`manuell`/`automatisch`), Vorschlagsstatus (`offen`, `uebernommen`, `verworfen`, `dublette`). Wer englische Werte schickt, bekommt einen Datenbankfehler statt einer Validierungsmeldung. |
## Oberflaeche pruefen ohne Datenbank
diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md
index 7761e42..bba7d92 100644
--- a/docs/09-quellcode.md
+++ b/docs/09-quellcode.md
@@ -70,6 +70,7 @@ Alle unter `router.use(requireAuth)`.
| `connections.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | jeder Angemeldete |
| `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; aendern/loeschen nur Ersteller oder Admin |
| `opportunities.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | aendern/loeschen nur Ersteller, Zustaendiger oder Admin |
+| `mail.js` | `GET /status`, `/messages`, `/messages/:id`, `/candidates`; `POST /messages/:id/links`, `/candidates/:id/adopt`, `/candidates/:id/reject`; `DELETE /links/:id`; `requireAdmin`: `GET/POST /settings`, `POST /test`, `POST /sync` | lesen und zuordnen jeder; einrichten Admin |
Wiederkehrende Muster in diesen Routern:
@@ -86,6 +87,46 @@ Wiederkehrende Muster in diesen Routern:
* Der letzte aktive Administrator kann nicht degradiert, deaktiviert oder
geloescht werden.
+### `server/src/settings.js`
+
+`getSetting`, `getSettings`, `setSetting` auf `crm_settings`, dazu `asBool` und
+`asInt`. Alle Werte liegen als Text in der Datenbank. `getSettings(defaults)`
+nimmt die Vorgaben gleichzeitig als Positivliste der erlaubten Schluessel - ein
+unbekannter Name kommt so nie in die Datenbank.
+
+Bewusst getrennt vom Setting-Modell des Scanners (andere Datenbank) und bewusst
+**ohne Geheimnisse**: Zugangsdaten stehen in `server/.env`.
+
+### `server/src/mail/`
+
+Postfachanbindung, fachlich in [11 Postfachanbindung](11-postfach.md).
+
+| Datei | Inhalt |
+| --- | --- |
+| `config.js` | `.env` lesen, `MAIL_CONFIGURED`, `missingCredentials()`, verstellbare Einstellungen mit Vorgaben (`mailSettings()`) |
+| `graph.js` | Token mit Zwischenspeicher, `graphRequest()` mit Wiederholung bei 429/5xx und Beachtung von `Retry-After`, `testConnection()` |
+| `html.js` | `htmlToText()` (Zeilen bleiben erhalten, Linkziele werden hinter den Linktext gesetzt), `sanitizeHtml()` |
+| `extract.js` | `extractCandidates()`, `parsePersonName()`, `parseSignature()`, `AUTO_ADOPT_MIN_CONFIDENCE` |
+| `store.js` | Zustandszeile, `upsertMessage()`, `insertCandidates()`, `linkMessage()` |
+| `contacts.js` | `adoptCandidate()`: anlegen oder vorhandenen Kontakt ergaenzen, `markCandidate()` |
+| `sync.js` | ein Abgleichslauf, Delta-Seiten, Einzelsperre, Fehlerbehandlung |
+| `scheduler.js` | Minutentakt, Faelligkeit aus `last_run_at`, kein Zeitfenster |
+
+Eigenheiten, die man kennen muss:
+
+* `graphRequest()` nimmt Pfade **und** vollstaendige URLs - Delta-Links kommen
+ absolut von Graph zurueck.
+* Ein `401` verwirft das Token genau einmal und wiederholt; so ueberlebt ein Lauf
+ ein serverseitig zurueckgezogenes Token.
+* `upsertMessage()` laesst die Textspalten unberuehrt, wenn `bodyText` im
+ uebergebenen Objekt `undefined` ist. Ein reines Metadaten-Update darf den schon
+ geholten Mailtext nicht loeschen.
+* `extractCandidates()` verlangt im Fliesstext eine Anrede vor dem Namen.
+ Ohne diese Bedingung liest der Musterabgleich jede Folge grossgeschriebener
+ Woerter als Namen.
+* Der Lauf ist **einfach gesperrt** (`running` in `sync.js`) - ein zweiter Aufruf
+ kommt sofort mit `skipped` zurueck, statt parallel zu laufen.
+
### `server/src/routes/scanner.js`
Die portierte Scanner-API unter `/api/scanner`, vollstaendig in
@@ -160,13 +201,25 @@ damit bleibt der Vergleich mit der alten App moeglich. Nur `ScannerProject`
traegt beide Schreibweisen fuer `created_at`, weil das Modell die Spalte selbst
umbenennt.
+### `src/lib/mailApi.ts`
+
+Typen (`MailMessageSummary`, `MailMessageDetail`, `MailCandidate`, `MailLink`,
+`MailStatus`, `MailSyncResult`, `MailConnectionTest`, `MailSettingsValues`), das
+Objekt `mailApi`, `MAIL_SETTINGS_DEFAULTS` und die Datumshelfer.
+
+`parseMailDate()` haengt ein `Z` an den MariaDB-Zeitstempel, bevor daraus ein
+`Date` wird. Ohne das liest der Browser den Wert als Ortszeit, und die Anzeige
+waere je Zeitzone um Stunden verschoben - die Mailzeiten stehen als UTC in der
+Datenbank, anders als die Kalendertermine. `formatMailDate()` zeigt heute nur die
+Uhrzeit, dieses Jahr Tag und Monat, sonst mit Jahr.
+
### Kontexte
| Datei | Haelt | Eigenheit |
| --- | --- | --- |
| `auth.tsx` | Benutzer, `isAdmin`, `login`, `logout`, `updateUser` | prueft beim Start das gespeicherte Token gegen `/auth/me` |
| `theme.tsx` | hell/dunkel/System | Wahl in `localStorage`, Doppelklick loest sie wieder |
-| `navigation.tsx` | `currentView`, `goTo(view, { tab })` | erspart es, Callbacks durch vier Ebenen zu reichen |
+| `navigation.tsx` | `currentView`, `goTo(view, { tab, leadId })` | erspart es, Callbacks durch vier Ebenen zu reichen; `leadId` oeffnet den Kontakt direkt |
| `whisper.tsx` | Modellstatus, Worker, Fortschritt, `transcribe` | ein Worker fuer die ganze App |
### Whisper
@@ -188,6 +241,8 @@ Warum genau diese Konstanten festgelegt sind, steht in
| --- | --- |
| `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `StatusBadge`, `Avatar`, Statusfarben |
| `components/scanner/scannerUi.tsx` | `cardClass`, Buttonklassen, `MatchBadge`, `MatchStatusBadge`, `PortalStatusPill`, `formatDateTime`, `projectTimestamp` |
+| `components/mail/mailUi.tsx` | `Badge`, `ConfidenceBar`, Herkunfts- und Statusbeschriftungen, `formatAddresses`, `avatarColor` |
+| `components/mail/MailBody.tsx` | Mailinhalt im `