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:
co-authored by
Claude Opus 5
parent
3647201111
commit
6fd713497d
+25
-3
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 → *Anwendungsberechtigungen* →
|
||||
`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 →
|
||||
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 ≥ 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 |
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user