Betreff "Du wurdest beworben: <Titel>", Text "wir haben dich auf folgendes Projekt beworben", Link zum Projekt als ausgeschriebene Zeile in der Tabelle statt versteckt hinter "Zur Ausschreibung". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
455 lines
22 KiB
Markdown
455 lines
22 KiB
Markdown
# 11 Postfachanbindung (Microsoft 365)
|
||
|
||
Das gemeinsame Vertriebspostfach `crm@perinco.de` wird **lesend** in das CRM
|
||
gespiegelt. Aus jeder Mail entstehen Kontakt-Vorschläge, und jede Mail lässt
|
||
sich an eine Opportunity und an Kontakte hängen.
|
||
|
||
Vorbild ist die gleiche Funktion im Altsystem KeepTrack. Übernommen 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 für mehrere Entwurfsentscheidungen.
|
||
|
||
## Überblick
|
||
|
||
```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/>Übernahme"]
|
||
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.
|
||
|
||
> Dieselbe App-Registrierung trägt seit dem 21.09.2026 zusätzlich die
|
||
> [Anmeldung über Microsoft](13-anmeldung-sso.md). Die braucht einen
|
||
> Redirect-URI und delegierte Berechtigungen - beides betrifft nur den
|
||
> Anmeldeweg, die hier beschriebene App-only-Anbindung bleibt unberührt.
|
||
|
||
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. Es ist dasselbe
|
||
Geheimnis wie bei der Anmeldung; erneuert wird es seit dem 21.09.2026 unter
|
||
*Einstellungen → Anmeldung*, siehe
|
||
[13 Anmeldung über Microsoft](13-anmeldung-sso.md#client-secret-pflegen).
|
||
3. **API-Berechtigung** Microsoft Graph → *Anwendungsberechtigungen* →
|
||
`Mail.Read`, danach Administratorzustimmung erteilen.
|
||
`Mail.ReadBasic` genügt nicht, es liefert keinen Mailtext.
|
||
Seit 21.10.2026 zusätzlich `Mail.Send` (erteilt) - damit verschickt das CRM
|
||
die [Berater-Mail bei neuem Projekt](#berater-mail-bei-neuem-projekt) aus
|
||
diesem Postfach.
|
||
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 für
|
||
delegierten Zugriff bzw. EWS-Impersonation. Bei App-only zählt allein die
|
||
Zugriffsrichtlinie.
|
||
|
||
Der Server muss ausgehend `login.microsoftonline.com` und `graph.microsoft.com`
|
||
auf 443 erreichen.
|
||
|
||
## Umgebungsvariablen
|
||
|
||
Zugangsdaten stehen ausschließlich in `server/.env` - nie in der Datenbank und
|
||
nie in einer API-Antwort. Ein Datenbank-Dump enthält 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: fällig? 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? im CRM gelöscht?
|
||
Note over Y: im Papierkorb oder endgültig gelöscht? dann weiter
|
||
Note over Y: unbekannt und älter als die Grenze? dann weiter
|
||
opt Text fehlt
|
||
Y->>G: /messages/{id}?$select=body
|
||
end
|
||
Y->>D: upsert crm_mail_messages
|
||
opt neue Mail
|
||
Y->>D: Kontakt-Vorschläge anlegen
|
||
opt gesichert und Automatik an
|
||
Y->>D: Kontakt anlegen bzw. ergänzen
|
||
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 genügt
|
||
`Mail.Read`, und deshalb geht keine Mail verloren, wenn ein Mensch sie vorher
|
||
in Outlook öffnet.
|
||
* **Fortsetzbar.** Nach jeder Seite wird der Verweis gesichert. Ein Absturz oder
|
||
ein Deployment mitten im Erstabgleich kostet höchstens eine Seite.
|
||
* **Gedeckelt.** `MAIL_MAX_PAGES` begrenzt einen Lauf. Ist danach noch etwas
|
||
offen, setzt der nächste 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 nächsten 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 geändertes
|
||
Gelesen-Kennzeichen) löscht den gespeicherten Text nicht.
|
||
* **Zeitgrenze doppelt.** Der Erstabgleich versucht
|
||
`$filter=receivedDateTime ge ...`; die Filterunterstützung in Delta-Abfragen
|
||
ist eng begrenzt, bei einem 400 läuft er ohne Filter weiter. Der Filter ist
|
||
deshalb nur eine Ersparnis - geprüft wird die Grenze **immer** beim Speichern,
|
||
und zwar nur für noch unbekannte Mails. Ein an den ersten Lauf gebundener
|
||
Merker wäre hier falsch: ein ungefilterter Erstabgleich zieht sich über
|
||
mehrere Läufe, und in den folgenden käme der Rest des Postfachs trotz Grenze
|
||
herein.
|
||
|
||
Im Postfach gelöschte Mails werden mit `is_deleted = 1` gekennzeichnet, nicht
|
||
entfernt: eine an eine Opportunity gehängte Mail soll nicht aus dem CRM
|
||
verschwinden, weil jemand sein Postfach aufräumt.
|
||
|
||
## 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["Empfänger<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["Eintrag in 'Aus Mails'<br/>mail_pool = 1"]
|
||
Z -->|"Adresse dort schon"| G["Eintrag ergänzen"]
|
||
Z -->|nein| R["bleibt zur Durchsicht"]
|
||
```
|
||
|
||
Zuversicht je Herkunft: Absender 90 (95, wenn die Signatur die Adresse
|
||
bestätigt), Empfänger 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, Straße, PLZ, Ort,
|
||
Website und LinkedIn-Profil.
|
||
|
||
Zwei Entscheidungen, die die Trefferqualität tragen:
|
||
|
||
* **Im Fliesstext ist eine Anrede Pflicht** („Herrn Max Mustermann Tel. …").
|
||
Ohne diese Bedingung liest der Musterabgleich jede Folge großgeschriebener
|
||
Wörter 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 schlägt die Adresse.** Bei „Müller" plus
|
||
`k.mueller@…` wird der Vorname aus der Adresse genommen, der Nachname aber aus
|
||
dem Anzeigenamen - dort stehen die Umlaute richtig.
|
||
|
||
## Vorbereich „Aus Mails"
|
||
|
||
Der Hintergrundlauf schreibt **nicht** in den Kontaktbestand. Was er findet,
|
||
landet in einem Vorbereich: `crm_leads.mail_pool = 1`. Die Kontaktansicht zeigt
|
||
nur `mail_pool = 0`, der Vorbereich steht als Unterpunkt *Aus Mails* unter dem
|
||
Menüpunkt *Kontakte*.
|
||
|
||
Der Grund ist Pflegbarkeit: die Kontaktliste ist eine gepflegte Liste, an der
|
||
Opportunities, Interviews und Netzwerk hängen. Ein Abgleich, der ungefragt jede
|
||
Adresse aus jeder Mail hineinschreibt, macht sie unbrauchbar - genau daran ist
|
||
das Altsystem gescheitert. Deshalb sind Funde aus Mails Rohmaterial, bis jemand
|
||
entscheidet.
|
||
|
||
Eine Spalte statt einer zweiten Tabelle, weil `crm_mail_links` und
|
||
`crm_mail_contact_candidates` per Fremdschlüssel auf `crm_leads` zeigen: der
|
||
Vorbereich behält damit seine Mailbezüge, ohne dass es zwei Wege gibt, eine
|
||
Person mit einer Mail zu verbinden.
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
P["Aus Mails<br/>mail_pool = 1"] --> D{"Person schon<br/>in den Kontakten?"}
|
||
D -->|nein| NEU["In Kontakte übernehmen<br/>Kopie mit mail_pool = 0"]
|
||
D -->|"ja - Adresse oder Name"| GOLD["gold markiert<br/>Abweichungen hervorgehoben"]
|
||
GOLD --> M["Felder einzeln in den<br/>vorhandenen Kontakt übernehmen"]
|
||
GOLD --> N2["trotzdem neu anlegen"]
|
||
P --> W["entfernen"]
|
||
```
|
||
|
||
Der Abgleich gegen die Kontakte läuft im Browser (`mailPool.ts`): erst über die
|
||
E-Mail-Adresse, sonst über den vollen Namen. Telefonnummern werden dabei über
|
||
ihre Ziffern verglichen, damit „0221 123-4" und „02211234" nicht als Abweichung
|
||
erscheinen.
|
||
|
||
Beim Übernehmen sind nur die **Lücken** vorausgewählt - Felder, in denen der
|
||
Kontakt noch nichts hat. Werte, die einen gepflegten Eintrag ersetzen würden,
|
||
sind als *überschreibt* gekennzeichnet und müssen bewusst angehakt werden: der
|
||
Mailfund kann aus einer alten Signatur stammen.
|
||
|
||
Übernommene Kontakte tragen weiterhin `crm_leads.source = 'Mail'` - die
|
||
Kontaktliste hat dafür den Herkunftsfilter *Aus Mails übernommen*.
|
||
|
||
Bestandsumstellung: `db.js` verschiebt beim ersten Start alle vorhandenen
|
||
Kontakte mit `source = 'Mail'` in den Vorbereich. Das läuft genau einmal,
|
||
festgehalten im Merker `leads_mail_pool_migrated` in `crm_settings` - sonst
|
||
wanderten übernommene Kontakte bei jedem Neustart zurück.
|
||
|
||
## Anzeige des Mailtexts
|
||
|
||
Fremdes HTML läuft in einem `<iframe sandbox="">` **ohne jedes allow-Flag**. Das
|
||
Dokument bekommt eine eigene, undurchsichtige Origin und kann weder Skripte
|
||
ausführen noch auf die Anwendung zugreifen.
|
||
|
||
`allow-same-origin` wäre hier ein Fehler: ein `srcDoc`-Dokument erbt damit die
|
||
Origin der Anwendung und könnte `localStorage` lesen - also das Anmeldetoken.
|
||
Weil die Höhe des Inhalts ohne dieses Flag nicht auslesbar ist, hat der Rahmen
|
||
eine feste Höhe mit eigenem Rollbalken und einen Schalter „Größer".
|
||
|
||
Zusätzlich entfernt das Backend beim Speichern Skripte, eingebettete Rahmen,
|
||
Formulare, Ereignis-Attribute und gefährliche 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 Öffnen
|
||
dem Absender, dass die Mail gelesen wurde.
|
||
|
||
## Endpunkte
|
||
|
||
Alle unter `/api/mail`, alle hinter `requireAuth`.
|
||
|
||
| Methode | Pfad | Rolle | Zweck |
|
||
| --- | --- | --- | --- |
|
||
| GET | `/status` | alle | Betriebszustand, Zähler, letzter Fehler |
|
||
| GET | `/messages` | alle | Liste; `search`, `filter`, `limit`, `offset`, `trash` |
|
||
| GET | `/messages/:id` | alle | Mail mit Text, Zuordnungen, Vorschlägen |
|
||
| POST | `/messages/:id/links` | alle | an Kontakt und/oder Opportunity hängen |
|
||
| DELETE | `/links/:id` | alle | Zuordnung lösen |
|
||
| GET | `/candidates` | alle | Kontakt-Vorschläge, `status` wählbar |
|
||
| POST | `/candidates/:id/adopt` | alle | Kontakt anlegen bzw. ergänzen; `target: 'pool'` legt in „Aus Mails" ab |
|
||
| POST | `/candidates/:id/reject` | alle | Vorschlag verwerfen |
|
||
| DELETE | `/messages/:id` | alle | Mail in den Papierkorb legen |
|
||
| POST | `/messages/:id/restore` | alle | Mail aus dem Papierkorb zurückholen |
|
||
| POST | `/trash/empty` | alle | Papierkorb endgültig leeren |
|
||
|
||
Der Vorbereich hängt an den Kontakten und hat seine Endpunkte deshalb unter
|
||
`/api/leads`:
|
||
|
||
| Methode | Pfad | Zweck |
|
||
| --- | --- | --- |
|
||
| GET | `/leads/mail-pool/sources` | Herkunft je Eintrag: Mailanzahl, letzter Betreff, Zuversicht |
|
||
| POST | `/leads/mail-pool/promote` | mehrere Einträge als neue Kontakte anlegen |
|
||
| POST | `/leads/:id/promote` | einen Eintrag übernehmen; mit `target_lead_id` und `fields` in einen vorhandenen Kontakt |
|
||
|
||
`keep_pool: true` behält den Eintrag in „Aus Mails"; ohne das verschwindet er
|
||
dort, sobald er als Kontakt geführt wird. In beiden Fällen wandern die
|
||
Mailverknüpfungen mit an den Zielkontakt.
|
||
| GET | `/settings` | admin | verstellbare Einstellungen |
|
||
| POST | `/settings` | admin | Einstellungen speichern |
|
||
| POST | `/test` | admin | Anmeldung **und** Postfachzugriff prüfen |
|
||
| POST | `/sync` | admin | Abgleich anstoßen, `{"reset":true}` von vorn |
|
||
|
||
Lesen ist für 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-Vorschläge), `linked` (bereits zugeordnet).
|
||
`trash=true` zeigt genau den Papierkorb; ohne den Parameter ist er ausgeblendet.
|
||
|
||
## Papierkorb
|
||
|
||
Löschen heißt im CRM: aus dem CRM verschwinden lassen. Das Postfach bleibt
|
||
unberührt - Schreibrechte darauf hat die Anwendung nicht und soll sie nicht
|
||
haben. Zwei Spalten in `crm_mail_messages` tragen das:
|
||
|
||
| Spalte | Bedeutung |
|
||
| --- | --- |
|
||
| `crm_deleted_at` (+ `crm_deleted_by`) | liegt im Papierkorb, lässt sich zurückholen |
|
||
| `crm_purged` | Papierkorb geleert: Zeile bleibt als leerer Merker stehen |
|
||
|
||
Der Merker ist der Kern der Sache. Im Postfach liegt die Mail weiter, der
|
||
Delta-Abgleich liefert sie also erneut aus. `runMailSync()` prüft deshalb bei
|
||
jeder Nachricht zuerst, ob sie im CRM gelöscht ist, und lässt sie dann in Ruhe -
|
||
kein Text wird nachgeladen, keine Zeile aktualisiert. Ohne den Merker käme jede
|
||
endgültig gelöschte Mail beim nächsten Lauf zurück. Der Lauf zählt die
|
||
übersprungenen Mails in `skippedTrashed` und schreibt sie ins Protokoll.
|
||
|
||
Beim Leeren fällt zusätzlich weg:
|
||
|
||
* alle `crm_mail_links` der betroffenen Mails - was es nicht mehr gibt, kann an
|
||
keiner Opportunity und keinem Kontakt hängen,
|
||
* deren **offene** Kontakt-Vorschläge. Entschiedene bleiben: sie sind der
|
||
Nachweis, woher ein Kontakt stammt, und tragen die Herkunftsanzeige in
|
||
„Aus Mails".
|
||
|
||
Vom Inhalt bleibt nichts: Betreff, Absender, Empfänger, Vorschautext und Text
|
||
werden geleert. In der Zeile stehen danach nur noch die Graph-Kennung, ihr Hash,
|
||
der Empfangszeitpunkt und der Löschvermerk.
|
||
|
||
In den Papierkorb legen und leeren darf **jeder angemeldete Benutzer** - beim
|
||
Leeren nach einer Warnung. Der erste Schritt ist umkehrbar, der zweite nicht.
|
||
`crm_deleted_by` trägt bewusst keinen Fremdschlüssel auf `crm_users`: Spalten
|
||
lassen sich mit `ADD COLUMN IF NOT EXISTS` idempotent nachziehen, ein
|
||
Fremdschlüssel nicht.
|
||
|
||
## 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 nötig | `Mail.Read` genügt |
|
||
| Der Haken daran | `PATCH` ging auf `/me/…` - unter App-only gibt es kein `/me`. Der Aufruf lief in einen 400, die Antwort wurde nicht geprüft. Mails blieben ungelesen und wurden bei **jedem** Lauf erneut durch das Sprachmodell geschickt. | entfällt |
|
||
| 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 erfüllen | Kontakt ohne Adresse; Vorschlag bleibt zur Durchsicht liegen |
|
||
| Vorhandener Kontakt | wurde übersprungen | leere Felder werden ergänzt, gepflegte bleiben |
|
||
| Kontaktdaten lesen | kompletter Mailtext an OpenAI, auch für 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`, lösbar, mehrfach möglich |
|
||
| Verbindungstest | prüft nur, ob ein Token kommt | prüft 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 entschärft, `sandbox=""`, externe Bilder gesperrt |
|
||
| Zeitangaben | gemischt | durchgehend UTC in der Datenbank, Umrechnung im Frontend |
|
||
|
||
## Berater-Mail bei neuem Projekt
|
||
|
||
Seit 21.10.2026 verschickt das CRM selbst Mails - bisher nur eine Sorte: Wird in
|
||
der Akquise ein Projekt eingetragen und dabei ein Berater ausgewählt, bekommt
|
||
dieser eine Mail: Betreff „Du wurdest beworben: ‹Titel›", im Text „wir haben
|
||
dich auf folgendes Projekt beworben", dazu Titel, Endkunde, Anbieter, Kanal,
|
||
Tagessatz, Zeitraum, der **Link zum Projekt** ausgeschrieben, wer beworben hat
|
||
und die Beschreibung (gekürzt auf 1500 Zeichen).
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant B as Browser
|
||
participant API as POST/PATCH /api/acquisitions
|
||
participant N as acquisitionNotify.js
|
||
participant G as Graph sendMail
|
||
B->>API: Eintrag mit Berater speichern
|
||
API-->>B: 201 / 200 (sofort)
|
||
API->>N: notifyConsultants(eintrag, neue Berater, benutzer)
|
||
N->>N: Adresse + Schalter je Berater aus crm_acquisition_options
|
||
N->>G: POST /users/crm@perinco.de/sendMail
|
||
```
|
||
|
||
* **Absender** ist `MS_MAILBOX` (crm@perinco.de), Kopie in *Gesendete
|
||
Elemente*. **Reply-To** ist der Benutzer, der den Eintrag gespeichert hat -
|
||
eine Antwort des Beraters landet also bei ihm, nicht im CRM-Postfach.
|
||
* **Wer** eine Mail bekommt: Beim Anlegen alle ausgewählten Berater, beim
|
||
Bearbeiten nur die neu hinzugekommenen. Voraussetzung je Berater: Adresse
|
||
hinterlegt und Schalter *Mail bei neuem Projekt* an (*Einstellungen →
|
||
Akquise*). Der Schalter war bei Einführung für alle an.
|
||
* **Fehler** brechen das Speichern nie ab. Der Versand läuft nach der Antwort an
|
||
den Browser; Erfolg und Fehler stehen im Log:
|
||
`journalctl -u perinco-crm-backend | grep 'Akquise] Mail'`.
|
||
* **Ohne M365-Anbindung** (Secret fehlt) wird nur eine Warnung geloggt.
|
||
* Die `ApplicationAccessPolicy` (siehe oben) gilt auch für `Mail.Send` - die App
|
||
darf also nur aus crm@perinco.de senden, nicht im Namen anderer.
|
||
|
||
## Was noch nicht drin ist
|
||
|
||
* **Antworten, Weiterleiten aus dem Postfachreiter.** Senden geht seit der
|
||
Berater-Mail (`Mail.Send`); Antworten auf eine konkrete Mail bräuchte
|
||
zusätzlich `Mail.ReadWrite`.
|
||
* **Anhänge.** `has_attachments` wird geführt und angezeigt, die Dateien
|
||
selbst werden nicht geholt.
|
||
* **Automatische Zuordnung zu Opportunities.** Die Zuordnung erfolgt per Hand;
|
||
der Dialog schlägt anhand der Mailadressen Kontakt und Opportunity vor. Eine
|
||
Bewertung durch ein Sprachmodell wäre der nächste Schritt - dann bitte mit
|
||
dem Schutz gegen Prompt-Injection, der im Altsystem in `mail-sync` fehlte.
|
||
* **Echtzeit statt Intervall.** Graph Change Notifications brauchen einen
|
||
öffentlich erreichbaren Endpunkt (haben wir) und eine Erneuerung der
|
||
Subscription etwa alle drei Tage.
|
||
|
||
## Störungssuche
|
||
|
||
| Beobachtung | Ursache und Abhilfe |
|
||
| --- | --- |
|
||
| Einstellungen melden fehlende Variablen | `server/.env` ergänzen, 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 läuft noch" bleibt stehen | Log auf `[Mail]` prüfen; `MAIL_MAX_PAGES` erhöhen, wenn es zu langsam vorangeht |
|
||
| Keine Kontakt-Vorschläge | stehen alle Absender auf einer eigenen Domain? `mail_internal_domains` prüfen |
|
||
| Doppelte Kontakte | tritt nicht auf: Abgleich erfolgt über die Adresse in Kleinschreibung; bei Treffer wird ergänzt statt angelegt |
|
||
| Zähler und Liste weichen ab | die Liste blendet im Postfach gelöschte Mails aus, der Zähler in den Einstellungen ebenfalls - Zuordnungen bleiben trotzdem erhalten |
|