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
+217
View File
@@ -0,0 +1,217 @@
import { pool } from '../db.js';
/*
* Datenbankzugriffe des Mailabgleichs. Lesende Abfragen der Oberflaeche stehen
* wie im uebrigen Backend direkt in routes/mail.js - hier liegt nur, was sowohl
* der Hintergrundlauf als auch die Routen brauchen.
*/
/** Zustandszeile des Abgleichs (immer id = 1). */
export async function getSyncState() {
const [rows] = await pool.query('SELECT * FROM crm_mail_sync_state WHERE id = 1 LIMIT 1');
if (rows[0]) return rows[0];
// Sollte die Startzeile fehlen (fremder Dump), hier nachziehen.
await pool.query('INSERT IGNORE INTO crm_mail_sync_state (id) VALUES (1)');
const [again] = await pool.query('SELECT * FROM crm_mail_sync_state WHERE id = 1 LIMIT 1');
return again[0];
}
const SYNC_STATE_FIELDS = new Set([
'mailbox',
'delta_link',
'has_more',
'last_run_at',
'last_success_at',
'last_error',
'last_new_messages',
'last_candidates',
]);
/** Teilweises Aktualisieren der Zustandszeile. */
export async function saveSyncState(patch) {
const entries = Object.entries(patch).filter(([key]) => SYNC_STATE_FIELDS.has(key));
if (entries.length === 0) return;
await pool.query(
`UPDATE crm_mail_sync_state SET ${entries.map(([key]) => `${key} = ?`).join(', ')} WHERE id = 1`,
entries.map(([, value]) => value)
);
}
/** Vollstaendigen Neuaufbau anstossen: Delta-Verweis verwerfen. */
export async function resetDelta() {
await saveSyncState({ delta_link: null, has_more: 0 });
}
/**
* Ist die Mail schon bekannt, und liegt ihr Text vor? Der Delta-Abgleich holt
* absichtlich nur Metadaten; der Mailtext wird einzeln nachgeladen und nur dann,
* wenn er wirklich fehlt.
*/
export async function getMessageStateByHash(graphIdHash) {
const [rows] = await pool.query(
`SELECT id, (body_text IS NOT NULL AND body_text <> '') AS has_body
FROM crm_mail_messages WHERE graph_id_hash = ? LIMIT 1`,
[graphIdHash]
);
if (!rows[0]) return null;
return { id: rows[0].id, hasBody: Boolean(Number(rows[0].has_body)) };
}
/**
* Nachricht anlegen oder aktualisieren. Bewusst mit vorherigem SELECT statt
* ON DUPLICATE KEY UPDATE: so ist eindeutig, ob es eine neue Mail war - nur dann
* werden Kontakt-Vorschlaege erzeugt.
*
* Fehlen die Textfelder im uebergebenen Objekt (undefined), bleiben die
* gespeicherten Werte unangetastet - ein reines Metadaten-Update darf den
* bereits geholten Mailtext nicht loeschen.
*/
export async function upsertMessage(message) {
const [existing] = await pool.query(
'SELECT id FROM crm_mail_messages WHERE graph_id_hash = ? LIMIT 1',
[message.graphIdHash]
);
const columns = {
graph_id: message.graphId,
graph_id_hash: message.graphIdHash,
internet_message_id: message.internetMessageId,
conversation_id: message.conversationId,
subject: message.subject,
from_name: message.fromName,
from_email: message.fromEmail,
to_json: JSON.stringify(message.to ?? []),
cc_json: JSON.stringify(message.cc ?? []),
bcc_json: JSON.stringify(message.bcc ?? []),
received_at: message.receivedAt,
sent_at: message.sentAt,
body_preview: message.bodyPreview,
body_text: message.bodyText,
body_html: message.bodyHtml,
has_attachments: message.hasAttachments ? 1 : 0,
importance: message.importance,
web_link: message.webLink,
is_read_in_mailbox: message.isReadInMailbox ? 1 : 0,
// Wiederaufgetauchte Mail gilt wieder als vorhanden.
is_deleted: 0,
};
if (message.bodyText === undefined) {
delete columns.body_text;
delete columns.body_html;
delete columns.body_preview;
}
if (existing[0]) {
const keys = Object.keys(columns).filter((key) => key !== 'graph_id_hash');
await pool.query(
`UPDATE crm_mail_messages SET ${keys.map((key) => `${key} = ?`).join(', ')} WHERE id = ?`,
[...keys.map((key) => columns[key]), existing[0].id]
);
return { id: existing[0].id, isNew: false };
}
const keys = Object.keys(columns);
const [result] = await pool.query(
`INSERT INTO crm_mail_messages (${keys.join(', ')})
VALUES (${keys.map(() => '?').join(', ')})`,
keys.map((key) => columns[key])
);
return { id: result.insertId, isNew: true };
}
/**
* Im Postfach geloeschte Mail markieren, nicht entfernen: eine bereits an eine
* Opportunity gehaengte Mail soll nicht aus dem CRM verschwinden, nur weil
* jemand das Postfach aufraeumt.
*/
export async function markMessageDeleted(graphIdHash) {
const [result] = await pool.query(
'UPDATE crm_mail_messages SET is_deleted = 1 WHERE graph_id_hash = ?',
[graphIdHash]
);
return result.affectedRows > 0;
}
const CANDIDATE_COLUMNS = [
'message_id',
'dedupe_hash',
'first_name',
'last_name',
'salutation',
'email',
'phone',
'mobile',
'company',
'position',
'street',
'zip',
'city',
'website',
'linkedin',
'origin',
'confidence',
];
/**
* Vorschlaege zu einer Mail speichern. INSERT IGNORE zusammen mit dem
* Unique-Schluessel (message_id, dedupe_hash) macht den Aufruf wiederholbar.
* Zurueck kommen die tatsaechlich neu angelegten Zeilen.
*/
export async function insertCandidates(messageId, candidates) {
const created = [];
for (const candidate of candidates) {
const values = [
messageId,
candidate.dedupeHash,
candidate.firstName,
candidate.lastName,
candidate.salutation,
candidate.email,
candidate.phone,
candidate.mobile,
candidate.company,
candidate.position,
candidate.street,
candidate.zip,
candidate.city,
candidate.website,
candidate.linkedin,
candidate.origin,
candidate.confidence,
];
const [result] = await pool.query(
`INSERT IGNORE INTO crm_mail_contact_candidates (${CANDIDATE_COLUMNS.join(', ')})
VALUES (${CANDIDATE_COLUMNS.map(() => '?').join(', ')})`,
values
);
if (result.affectedRows > 0) created.push({ id: result.insertId, ...candidate });
}
return created;
}
/** Bestehenden Kontakt zu einer Adresse finden (Gross-/Kleinschreibung egal). */
export async function findLeadByEmail(email) {
if (!email) return null;
const [rows] = await pool.query(
'SELECT id, first_name, last_name FROM crm_leads WHERE LOWER(email) = LOWER(?) ORDER BY id LIMIT 1',
[email]
);
return rows[0] ?? null;
}
/** Verknuepfung Mail <-> Kontakt/Opportunity; doppelte Paare werden ignoriert. */
export async function linkMessage({ messageId, leadId = null, opportunityId = null, origin = 'manuell', note = null, userId = null }) {
const [result] = await pool.query(
`INSERT IGNORE INTO crm_mail_links (message_id, lead_id, opportunity_id, origin, note, created_by)
VALUES (?, ?, ?, ?, ?, ?)`,
[messageId, leadId, opportunityId, origin, note, userId]
);
return result.affectedRows > 0;
}