Files
perinco-CRM/docs/11-postfach.md
T
Sascha WolfandClaude Opus 5 ea55acdb5c feat(kontakte): Vorbereich "Aus Mails" mit Dublettenabgleich
Der Postfachabgleich hat gefundene Personen bisher direkt in den
Kontaktbestand geschrieben. Damit wächst eine gepflegte Liste ungefragt
mit jeder Adresse aus jeder Mail - genau daran ist das Altsystem
gescheitert.

Funde landen jetzt in einem Vorbereich (crm_leads.mail_pool = 1), der als
Unterpunkt "Aus Mails" unter dem Menüpunkt Kontakte aufklappt. Der
Kontaktbestand bleibt unberührt, bis dort jemand entscheidet; in
Opportunities, Kalender und Netzwerk tauchen die Funde nicht auf.

Eine Spalte statt einer zweiten Tabelle, weil crm_mail_links und
crm_mail_contact_candidates per Fremdschlüssel auf crm_leads zeigen - so
behält der Vorbereich seine Mailbezüge.

Übernehmen kennt zwei Wege:
- neue Person: Kopie mit mail_pool = 0, die Mailverknüpfungen wandern mit
- Dublette (Adresse, sonst voller Name): gold markiert, abweichende
  Felder mit altem und neuem Wert; die Feldauswahl schreibt sie in den
  vorhandenen Kontakt. Vorausgewählt sind nur Lücken - ein Wert aus einer
  alten Signatur soll nichts Gepflegtes stillschweigend ersetzen.

db.js verschiebt beim ersten Start die vorhandenen Kontakte mit
source = 'Mail' in den Vorbereich, einmalig über den Merker
leads_mail_pool_migrated in crm_settings.

Die Sidebar-Gruppe ist dabei generisch geworden (MenuGroup) - Kontakte
und Scanner benutzen jetzt denselben Code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:59:14 +02:00

17 KiB

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 und sind der Grund fuer mehrere Entwurfsentscheidungen.

Ueberblick

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:

    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

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?
        Note over Y: unbekannt und aelter 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-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.
  • Zeitgrenze doppelt. Der Erstabgleich versucht $filter=receivedDateTime ge ...; die Filterunterstuetzung in Delta-Abfragen ist eng begrenzt, bei einem 400 laeuft er ohne Filter weiter. Der Filter ist deshalb nur eine Ersparnis - geprueft wird die Grenze immer beim Speichern, und zwar nur fuer noch unbekannte Mails. Ein an den ersten Lauf gebundener Merker waere hier falsch: ein ungefilterter Erstabgleich zieht sich ueber mehrere Laeufe, und in den folgenden kaeme der Rest des Postfachs trotz Grenze herein.

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.

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 &ge; 70?"}
    Z -->|ja, Automatik an| N["Eintrag in 'Aus Mails'<br/>mail_pool = 1"]
    Z -->|"Adresse dort schon"| G["Eintrag 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.

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 Menuepunkt Kontakte.

Der Grund ist Pflegbarkeit: die Kontaktliste ist eine gepflegte Liste, an der Opportunities, Kalender und Netzwerk haengen. 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 Fremdschluessel auf crm_leads zeigen: der Vorbereich behaelt damit seine Mailbezuege, ohne dass es zwei Wege gibt, eine Person mit einer Mail zu verbinden.

flowchart LR
    P["Aus Mails<br/>mail_pool = 1"] --> D{"Person schon<br/>in den Kontakten?"}
    D -->|nein| NEU["In Kontakte uebernehmen<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 uebernehmen"]
    GOLD --> N2["trotzdem neu anlegen"]
    P --> W["entfernen"]

Der Abgleich gegen die Kontakte laeuft im Browser (mailPool.ts): erst ueber die E-Mail-Adresse, sonst ueber den vollen Namen. Telefonnummern werden dabei ueber ihre Ziffern verglichen, damit „0221 123-4" und „02211234" nicht als Abweichung erscheinen.

Beim Uebernehmen sind nur die Luecken vorausgewaehlt - Felder, in denen der Kontakt noch nichts hat. Werte, die einen gepflegten Eintrag ersetzen wuerden, sind als ueberschreibt gekennzeichnet und muessen bewusst angehakt werden: der Mailfund kann aus einer alten Signatur stammen.

Uebernommene Kontakte tragen weiterhin crm_leads.source = 'Mail' - die Kontaktliste hat dafuer den Herkunftsfilter Aus Mails uebernommen.

Bestandsumstellung: db.js verschiebt beim ersten Start alle vorhandenen Kontakte mit source = 'Mail' in den Vorbereich. Das laeuft genau einmal, festgehalten im Merker leads_mail_pool_migrated in crm_settings - sonst wanderten uebernommene Kontakte bei jedem Neustart zurueck.

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; target: 'pool' legt in „Aus Mails" ab
POST /candidates/:id/reject alle Vorschlag verwerfen

Der Vorbereich haengt 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 Eintraege als neue Kontakte anlegen
POST /leads/:id/promote einen Eintrag uebernehmen; mit target_lead_id und fields in einen vorhandenen Kontakt

keep_pool: true behaelt den Eintrag in „Aus Mails"; ohne das verschwindet er dort, sobald er als Kontakt gefuehrt wird. In beiden Faellen wandern die Mailverknuepfungen mit an den Zielkontakt. | 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