Files
Sascha WolfandClaude Opus 5.5 019fa1a251 fix(akquise): Berater-Mail ohne Link ins CRM
Die Berater haben keinen Zugang zum CRM. Die Mail verlinkt nur noch die
Ausschreibung, falls hinterlegt; der Hinweis auf die Einstellungen in der
Fusszeile entfaellt ebenso. CRM_PUBLIC_URL wird nicht mehr gebraucht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:04:23 +02:00

457 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 &rarr; *Anwendungsberechtigungen* &rarr;
`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 &rarr;
Postfach* erreichbar: `mail_sync_active`, `mail_sync_interval`,
`mail_auto_contacts`, `mail_since_days`, `mail_internal_domains`.
## Abgleich per Delta Query
```mermaid
sequenceDiagram
participant S as scheduler.js
participant Y as sync.js
participant G as Graph
participant D as MariaDB
S->>D: last_run_at, has_more
Note over S: 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 &ge; 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). Ist kein Link hinterlegt,
fehlt die Zeile. **Einen Link ins CRM enthält die Mail bewusst nicht** - die
Berater haben keinen Zugang zum CRM.
```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 |