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:
Sascha Wolf
2026-08-11 00:06:01 +02:00
co-authored by Claude Opus 5
parent 3647201111
commit 6fd713497d
42 changed files with 5926 additions and 32 deletions
+145
View File
@@ -151,3 +151,148 @@ CREATE TABLE IF NOT EXISTS crm_event_participants (
CONSTRAINT fk_crm_ep_event FOREIGN KEY (event_id) REFERENCES crm_events (id) ON DELETE CASCADE,
CONSTRAINT fk_crm_ep_user FOREIGN KEY (user_id) REFERENCES crm_users (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ===========================================================================
-- Allgemeine Einstellungen des CRM als Schluessel/Wert. Bewusst ohne
-- Geheimnisse: Zugangsdaten stehen ausschliesslich in der .env auf dem Server,
-- damit ein Datenbank-Dump keine Secrets enthaelt.
-- ===========================================================================
CREATE TABLE IF NOT EXISTS crm_settings (
key_name VARCHAR(100) NOT NULL,
value TEXT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (key_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ===========================================================================
-- M365-Postfachanbindung (crm@perinco.de)
-- ===========================================================================
/*
* Gespiegelte Nachrichten aus dem Postfach. Die Graph-ID ist zu lang fuer einen
* Unique-Index (utf8mb4), deshalb liegt daneben ihr SHA-256 als Hex - der traegt
* die Eindeutigkeit. Zeiten stehen in UTC, so wie Graph sie liefert. Die
* Umrechnung macht das Frontend.
*
* ROW_FORMAT=DYNAMIC steht ausdruecklich da: bei COMPACT legt InnoDB von jeder
* langen Spalte 768 Bytes in die Zeile, und die Summe aus mehreren VARCHAR(512+)
* und fuenf LONGTEXT reisst dann die Zeilengrenze.
*/
CREATE TABLE IF NOT EXISTS crm_mail_messages (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
graph_id VARCHAR(1024) NOT NULL,
graph_id_hash CHAR(64) NOT NULL,
internet_message_id VARCHAR(512) NULL,
conversation_id VARCHAR(512) NULL,
subject VARCHAR(512) NULL,
from_name VARCHAR(190) NULL,
from_email VARCHAR(190) NULL,
to_json LONGTEXT NULL, -- [{name,email}] als JSON-Text
cc_json LONGTEXT NULL,
bcc_json LONGTEXT NULL,
received_at DATETIME NOT NULL, -- UTC
sent_at DATETIME NULL, -- UTC
body_preview VARCHAR(512) NULL,
body_text LONGTEXT NULL, -- aus HTML gewandelt, Zeilen erhalten
body_html LONGTEXT NULL, -- entschaerftes HTML fuer die Anzeige
has_attachments TINYINT(1) NOT NULL DEFAULT 0,
importance VARCHAR(16) NULL,
web_link VARCHAR(1024) NULL, -- Outlook-Web-Link
is_read_in_mailbox TINYINT(1) NOT NULL DEFAULT 0,
is_deleted TINYINT(1) NOT NULL DEFAULT 0, -- im Postfach geloescht
first_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_crm_mail_graph (graph_id_hash),
KEY idx_crm_mail_received (received_at),
KEY idx_crm_mail_from (from_email),
KEY idx_crm_mail_conversation (conversation_id(191))
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*
* Verknuepfung einer Mail mit Kontakt und/oder Opportunity. Absichtlich eine
* eigene Tabelle statt eines Textanhangs im Notizfeld: so laesst sich eine
* Verknuepfung wieder loesen, und dieselbe Mail kann an mehreren Stellen haengen.
* Mehrere NULL-Werte sind in einem Unique-Index erlaubt - damit sperren die
* beiden Schluessel genau die Doppelung desselben Paares.
*/
CREATE TABLE IF NOT EXISTS crm_mail_links (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
message_id INT UNSIGNED NOT NULL,
lead_id INT UNSIGNED NULL,
opportunity_id INT UNSIGNED NULL,
origin ENUM('manuell','automatisch') NOT NULL DEFAULT 'manuell',
note VARCHAR(500) NULL,
created_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_crm_mail_link_lead (message_id, lead_id),
UNIQUE KEY uq_crm_mail_link_opp (message_id, opportunity_id),
KEY idx_crm_mail_link_lead (lead_id),
KEY idx_crm_mail_link_opp (opportunity_id),
CONSTRAINT fk_crm_ml_message FOREIGN KEY (message_id) REFERENCES crm_mail_messages (id) ON DELETE CASCADE,
CONSTRAINT fk_crm_ml_lead FOREIGN KEY (lead_id) REFERENCES crm_leads (id) ON DELETE CASCADE,
CONSTRAINT fk_crm_ml_opp FOREIGN KEY (opportunity_id) REFERENCES crm_opportunities (id) ON DELETE CASCADE,
CONSTRAINT fk_crm_ml_user FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*
* Kontakt-Vorschlaege aus Mails. Bewusst NICHT direkt als Kontakt angelegt:
* das Altsystem hat dafuer Platzhalter-Adressen wie name@kontakt-aus-email.local
* erfunden und damit die Kontaktliste vermuellt. Hier entsteht ein Kontakt erst
* bei der Uebernahme - automatisch nur, wenn eine echte E-Mail-Adresse vorliegt.
*/
CREATE TABLE IF NOT EXISTS crm_mail_contact_candidates (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
message_id INT UNSIGNED NOT NULL,
dedupe_hash CHAR(64) NOT NULL, -- aus E-Mail bzw. Name+Telefon
first_name VARCHAR(120) NULL,
last_name VARCHAR(120) NULL,
salutation VARCHAR(32) NULL,
email VARCHAR(190) NULL,
phone VARCHAR(64) NULL,
mobile VARCHAR(64) NULL,
company VARCHAR(190) NULL,
position VARCHAR(190) NULL,
street VARCHAR(190) NULL,
zip VARCHAR(24) NULL,
city VARCHAR(120) NULL,
website VARCHAR(255) NULL,
linkedin VARCHAR(255) NULL,
origin ENUM('absender','empfaenger','signatur','text') NOT NULL DEFAULT 'text',
confidence TINYINT UNSIGNED NOT NULL DEFAULT 50, -- 0-100
status ENUM('offen','uebernommen','verworfen','dublette') NOT NULL DEFAULT 'offen',
lead_id INT UNSIGNED NULL, -- gesetzt bei uebernommen und dublette
decided_by INT UNSIGNED NULL,
decided_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_crm_mail_cand (message_id, dedupe_hash),
KEY idx_crm_mail_cand_status (status),
KEY idx_crm_mail_cand_email (email),
CONSTRAINT fk_crm_mc_message FOREIGN KEY (message_id) REFERENCES crm_mail_messages (id) ON DELETE CASCADE,
CONSTRAINT fk_crm_mc_lead FOREIGN KEY (lead_id) REFERENCES crm_leads (id) ON DELETE SET NULL,
CONSTRAINT fk_crm_mc_user FOREIGN KEY (decided_by) REFERENCES crm_users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*
* Zustand der Delta-Synchronisation - genau eine Zeile. `delta_link` ist die
* URL, die Graph fuer den naechsten Aufruf zurueckgegeben hat. `has_more`
* unterscheidet nextLink (Seite folgt noch) von deltaLink (aktueller Stand).
* Damit ueberlebt der Abgleich Neustarts und braucht keinen Schreibzugriff auf
* das Postfach - anders als der isRead-Ansatz des Altsystems.
*/
CREATE TABLE IF NOT EXISTS crm_mail_sync_state (
id TINYINT UNSIGNED NOT NULL DEFAULT 1,
mailbox VARCHAR(190) NULL,
delta_link TEXT NULL,
has_more TINYINT(1) NOT NULL DEFAULT 0,
last_run_at DATETIME NULL,
last_success_at DATETIME NULL,
last_error TEXT NULL,
last_new_messages INT UNSIGNED NOT NULL DEFAULT 0,
last_candidates INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO crm_mail_sync_state (id) VALUES (1);