Das Secret der App-Registrierung laeuft in Entra ab. Bisher hiess das: jemand
mit SSH-Zugang aendert die .env und startet den Dienst neu - und niemand sieht
vorher, wann es soweit ist. Laeuft es aus, stehen Anmeldung UND
Postfachabgleich still, denn beide haengen an derselben Registrierung.
Neu unter Einstellungen > Anmeldung: Secret eintragen, Ablaufdatum hinterlegen,
Restlaufzeit ablesen. Ab 30 Tagen vor dem Ablauf bekommen Administratoren ueber
jeder Ansicht ein Hinweisband, das direkt dorthin fuehrt; nach dem Ablauf wird
es rot. Der Hinweis reist mit /api/badges mit, das die App ohnehin bei jedem
Laden abruft.
Bewusst so entschieden:
- Das Secret muss dafuer in die Datenbank, entgegen der bisherigen Regel
"keine Geheimnisse in crm_settings". Es liegt deshalb AES-256-GCM-
verschluesselt dort; der Schluessel kommt aus CRM_ENCRYPTION_KEY, ersatz-
weise JWT_SECRET, und steht damit nie im selben Dump.
- Vor dem Speichern holt das CRM mit dem eingegebenen Wert ein Token bei
Microsoft. Ein Tippfehler wird gar nicht erst uebernommen.
- Gelesen wird Datenbank vor .env; der .env-Wert bleibt Rueckfallebene, und
"Hinterlegtes Secret verwerfen" schaltet dorthin zurueck.
- Passt der Schluessel nicht mehr, meldet die Oberflaeche das ausdruecklich,
statt still auf die .env zurueckzufallen - sonst sucht man den Fehler an
der falschen Stelle.
- Das Ablaufdatum wird von Hand eingetragen. Aus Entra lesen braeuchte
Application.Read.All, die die App bewusst nicht hat.
MAIL_CONFIGURED und SSO_CONFIGURED sind dadurch von Konstanten zu den
Funktionen mailConfigured() und ssoConfigured() geworden - die Pruefung muss
jetzt in die Datenbank sehen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
415 lines
20 KiB
Markdown
415 lines
20 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.
|
|
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 |
|
|
|
|
## Was noch nicht drin ist
|
|
|
|
* **Senden, Antworten, Weiterleiten.** Braucht `Mail.ReadWrite` und `Mail.Send`
|
|
und damit eine erneute Administratorzustimmung.
|
|
* **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 |
|