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:
co-authored by
Claude Opus 5
parent
3647201111
commit
6fd713497d
@@ -25,6 +25,42 @@ ADMIN_PASSWORD=changeme-starkes-passwort
|
||||
# Das Diktat braucht hier keine Konfiguration: Whisper laeuft im Browser des
|
||||
# Benutzers, eingestellt wird es im Reiter "Whisper" der Anwendung.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Postfachanbindung Microsoft 365 (crm@perinco.de)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lesender Zugriff ueber Microsoft Graph mit App-only-Berechtigung
|
||||
# (Client Credentials). Benoetigt wird in Entra genau eine
|
||||
# Anwendungsberechtigung: Mail.Read, mit Administratorzustimmung. Kein
|
||||
# Schreibzugriff, kein Redirect-URI, kein interaktiver Login.
|
||||
#
|
||||
# Fehlen diese Werte, startet das CRM normal - die Postfachanbindung meldet in
|
||||
# den Einstellungen, welche Angabe fehlt, und der Abgleich bleibt aus.
|
||||
#
|
||||
# In Exchange Online zusaetzlich den Zugriff auf dieses eine Postfach begrenzen,
|
||||
# sonst darf die App jedes Postfach im Tenant lesen:
|
||||
# New-ApplicationAccessPolicy -AppId <MS_CLIENT_ID> `
|
||||
# -PolicyScopeGroupId crm@perinco.de -AccessRight RestrictAccess `
|
||||
# -Description "perinco CRM: nur crm@perinco.de"
|
||||
MS_TENANT_ID=
|
||||
MS_CLIENT_ID=
|
||||
MS_CLIENT_SECRET=
|
||||
MS_MAILBOX=crm@perinco.de
|
||||
|
||||
# Ordner im Postfach. "inbox" ist ein von Graph vorgegebener Name.
|
||||
# MS_MAIL_FOLDER=inbox
|
||||
|
||||
# Hintergrundabgleich. Standard ist AN; nur "false" schaltet ihn hart ab.
|
||||
# Intervall, Zeitraum und automatische Kontaktuebernahme stehen dagegen in den
|
||||
# Einstellungen der Anwendung (Tabelle crm_settings).
|
||||
MAIL_SYNC=true
|
||||
|
||||
# Bremse fuer den Erstabgleich: so viele Delta-Seiten holt ein Lauf hoechstens.
|
||||
# Der naechste Lauf setzt an derselben Stelle fort.
|
||||
# MAIL_MAX_PAGES=20
|
||||
|
||||
# Obergrenze fuer den gespeicherten Mailtext je Nachricht (Zeichen).
|
||||
# MAIL_MAX_BODY_CHARS=400000
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Projekt Scanner (portiert, laeuft parallel zur alten App)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+2
-1
@@ -12,7 +12,8 @@
|
||||
"start": "node src/server.js",
|
||||
"dev": "node --watch src/server.js",
|
||||
"migrate": "node src/migrate.js",
|
||||
"scanner:import": "node src/scanner/importLegacy.js"
|
||||
"scanner:import": "node src/scanner/importLegacy.js",
|
||||
"test": "node test/mail.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { getSettings, asBool, asInt } from '../settings.js';
|
||||
|
||||
/*
|
||||
* Konfiguration der M365-Anbindung.
|
||||
*
|
||||
* Zugangsdaten kommen ausschliesslich aus der .env und werden nie an das
|
||||
* Frontend gegeben - im Altsystem schickte der Browser Tenant- und Client-ID
|
||||
* mit, wodurch jeder Angemeldete das Server-Secret gegen einen fremden Tenant
|
||||
* verwenden konnte. Hier ist das serverseitig festgenagelt.
|
||||
*
|
||||
* Alles, was im Betrieb verstellbar sein soll (Intervall, Schalter, Zeitraum),
|
||||
* liegt dagegen in crm_settings und ist ueber die Einstellungen erreichbar.
|
||||
*/
|
||||
|
||||
export const TENANT_ID = (process.env.MS_TENANT_ID || '').trim();
|
||||
export const CLIENT_ID = (process.env.MS_CLIENT_ID || '').trim();
|
||||
const CLIENT_SECRET = (process.env.MS_CLIENT_SECRET || '').trim();
|
||||
export const MAILBOX = (process.env.MS_MAILBOX || 'crm@perinco.de').trim().toLowerCase();
|
||||
|
||||
/** Ordner im Postfach, der abgeglichen wird. `inbox` ist ein Wellknown-Name. */
|
||||
export const MAIL_FOLDER = (process.env.MS_MAIL_FOLDER || 'inbox').trim();
|
||||
|
||||
/** Harter Aus-Schalter fuer den Hintergrundabgleich, unabhaengig von der DB. */
|
||||
export const SCHEDULER_ENABLED = process.env.MAIL_SYNC !== 'false';
|
||||
|
||||
/** Ohne diese vier Werte ist die Anbindung nicht betriebsbereit. */
|
||||
export const MAIL_CONFIGURED = Boolean(TENANT_ID && CLIENT_ID && CLIENT_SECRET && MAILBOX);
|
||||
|
||||
export function clientSecret() {
|
||||
if (!CLIENT_SECRET) throw new Error('MS_CLIENT_SECRET ist nicht gesetzt.');
|
||||
return CLIENT_SECRET;
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Zugangsdaten fehlen - fuer eine brauchbare Meldung in der Oberflaeche,
|
||||
* ohne Werte zu verraten.
|
||||
*/
|
||||
export function missingCredentials() {
|
||||
const missing = [];
|
||||
if (!TENANT_ID) missing.push('MS_TENANT_ID');
|
||||
if (!CLIENT_ID) missing.push('MS_CLIENT_ID');
|
||||
if (!CLIENT_SECRET) missing.push('MS_CLIENT_SECRET');
|
||||
if (!MAILBOX) missing.push('MS_MAILBOX');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verstellbare Einstellungen mit ihren Vorgaben. Die Schluessel sind gleichzeitig
|
||||
* die Positivliste des Einstellungs-Endpunkts - andere Namen werden verworfen.
|
||||
*/
|
||||
export const MAIL_SETTING_DEFAULTS = {
|
||||
/** Hauptschalter des Abgleichs. */
|
||||
mail_sync_active: 'true',
|
||||
/** Minuten zwischen zwei Laeufen. */
|
||||
mail_sync_interval: '10',
|
||||
/** Kontakt-Vorschlaege mit echter E-Mail-Adresse automatisch uebernehmen. */
|
||||
mail_auto_contacts: 'true',
|
||||
/** Beim ersten Lauf nur Mails ab so vielen Tagen zuruecklesen. */
|
||||
mail_since_days: '30',
|
||||
/**
|
||||
* Absender/Empfaenger dieser Domains werden nicht als Kontakt vorgeschlagen -
|
||||
* die eigenen Leute stehen schon in der Benutzerverwaltung.
|
||||
*/
|
||||
mail_internal_domains: 'perinco.de,perinco.onmicrosoft.com',
|
||||
};
|
||||
|
||||
/** Eingelesene und getypte Einstellungen. */
|
||||
export async function mailSettings() {
|
||||
const raw = await getSettings(MAIL_SETTING_DEFAULTS);
|
||||
return {
|
||||
raw,
|
||||
active: asBool(raw.mail_sync_active, true),
|
||||
intervalMinutes: asInt(raw.mail_sync_interval, 10, { min: 2, max: 1440 }),
|
||||
autoContacts: asBool(raw.mail_auto_contacts, true),
|
||||
sinceDays: asInt(raw.mail_since_days, 30, { min: 1, max: 3650 }),
|
||||
internalDomains: String(raw.mail_internal_domains || '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim().toLowerCase().replace(/^@/, ''))
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
/** Wie viele Delta-Seiten ein Lauf maximal holt - Bremse fuer den Erstabgleich. */
|
||||
export const MAX_PAGES_PER_RUN = asInt(process.env.MAIL_MAX_PAGES, 20, { min: 1, max: 500 });
|
||||
|
||||
/** Obergrenze fuer gespeicherte Mailtexte, damit einzelne Mails die DB nicht sprengen. */
|
||||
export const MAX_BODY_CHARS = asInt(process.env.MAIL_MAX_BODY_CHARS, 400_000, {
|
||||
min: 10_000,
|
||||
max: 4_000_000,
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { pool } from '../db.js';
|
||||
import { findLeadByEmail, linkMessage } from './store.js';
|
||||
|
||||
/*
|
||||
* Aus einem Kontakt-Vorschlag einen Kontakt machen.
|
||||
*
|
||||
* Zwei Dinge macht das bewusst anders als das Altsystem:
|
||||
*
|
||||
* 1. Es werden keine Platzhalter-Adressen erfunden. Wer keine E-Mail-Adresse hat,
|
||||
* wird angelegt ohne - das Feld ist in crm_leads ohnehin optional. Das
|
||||
* Altsystem hat "name@kontakt-aus-email.local" eingetragen, um eine
|
||||
* Datenbankbedingung zu befriedigen, und damit unbrauchbare Adressen erzeugt.
|
||||
*
|
||||
* 2. Ein bereits vorhandener Kontakt wird nicht ein zweites Mal angelegt, sondern
|
||||
* ergaenzt: leere Felder werden gefuellt, gepflegte Werte bleiben unangetastet.
|
||||
*/
|
||||
|
||||
/** Herkunftskennzeichen in crm_leads.source - traegt die Unterkategorie. */
|
||||
export const MAIL_LEAD_SOURCE = 'Mail';
|
||||
|
||||
/** Felder, die aus einem Vorschlag in einen leeren Kontakt nachgetragen werden. */
|
||||
const ENRICHABLE = ['company', 'position', 'phone', 'mobile', 'street', 'zip', 'city', 'website', 'linkedin', 'salutation'];
|
||||
|
||||
function candidateToLeadFields(candidate) {
|
||||
// Mindestens ein Namensteil ist garantiert (siehe extract.js). Fehlt der
|
||||
// Nachname, wandert der Vorname dorthin - crm_leads verlangt beide Spalten,
|
||||
// und ein Kontakt ohne Nachnamen sieht in jeder Liste falsch aus.
|
||||
const lastName = candidate.last_name || candidate.first_name || 'Unbekannt';
|
||||
const firstName = candidate.last_name ? candidate.first_name || '' : '';
|
||||
|
||||
return {
|
||||
salutation: candidate.salutation ?? null,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
company: candidate.company ?? null,
|
||||
position: candidate.position ?? null,
|
||||
email: candidate.email ?? null,
|
||||
phone: candidate.phone ?? null,
|
||||
mobile: candidate.mobile ?? null,
|
||||
linkedin: candidate.linkedin ?? null,
|
||||
website: candidate.website ?? null,
|
||||
street: candidate.street ?? null,
|
||||
zip: candidate.zip ?? null,
|
||||
city: candidate.city ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Leere Felder eines bestehenden Kontakts aus dem Vorschlag auffuellen. */
|
||||
async function enrichLead(leadId, candidate) {
|
||||
const [rows] = await pool.query('SELECT * FROM crm_leads WHERE id = ? LIMIT 1', [leadId]);
|
||||
const lead = rows[0];
|
||||
if (!lead) return 0;
|
||||
|
||||
const updates = {};
|
||||
for (const field of ENRICHABLE) {
|
||||
const incoming = candidate[field];
|
||||
const current = lead[field];
|
||||
if (incoming && (current === null || current === '')) updates[field] = incoming;
|
||||
}
|
||||
|
||||
const keys = Object.keys(updates);
|
||||
if (keys.length === 0) return 0;
|
||||
|
||||
await pool.query(
|
||||
`UPDATE crm_leads SET ${keys.map((key) => `${key} = ?`).join(', ')} WHERE id = ?`,
|
||||
[...keys.map((key) => updates[key]), leadId]
|
||||
);
|
||||
return keys.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorschlag uebernehmen.
|
||||
*
|
||||
* `mode` steuert nur die Protokollierung: 'automatisch' kommt aus dem
|
||||
* Hintergrundlauf, 'manuell' aus der Oberflaeche. `userId` ist bei automatischer
|
||||
* Uebernahme null.
|
||||
*
|
||||
* Rueckgabe: { status, leadId, enrichedFields } - status ist 'uebernommen' bei
|
||||
* einem neuen Kontakt und 'dublette', wenn die Adresse schon vorhanden war.
|
||||
*/
|
||||
export async function adoptCandidate(candidate, { userId = null, ownerId = null, mode = 'manuell' } = {}) {
|
||||
const existing = candidate.email ? await findLeadByEmail(candidate.email) : null;
|
||||
|
||||
if (existing) {
|
||||
const enrichedFields = await enrichLead(existing.id, candidate);
|
||||
await markCandidate(candidate.id, 'dublette', { leadId: existing.id, userId });
|
||||
await linkMessage({
|
||||
messageId: candidate.message_id,
|
||||
leadId: existing.id,
|
||||
origin: mode === 'automatisch' ? 'automatisch' : 'manuell',
|
||||
note: 'Aus Kontakt-Vorschlag',
|
||||
userId,
|
||||
});
|
||||
return { status: 'dublette', leadId: existing.id, enrichedFields };
|
||||
}
|
||||
|
||||
const fields = candidateToLeadFields(candidate);
|
||||
const columns = { ...fields, status: 'Neu', source: MAIL_LEAD_SOURCE, tags: '[]', owner_id: ownerId, created_by: userId };
|
||||
const keys = Object.keys(columns);
|
||||
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO crm_leads (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
|
||||
keys.map((key) => columns[key])
|
||||
);
|
||||
|
||||
await markCandidate(candidate.id, 'uebernommen', { leadId: result.insertId, userId });
|
||||
await linkMessage({
|
||||
messageId: candidate.message_id,
|
||||
leadId: result.insertId,
|
||||
origin: mode === 'automatisch' ? 'automatisch' : 'manuell',
|
||||
note: 'Aus Kontakt-Vorschlag',
|
||||
userId,
|
||||
});
|
||||
|
||||
return { status: 'uebernommen', leadId: result.insertId, enrichedFields: 0 };
|
||||
}
|
||||
|
||||
/** Status eines Vorschlags setzen. */
|
||||
export async function markCandidate(candidateId, status, { leadId = null, userId = null } = {}) {
|
||||
await pool.query(
|
||||
`UPDATE crm_mail_contact_candidates
|
||||
SET status = ?, lead_id = ?, decided_by = ?, decided_at = UTC_TIMESTAMP()
|
||||
WHERE id = ?`,
|
||||
[status, leadId, userId, candidateId]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,781 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
/*
|
||||
* Kontakt-Vorschlaege aus einer Mail gewinnen - ohne KI.
|
||||
*
|
||||
* Der weitaus groesste Teil der Ausbeute steckt in den strukturierten
|
||||
* Graph-Feldern (Absender, To, Cc, Bcc): Name und Adresse sind dort schon
|
||||
* getrennt und korrekt. Das Altsystem hat stattdessen den kompletten Mailtext an
|
||||
* OpenAI geschickt, um Daten zu rekonstruieren, die es bereits hatte - teuer,
|
||||
* langsam und eine Datenweitergabe, die niemand gebraucht hat.
|
||||
*
|
||||
* Der Fliesstext wird nur noch fuer die Anreicherung genutzt: Signaturblock
|
||||
* auswerten (Telefon, Firma, Position, Adresse, Web) und Personen finden, die im
|
||||
* Text mit Telefonnummer genannt werden. Letztere haben absichtlich niedrige
|
||||
* Zuversicht und werden nie automatisch uebernommen.
|
||||
*
|
||||
* Erweiterung um ein Sprachmodell ist vorgesehen: extractCandidates() liefert
|
||||
* eine flache Liste, ein spaeterer KI-Schritt kann dieselbe Struktur ergaenzen.
|
||||
*/
|
||||
|
||||
/** Funktionspostfaecher - keine Personen, gehoeren nicht in die Kontaktliste. */
|
||||
const FUNCTIONAL_LOCALPARTS = new Set([
|
||||
'info',
|
||||
'kontakt',
|
||||
'contact',
|
||||
'office',
|
||||
'mail',
|
||||
'email',
|
||||
'service',
|
||||
'support',
|
||||
'help',
|
||||
'helpdesk',
|
||||
'sales',
|
||||
'vertrieb',
|
||||
'einkauf',
|
||||
'buchhaltung',
|
||||
'rechnung',
|
||||
'rechnungen',
|
||||
'invoice',
|
||||
'accounting',
|
||||
'hr',
|
||||
'personal',
|
||||
'jobs',
|
||||
'job',
|
||||
'bewerbung',
|
||||
'bewerbungen',
|
||||
'karriere',
|
||||
'career',
|
||||
'recruiting',
|
||||
'noreply',
|
||||
'no-reply',
|
||||
'donotreply',
|
||||
'do-not-reply',
|
||||
'postmaster',
|
||||
'mailer-daemon',
|
||||
'bounce',
|
||||
'bounces',
|
||||
'newsletter',
|
||||
'news',
|
||||
'marketing',
|
||||
'presse',
|
||||
'press',
|
||||
'datenschutz',
|
||||
'privacy',
|
||||
'admin',
|
||||
'administrator',
|
||||
'webmaster',
|
||||
'notifications',
|
||||
'notification',
|
||||
'automail',
|
||||
'system',
|
||||
'crm',
|
||||
]);
|
||||
|
||||
/** Titel und Grade, die nicht Teil des Namens sind. */
|
||||
const NAME_TITLES = [
|
||||
'herr',
|
||||
'herrn',
|
||||
'frau',
|
||||
'hr',
|
||||
'hrn',
|
||||
'fr',
|
||||
'frl',
|
||||
'mr',
|
||||
'mrs',
|
||||
'ms',
|
||||
'dr',
|
||||
'prof',
|
||||
'dipl',
|
||||
'dipl.-ing',
|
||||
'dipl.-kfm',
|
||||
'ing',
|
||||
'mag',
|
||||
'msc',
|
||||
'm.sc',
|
||||
'bsc',
|
||||
'b.sc',
|
||||
'ma',
|
||||
'm.a',
|
||||
'ba',
|
||||
'b.a',
|
||||
'mba',
|
||||
'llm',
|
||||
'phd',
|
||||
];
|
||||
|
||||
/** Namensbestandteile, die zum Nachnamen gehoeren. */
|
||||
const NAME_PARTICLES = new Set([
|
||||
'von',
|
||||
'van',
|
||||
'de',
|
||||
'del',
|
||||
'della',
|
||||
'der',
|
||||
'den',
|
||||
'di',
|
||||
'da',
|
||||
'du',
|
||||
'le',
|
||||
'la',
|
||||
'zu',
|
||||
'zur',
|
||||
'ter',
|
||||
'ten',
|
||||
'af',
|
||||
'av',
|
||||
]);
|
||||
|
||||
const LEGAL_FORMS =
|
||||
'(?:GmbH\\s*&\\s*Co\\.?\\s*KGaA|GmbH\\s*&\\s*Co\\.?\\s*KG|AG\\s*&\\s*Co\\.?\\s*KG|gGmbH|gemeinnuetzige\\s+GmbH|GmbH|mbH|UG\\s*\\(haftungsbeschraenkt\\)|UG|AG|SE|KGaA|KG|OHG|GbR|eG|e\\.?\\s?V\\.?|e\\.?\\s?K\\.?|Ltd\\.?|LLC|LLP|Inc\\.?|Corp\\.?|PLC|B\\.?V\\.?|N\\.?V\\.?|S\\.?A\\.?|S\\.?A\\.?S\\.?|S\\.?r\\.?l\\.?|AB|AS|Oy|A/S|ApS|Sp\\.?\\s?z\\s?o\\.?o\\.?|s\\.?r\\.?o\\.?|Zrt\\.?|Kft\\.?)';
|
||||
|
||||
/** Berufsbezeichnungen, die in Signaturen vorkommen. */
|
||||
const POSITION_HINTS = [
|
||||
'CEO',
|
||||
'CTO',
|
||||
'CFO',
|
||||
'COO',
|
||||
'CIO',
|
||||
'CDO',
|
||||
'CHRO',
|
||||
'Geschäftsführer',
|
||||
'Geschäftsführerin',
|
||||
'Geschaeftsfuehrer',
|
||||
'Inhaber',
|
||||
'Inhaberin',
|
||||
'Vorstand',
|
||||
'Prokurist',
|
||||
'Prokuristin',
|
||||
'Gesellschafter',
|
||||
'Managing Director',
|
||||
'Director',
|
||||
'Direktor',
|
||||
'Head of',
|
||||
'Leiter',
|
||||
'Leiterin',
|
||||
'Teamleiter',
|
||||
'Abteilungsleiter',
|
||||
'Bereichsleiter',
|
||||
'Projektleiter',
|
||||
'Projektleiterin',
|
||||
'Manager',
|
||||
'Managerin',
|
||||
'Consultant',
|
||||
'Berater',
|
||||
'Beraterin',
|
||||
'Senior',
|
||||
'Principal',
|
||||
'Partner',
|
||||
'Recruiter',
|
||||
'Recruiting',
|
||||
'Talent Acquisition',
|
||||
'Account Executive',
|
||||
'Account Manager',
|
||||
'Key Account',
|
||||
'Sales',
|
||||
'Vertrieb',
|
||||
'Einkauf',
|
||||
'Entwickler',
|
||||
'Developer',
|
||||
'Engineer',
|
||||
'Architekt',
|
||||
'Architect',
|
||||
'Analyst',
|
||||
'Scientist',
|
||||
'Assistenz',
|
||||
'Sachbearbeiter',
|
||||
'Sachbearbeiterin',
|
||||
'Referent',
|
||||
'Referentin',
|
||||
'Werkstudent',
|
||||
'Praktikant',
|
||||
'Trainee',
|
||||
'Scrum Master',
|
||||
'Product Owner',
|
||||
];
|
||||
|
||||
/** Grussformeln - alles danach ist Signatur. */
|
||||
const CLOSING_PATTERNS = [
|
||||
/^--+\s*$/,
|
||||
/^__+\s*$/,
|
||||
/mit\s+freundlichen\s+gr(ü|ue)(ß|ss)en/i,
|
||||
/mit\s+besten\s+gr(ü|ue)(ß|ss)en/i,
|
||||
/freundliche\s+gr(ü|ue)(ß|ss)e/i,
|
||||
/viele\s+gr(ü|ue)(ß|ss)e/i,
|
||||
/beste\s+gr(ü|ue)(ß|ss)e/i,
|
||||
/liebe\s+gr(ü|ue)(ß|ss)e/i,
|
||||
/herzliche\s+gr(ü|ue)(ß|ss)e/i,
|
||||
/sonnige\s+gr(ü|ue)(ß|ss)e/i,
|
||||
/^gr(ü|ue)(ß|ss)e\b/i,
|
||||
/^vg\b/i,
|
||||
/^mfg\b/i,
|
||||
/^lg\b/i,
|
||||
/^bg\b/i,
|
||||
/best\s+regards/i,
|
||||
/kind\s+regards/i,
|
||||
/^regards\b/i,
|
||||
/^cheers\b/i,
|
||||
/^thanks\b/i,
|
||||
/^sincerely\b/i,
|
||||
];
|
||||
|
||||
/** Beginn eines zitierten Vorgaengers - danach wird nichts mehr ausgewertet. */
|
||||
const QUOTE_PATTERNS = [
|
||||
/^>/,
|
||||
/^von:\s/i,
|
||||
/^gesendet:\s/i,
|
||||
/^an:\s/i,
|
||||
/^betreff:\s/i,
|
||||
/^from:\s/i,
|
||||
/^sent:\s/i,
|
||||
/^subject:\s/i,
|
||||
/^-{3,}\s*urspr(ü|ue)ngliche\s+nachricht/i,
|
||||
/^-{3,}\s*original\s+message/i,
|
||||
/^am\s.+\sschrieb\s/i,
|
||||
/^on\s.+\swrote:/i,
|
||||
];
|
||||
|
||||
const EMAIL_RE = /[\w.!#$%&'*+/=?^`{|}~-]+@[\w-]+(?:\.[\w-]+)+/g;
|
||||
const PHONE_RE =
|
||||
/(?:\+|00)\s?\d{1,3}[\s./-]?(?:\(0\)\s?)?\d{1,5}(?:[\s./-]?\d{1,6}){1,4}|\b0\d{2,5}[\s./-]?\d{3,9}(?:[\s./-]?\d{1,6})?/g;
|
||||
/*
|
||||
* PLZ und Ort. Die kleingeschriebenen Verbindungswoerter sind noetig, damit
|
||||
* "60311 Frankfurt am Main" oder "56242 Selters (Westerwald)" nicht nach dem
|
||||
* ersten Wort abbricht - sie zaehlen nur, wenn danach wieder ein Ortsteil folgt.
|
||||
*/
|
||||
const ZIP_CITY_RE =
|
||||
/\b(\d{5})\s+([A-ZÄÖÜ][\wäöüß.\-]*(?:[ -](?:[A-ZÄÖÜ][\wäöüß.\-]*|(?:am|an|auf|bei|der|den|im|in|ob|unter|vor|a\.|d\.)(?=[ -][A-ZÄÖÜ])))*)/;
|
||||
const STREET_RE =
|
||||
/\b([A-ZÄÖÜ][\wäöüß.\-]*(?:[ -](?:[A-ZÄÖÜ][\wäöüß.\-]*|von|der|am|im))*\s?(?:stra(?:ß|ss)e|str\.?|weg|allee|platz|ring|gasse|damm|ufer|chaussee|park|hof|berg|feld)\s+\d+\s?[a-zA-Z]?(?:\s?[-/]\s?\d+\s?[a-zA-Z]?)?)/i;
|
||||
const URL_RE = /\b(?:https?:\/\/|www\.)[\w-]+(?:\.[\w-]+)+(?:\/[^\s,;)]*)?/gi;
|
||||
|
||||
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
const clean = (value) => {
|
||||
if (value === null || value === undefined) return null;
|
||||
const text = String(value).replace(/\s+/g, ' ').trim();
|
||||
return text === '' ? null : text;
|
||||
};
|
||||
|
||||
/** Auf Spaltenbreite kuerzen, damit MariaDB nichts abschneiden muss. */
|
||||
const cut = (value, max) => {
|
||||
const text = clean(value);
|
||||
return text === null ? null : text.slice(0, max);
|
||||
};
|
||||
|
||||
const normalizeEmail = (value) => {
|
||||
const text = clean(value);
|
||||
if (!text) return null;
|
||||
const match = text.match(/[\w.!#$%&'*+/=?^`{|}~-]+@[\w-]+(?:\.[\w-]+)+/);
|
||||
return match ? match[0].toLowerCase() : null;
|
||||
};
|
||||
|
||||
const domainOf = (email) => (email && email.includes('@') ? email.split('@')[1].toLowerCase() : null);
|
||||
|
||||
const titleCase = (value) =>
|
||||
value
|
||||
.split(/([ -])/)
|
||||
.map((part) =>
|
||||
/^[ -]$/.test(part) ? part : part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()
|
||||
)
|
||||
.join('');
|
||||
|
||||
/** Titel vom Anfang eines Namens abtrennen. */
|
||||
function stripTitles(name) {
|
||||
let rest = name;
|
||||
let salutation = null;
|
||||
|
||||
for (;;) {
|
||||
const match = rest.match(/^\s*([\wäöüÄÖÜß.\-]+)\.?\s+(.*)$/);
|
||||
if (!match) break;
|
||||
const token = match[1].replace(/\.$/, '').toLowerCase();
|
||||
if (!NAME_TITLES.includes(token)) break;
|
||||
if (['herr', 'herrn', 'hr', 'hrn', 'mr'].includes(token)) salutation = salutation ?? 'Herr';
|
||||
if (['frau', 'fr', 'frl', 'mrs', 'ms'].includes(token)) salutation = salutation ?? 'Frau';
|
||||
rest = match[2];
|
||||
}
|
||||
|
||||
// Nachgestellte Grade: "Sascha Wolf, MBA"
|
||||
rest = rest.replace(/,\s*(?:M\.?B\.?A|M\.?Sc|B\.?Sc|M\.?A|B\.?A|LL\.?M|Ph\.?D)\.?\s*$/i, '');
|
||||
|
||||
return { name: rest.trim(), salutation };
|
||||
}
|
||||
|
||||
/**
|
||||
* Anzeigename in Vor- und Nachnamen zerlegen. Beherrscht "Nachname, Vorname",
|
||||
* Namenspartikel ("van der Berg") und faellt notfalls auf den lokalen Teil der
|
||||
* Adresse zurueck.
|
||||
*/
|
||||
export function parsePersonName(displayName, email = null) {
|
||||
const raw = clean(displayName);
|
||||
let salutation = null;
|
||||
let source = raw;
|
||||
|
||||
// Anzeigenamen wie "Wolf, Sascha (perinco GmbH)" entschlacken.
|
||||
if (source) {
|
||||
source = source.replace(/\s*[([][^)\]]*[)\]]\s*$/g, ' ').trim();
|
||||
source = source.replace(/^["']|["']$/g, '').trim();
|
||||
// Ist der Anzeigename nur die Adresse, bringt er nichts.
|
||||
if (/^[^\s@]+@[^\s@]+$/.test(source)) source = null;
|
||||
}
|
||||
|
||||
if (source) {
|
||||
const stripped = stripTitles(source);
|
||||
salutation = stripped.salutation;
|
||||
source = stripped.name;
|
||||
}
|
||||
|
||||
if (source && source.includes(',')) {
|
||||
const [last, first] = source.split(',', 2).map((part) => part.trim());
|
||||
if (last && first) return { firstName: cut(first, 120), lastName: cut(last, 120), salutation };
|
||||
}
|
||||
|
||||
if (source) {
|
||||
const tokens = source.split(/\s+/).filter(Boolean);
|
||||
if (tokens.length >= 2) {
|
||||
// Erstes Partikel markiert den Beginn des Nachnamens.
|
||||
let splitAt = tokens.length - 1;
|
||||
for (let i = 1; i < tokens.length - 1; i += 1) {
|
||||
if (NAME_PARTICLES.has(tokens[i].toLowerCase())) {
|
||||
splitAt = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
firstName: cut(tokens.slice(0, splitAt).join(' '), 120),
|
||||
lastName: cut(tokens.slice(splitAt).join(' '), 120),
|
||||
salutation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const derived = nameFromEmail(email);
|
||||
|
||||
/*
|
||||
* Einteiliger Anzeigename ("Mueller") plus Adresse "k.mueller@...": den
|
||||
* Vornamen aus der Adresse nehmen, aber den Nachnamen aus dem Anzeigenamen
|
||||
* behalten - dort stehen die Umlaute richtig, in der Adresse nicht.
|
||||
*/
|
||||
if (source && derived?.firstName && derived?.lastName) {
|
||||
if (foldUmlauts(derived.lastName) === foldUmlauts(source)) {
|
||||
return { firstName: derived.firstName, lastName: cut(source, 120), salutation };
|
||||
}
|
||||
}
|
||||
|
||||
// Kein brauchbarer Anzeigename: aus dem lokalen Teil ableiten.
|
||||
if (derived) return { ...derived, salutation };
|
||||
|
||||
if (source) return { firstName: null, lastName: cut(source, 120), salutation };
|
||||
return { firstName: null, lastName: null, salutation };
|
||||
}
|
||||
|
||||
/** Woerter, die neben einem Namen stehen, aber nicht dazugehoeren. */
|
||||
const NAME_NOISE = new Set([
|
||||
'tel',
|
||||
'telefon',
|
||||
'phone',
|
||||
'fon',
|
||||
'mobil',
|
||||
'mobile',
|
||||
'handy',
|
||||
'fax',
|
||||
'mail',
|
||||
'email',
|
||||
'e-mail',
|
||||
'durchwahl',
|
||||
'zentrale',
|
||||
'nummer',
|
||||
'nr',
|
||||
'unter',
|
||||
'erreichbar',
|
||||
'kontakt',
|
||||
'ansprechpartner',
|
||||
'ansprechpartnerin',
|
||||
]);
|
||||
|
||||
/** Beschriftungen am Rand eines gefundenen Namens abschneiden. */
|
||||
function stripNameNoise(value) {
|
||||
const tokens = String(value).split(/\s+/).filter(Boolean);
|
||||
while (tokens.length > 1 && NAME_NOISE.has(tokens[tokens.length - 1].replace(/\.$/, '').toLowerCase())) {
|
||||
tokens.pop();
|
||||
}
|
||||
while (tokens.length > 1 && NAME_NOISE.has(tokens[0].replace(/\.$/, '').toLowerCase())) {
|
||||
tokens.shift();
|
||||
}
|
||||
return tokens.join(' ');
|
||||
}
|
||||
|
||||
/** Umlaute und Schaerfe-S angleichen, damit "Müller" und "Mueller" gleich sind. */
|
||||
function foldUmlauts(value) {
|
||||
return String(value)
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae')
|
||||
.replace(/ö/g, 'oe')
|
||||
.replace(/ü/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z]/g, '');
|
||||
}
|
||||
|
||||
/** "sascha.wolf@..." wird zu Sascha Wolf; ohne Trenner bleibt es einteilig. */
|
||||
function nameFromEmail(email) {
|
||||
const address = normalizeEmail(email);
|
||||
if (!address) return null;
|
||||
|
||||
const local = address.split('@')[0].replace(/\+.*$/, '');
|
||||
if (FUNCTIONAL_LOCALPARTS.has(local)) return null;
|
||||
|
||||
const parts = local
|
||||
.split(/[._\-]+/)
|
||||
.filter((part) => part.length > 0 && !/^\d+$/.test(part));
|
||||
|
||||
if (parts.length >= 2) {
|
||||
return {
|
||||
firstName: cut(titleCase(parts[0]), 120),
|
||||
lastName: cut(titleCase(parts.slice(1).join(' ')), 120),
|
||||
};
|
||||
}
|
||||
if (parts.length === 1 && parts[0].length > 1) {
|
||||
return { firstName: null, lastName: cut(titleCase(parts[0]), 120) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Funktionspostfach oder Automat? Solche Adressen werden nicht vorgeschlagen. */
|
||||
export function isFunctionalAddress(email) {
|
||||
const address = normalizeEmail(email);
|
||||
if (!address) return true;
|
||||
const local = address.split('@')[0].replace(/\+.*$/, '').toLowerCase();
|
||||
if (FUNCTIONAL_LOCALPARTS.has(local)) return true;
|
||||
// Muster wie "noreply-1234", "bounce+abc", "mailer_daemon"
|
||||
return /^(?:no-?reply|do-?not-?reply|bounce|mailer-?daemon|postmaster|notifications?|automat|robot|bot)\b/.test(
|
||||
local.replace(/[._]/g, '-')
|
||||
);
|
||||
}
|
||||
|
||||
/** Telefonnummer lesbar normalisieren, ohne die Zahl zu verfaelschen. */
|
||||
function normalizePhone(value) {
|
||||
const text = clean(value);
|
||||
if (!text) return null;
|
||||
const compact = text.replace(/[^\d+]/g, '');
|
||||
// Zu kurz fuer eine echte Rufnummer (Hausnummern, Jahreszahlen).
|
||||
if (compact.replace(/\D/g, '').length < 7) return null;
|
||||
return cut(text.replace(/\s{2,}/g, ' '), 64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signaturblock bestimmen. Zuerst wird alles ab dem ersten Zitat abgeschnitten,
|
||||
* dann ab der letzten Grussformel gelesen. Ohne Grussformel gelten die letzten
|
||||
* Zeilen als Signatur.
|
||||
*/
|
||||
export function signatureLines(bodyText, maxLines = 18) {
|
||||
if (!bodyText) return [];
|
||||
|
||||
const all = String(bodyText).split('\n');
|
||||
|
||||
let end = all.length;
|
||||
for (let i = 0; i < all.length; i += 1) {
|
||||
if (QUOTE_PATTERNS.some((pattern) => pattern.test(all[i].trim()))) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const lines = all.slice(0, end);
|
||||
|
||||
let start = -1;
|
||||
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
||||
const line = lines[i].trim();
|
||||
if (line === '') continue;
|
||||
if (CLOSING_PATTERNS.some((pattern) => pattern.test(line))) {
|
||||
start = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const block = start >= 0 ? lines.slice(start) : lines.slice(Math.max(0, lines.length - maxLines));
|
||||
return block.map((line) => line.trim()).filter(Boolean).slice(0, maxLines * 2);
|
||||
}
|
||||
|
||||
/** Firmenname, Position, Adresse, Web und Rufnummern aus dem Signaturblock. */
|
||||
export function parseSignature(bodyText) {
|
||||
const lines = signatureLines(bodyText);
|
||||
const block = lines.join('\n');
|
||||
const result = {
|
||||
phone: null,
|
||||
mobile: null,
|
||||
company: null,
|
||||
position: null,
|
||||
street: null,
|
||||
zip: null,
|
||||
city: null,
|
||||
website: null,
|
||||
linkedin: null,
|
||||
emails: [],
|
||||
};
|
||||
if (lines.length === 0) return result;
|
||||
|
||||
/*
|
||||
* Rufnummern nach Beschriftung unterscheiden. Zwischen Bezeichnung und Nummer
|
||||
* darf mehr als ein Satzzeichen stehen - "Tel.:" ist der Normalfall in
|
||||
* deutschen Signaturen, ein einzelnes optionales Zeichen reicht dafuer nicht.
|
||||
*/
|
||||
for (const line of lines) {
|
||||
const labelled = line.match(
|
||||
/\b(mobil|mobile|handy|cell|m)\b[\s.:|·•-]{0,4}((?:\+|00|0)[\d\s()./-]{6,})/i
|
||||
);
|
||||
if (labelled && !result.mobile) result.mobile = normalizePhone(labelled[2]);
|
||||
|
||||
const office = line.match(
|
||||
/\b(tel(?:efon)?|phone|fon|festnetz|office|t)\b[\s.:|·•-]{0,4}((?:\+|00|0)[\d\s()./-]{6,})/i
|
||||
);
|
||||
if (office && !result.phone) result.phone = normalizePhone(office[2]);
|
||||
}
|
||||
|
||||
// Unbeschriftete Nummer als Festnetz nehmen, wenn noch keine gefunden wurde.
|
||||
if (!result.phone && !result.mobile) {
|
||||
const found = block.match(PHONE_RE);
|
||||
if (found) {
|
||||
for (const candidate of found) {
|
||||
const phone = normalizePhone(candidate);
|
||||
if (phone) {
|
||||
result.phone = phone;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const company = block.match(new RegExp(`([\\wÄÖÜäöüß.&'-]+(?:[ -][\\wÄÖÜäöüß.&'-]+){0,5}\\s+${LEGAL_FORMS})`, 'i'));
|
||||
if (company) result.company = cut(company[1], 190);
|
||||
|
||||
for (const hint of POSITION_HINTS) {
|
||||
const line = lines.find((entry) => {
|
||||
if (!entry.toLowerCase().includes(hint.toLowerCase())) return false;
|
||||
// Zeilen mit Adresse oder Rufnummer sind keine Positionsangaben.
|
||||
return !/\d{5}\s|\+\d|@/.test(entry) && entry.length <= 80;
|
||||
});
|
||||
if (line) {
|
||||
result.position = cut(line.replace(/^[|•·\-–—\s]+|[|•·\-–—\s]+$/g, ''), 190);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const street = block.match(STREET_RE);
|
||||
if (street) result.street = cut(street[1], 190);
|
||||
|
||||
const zipCity = block.match(ZIP_CITY_RE);
|
||||
if (zipCity) {
|
||||
result.zip = cut(zipCity[1], 24);
|
||||
result.city = cut(zipCity[2], 120);
|
||||
}
|
||||
|
||||
const urls = block.match(URL_RE) || [];
|
||||
for (const url of urls) {
|
||||
if (/linkedin\.com/i.test(url)) {
|
||||
result.linkedin = result.linkedin ?? cut(url, 255);
|
||||
} else if (!/(?:xing|facebook|instagram|twitter|x\.com|youtube)\./i.test(url)) {
|
||||
result.website = result.website ?? cut(url, 255);
|
||||
}
|
||||
}
|
||||
|
||||
result.emails = [...new Set((block.match(EMAIL_RE) || []).map((entry) => entry.toLowerCase()))];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Personen, die im Fliesstext mit Rufnummer genannt werden.
|
||||
*
|
||||
* Es wird bewusst eine Anrede verlangt ("Herrn Max Mustermann Tel. ..."). Ohne
|
||||
* diese Bedingung liest der Musterabgleich jede Folge grossgeschriebener Woerter
|
||||
* als Namen - "Zweiter Ansprechpartner" landete so als Kontakt in der Liste.
|
||||
* Ein verpasster Treffer ist harmloser als ein falscher: die Durchsicht soll
|
||||
* kurz bleiben, sonst schaut niemand hin.
|
||||
*/
|
||||
function textMentions(bodyText, signatureBlock) {
|
||||
if (!bodyText) return [];
|
||||
|
||||
const lines = String(bodyText)
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() && !signatureBlock.includes(line.trim()));
|
||||
|
||||
const found = [];
|
||||
// Anrede ist Pflicht; danach ein bis drei grossgeschriebene Namensteile.
|
||||
const namePattern =
|
||||
/\b(Herrn?|Frau|Hrn?\.|Fr\.|Frl\.)\s+((?:(?:Dr|Prof|Dipl[\w.-]*)\.\s+)*[A-ZÄÖÜ][a-zäöüß]+(?:\s+(?:von|van|de|zu|der)\b)?(?:\s+[A-ZÄÖÜ][a-zäöüß]+){0,2})/g;
|
||||
|
||||
for (const line of lines) {
|
||||
const phones = line.match(PHONE_RE);
|
||||
if (!phones) continue;
|
||||
|
||||
const phone = normalizePhone(phones[0]);
|
||||
if (!phone) continue;
|
||||
|
||||
for (const match of line.matchAll(namePattern)) {
|
||||
// "Herrn Max Mustermann Tel. 0221/..." - die Beschriftung der Rufnummer
|
||||
// steht gross direkt hinter dem Namen und wuerde sonst mitgelesen.
|
||||
const parsed = parsePersonName(stripNameNoise(match[2]));
|
||||
if (!parsed.lastName) continue;
|
||||
|
||||
found.push({
|
||||
...parsed,
|
||||
salutation: /^(?:Herrn?|Hrn?\.)$/.test(match[1]) ? 'Herr' : 'Frau',
|
||||
phone,
|
||||
origin: 'text',
|
||||
confidence: 35,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Zuversicht je Herkunft. Hoehere Werte duerfen automatisch uebernommen werden. */
|
||||
const CONFIDENCE = {
|
||||
absender: 90,
|
||||
empfaenger: 78,
|
||||
signatur: 55,
|
||||
text: 35,
|
||||
};
|
||||
|
||||
/** Schluessel fuer die Dublettenpruefung innerhalb einer Mail. */
|
||||
function dedupeKey(candidate) {
|
||||
if (candidate.email) return `email:${candidate.email}`;
|
||||
const name = [candidate.firstName, candidate.lastName].filter(Boolean).join(' ').toLowerCase();
|
||||
const phone = (candidate.phone || candidate.mobile || '').replace(/\D/g, '');
|
||||
return `name:${name}|tel:${phone}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Vorschlaege einer Mail.
|
||||
*
|
||||
* `message` erwartet die normalisierte Form aus sync.js:
|
||||
* { fromName, fromEmail, to[], cc[], bcc[], bodyText }
|
||||
* `options.internalDomains` sind die eigenen Domains, `options.mailbox` das
|
||||
* abgeglichene Postfach - beide werden uebersprungen.
|
||||
*/
|
||||
export function extractCandidates(message, options = {}) {
|
||||
const internalDomains = (options.internalDomains || []).map((entry) => entry.toLowerCase());
|
||||
const mailbox = normalizeEmail(options.mailbox);
|
||||
|
||||
const isInternal = (email) => {
|
||||
const domain = domainOf(email);
|
||||
return Boolean(domain && internalDomains.includes(domain));
|
||||
};
|
||||
|
||||
const signature = parseSignature(message.bodyText);
|
||||
const signatureBlock = signatureLines(message.bodyText).join('\n');
|
||||
const senderEmail = normalizeEmail(message.fromEmail);
|
||||
|
||||
/** Sammler: pro Dublettenschluessel bleibt der Eintrag mit der hoechsten Zuversicht. */
|
||||
const collected = new Map();
|
||||
|
||||
const add = (candidate) => {
|
||||
if (!candidate) return;
|
||||
if (!candidate.email && !candidate.phone && !candidate.mobile) return;
|
||||
if (!candidate.firstName && !candidate.lastName) return;
|
||||
|
||||
const key = dedupeKey(candidate);
|
||||
const existing = collected.get(key);
|
||||
if (!existing) {
|
||||
collected.set(key, candidate);
|
||||
return;
|
||||
}
|
||||
// Zusammenfuehren: fehlende Felder auffuellen, hoehere Zuversicht gewinnt.
|
||||
for (const [field, value] of Object.entries(candidate)) {
|
||||
if (existing[field] === null || existing[field] === undefined) existing[field] = value;
|
||||
}
|
||||
if (candidate.confidence > existing.confidence) {
|
||||
existing.confidence = candidate.confidence;
|
||||
existing.origin = candidate.origin;
|
||||
}
|
||||
};
|
||||
|
||||
const personFrom = (entry, origin) => {
|
||||
const email = normalizeEmail(entry?.email);
|
||||
if (!email) return null;
|
||||
if (email === mailbox) return null;
|
||||
if (isInternal(email)) return null;
|
||||
if (isFunctionalAddress(email)) return null;
|
||||
|
||||
const parsed = parsePersonName(entry?.name, email);
|
||||
if (!parsed.firstName && !parsed.lastName) return null;
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
email: cut(email, 190),
|
||||
phone: null,
|
||||
mobile: null,
|
||||
company: null,
|
||||
position: null,
|
||||
street: null,
|
||||
zip: null,
|
||||
city: null,
|
||||
website: null,
|
||||
linkedin: null,
|
||||
origin,
|
||||
confidence: CONFIDENCE[origin],
|
||||
};
|
||||
};
|
||||
|
||||
// 1. Absender - inklusive Signaturdaten, die eindeutig zu ihm gehoeren.
|
||||
const sender = personFrom({ name: message.fromName, email: message.fromEmail }, 'absender');
|
||||
if (sender) {
|
||||
Object.assign(sender, {
|
||||
phone: signature.phone,
|
||||
mobile: signature.mobile,
|
||||
company: signature.company,
|
||||
position: signature.position,
|
||||
street: signature.street,
|
||||
zip: signature.zip,
|
||||
city: signature.city,
|
||||
website: signature.website,
|
||||
linkedin: signature.linkedin,
|
||||
});
|
||||
// Signatur mit passender Adresse bestaetigt den Absender zusaetzlich.
|
||||
if (senderEmail && signature.emails.includes(senderEmail)) sender.confidence = 95;
|
||||
add(sender);
|
||||
}
|
||||
|
||||
// 2. Empfaenger. Deckt auch das Muster ab, bei dem eine interne Mail das
|
||||
// CRM auf Cc setzt: der Absender ist dann intern und faellt weg, die
|
||||
// externen Empfaenger sind die eigentlichen Kontakte. Dafuer braucht es
|
||||
// keinen Sonderfall wie im Altsystem.
|
||||
for (const list of [message.to, message.cc, message.bcc]) {
|
||||
for (const entry of list || []) add(personFrom(entry, 'empfaenger'));
|
||||
}
|
||||
|
||||
// 3. Weitere Adressen aus der Signatur (Assistenz, zweiter Ansprechpartner).
|
||||
for (const email of signature.emails) {
|
||||
if (senderEmail && email === senderEmail) continue;
|
||||
add(personFrom({ name: null, email }, 'signatur'));
|
||||
}
|
||||
|
||||
// 4. Im Text genannte Personen mit Rufnummer, ohne Adresse.
|
||||
for (const mention of textMentions(message.bodyText, signatureBlock)) add(mention);
|
||||
|
||||
return [...collected.values()].map((candidate) => ({
|
||||
dedupeHash: hash(dedupeKey(candidate)),
|
||||
firstName: candidate.firstName,
|
||||
lastName: candidate.lastName,
|
||||
salutation: cut(candidate.salutation, 32),
|
||||
email: candidate.email,
|
||||
phone: candidate.phone,
|
||||
mobile: candidate.mobile,
|
||||
company: candidate.company,
|
||||
position: candidate.position,
|
||||
street: candidate.street,
|
||||
zip: candidate.zip,
|
||||
city: candidate.city,
|
||||
website: candidate.website,
|
||||
linkedin: candidate.linkedin,
|
||||
origin: candidate.origin,
|
||||
confidence: Math.max(0, Math.min(100, candidate.confidence)),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ab dieser Zuversicht darf ein Vorschlag mit E-Mail-Adresse automatisch zum
|
||||
* Kontakt werden. Absender und Empfaenger liegen darueber, Signatur- und
|
||||
* Textfunde bleiben zur Durchsicht liegen.
|
||||
*/
|
||||
export const AUTO_ADOPT_MIN_CONFIDENCE = 70;
|
||||
@@ -0,0 +1,218 @@
|
||||
import { TENANT_ID, CLIENT_ID, MAILBOX, MAIL_FOLDER, clientSecret } from './config.js';
|
||||
|
||||
/*
|
||||
* Schmaler Microsoft-Graph-Client fuer den App-only-Zugriff (Client Credentials).
|
||||
*
|
||||
* Bewusst anders als im Altsystem:
|
||||
* - Das Token wird zwischengespeichert, statt bei jedem Aufruf neu geholt.
|
||||
* - 429 und 5xx werden mit Beachtung von Retry-After wiederholt.
|
||||
* - Ein 401 verwirft das Token genau einmal und wiederholt - so ueberlebt der
|
||||
* Lauf einen serverseitig zurueckgezogenen Token.
|
||||
* - Fehlerantworten werden gelesen und mit Graph-Fehlercode weitergegeben,
|
||||
* statt still verschluckt zu werden.
|
||||
*/
|
||||
|
||||
const AUTHORITY = 'https://login.microsoftonline.com';
|
||||
export const GRAPH_ROOT = 'https://graph.microsoft.com/v1.0';
|
||||
|
||||
/** Zeitlimit je Anfrage - haengende Verbindungen sollen den Lauf nicht blockieren. */
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
const MAX_ATTEMPTS = 4;
|
||||
|
||||
export class GraphError extends Error {
|
||||
constructor(message, { status = 0, code = null, requestUrl = null } = {}) {
|
||||
super(message);
|
||||
this.name = 'GraphError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.requestUrl = requestUrl;
|
||||
}
|
||||
}
|
||||
|
||||
let tokenCache = null;
|
||||
|
||||
/** Verwirft das gespeicherte Token - nach 401 und beim Verbindungstest. */
|
||||
export function resetTokenCache() {
|
||||
tokenCache = null;
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function fetchAccessToken() {
|
||||
const body = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: clientSecret(),
|
||||
scope: 'https://graph.microsoft.com/.default',
|
||||
grant_type: 'client_credentials',
|
||||
});
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${AUTHORITY}/${TENANT_ID}/oauth2/v2.0/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new GraphError(`Anmeldung bei Microsoft nicht erreichbar: ${err.message}`, { status: 0 });
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
// error_description von Entra ist die einzige brauchbare Fehlerquelle bei
|
||||
// falscher Tenant-ID, falschem Secret oder fehlender Zustimmung.
|
||||
const detail = data?.error_description || data?.error || `HTTP ${response.status}`;
|
||||
throw new GraphError(`Anmeldung fehlgeschlagen: ${String(detail).split('\n')[0]}`, {
|
||||
status: response.status,
|
||||
code: data?.error ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
if (!data?.access_token) {
|
||||
throw new GraphError('Anmeldung lieferte kein Token zurueck.', { status: response.status });
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Gueltiges Token, notfalls neu geholt. Puffer von 120 s vor dem Ablauf. */
|
||||
export async function accessToken() {
|
||||
if (tokenCache && tokenCache.expiresAt > Date.now()) return tokenCache.value;
|
||||
|
||||
const data = await fetchAccessToken();
|
||||
const lifetime = Number(data.expires_in) || 3600;
|
||||
tokenCache = {
|
||||
value: data.access_token,
|
||||
expiresAt: Date.now() + Math.max(60, lifetime - 120) * 1000,
|
||||
};
|
||||
return tokenCache.value;
|
||||
}
|
||||
|
||||
/** Wartezeit aus Retry-After (Sekunden oder HTTP-Datum), sonst exponentiell. */
|
||||
function retryDelayMs(response, attempt) {
|
||||
const header = response?.headers?.get('retry-after');
|
||||
if (header) {
|
||||
const seconds = Number(header);
|
||||
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds, 120) * 1000;
|
||||
const date = Date.parse(header);
|
||||
if (Number.isFinite(date)) return Math.min(Math.max(0, date - Date.now()), 120_000);
|
||||
}
|
||||
return Math.min(1000 * 2 ** (attempt - 1), 16_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Graph-Aufruf mit Wiederholung. `target` darf ein Pfad relativ zu /v1.0 oder
|
||||
* eine vollstaendige URL sein - Delta-Links kommen absolut von Graph zurueck.
|
||||
*/
|
||||
export async function graphRequest(target, { method = 'GET', body = null, headers = {} } = {}) {
|
||||
const url = target.startsWith('http') ? target : `${GRAPH_ROOT}${target}`;
|
||||
let tokenRetried = false;
|
||||
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
const token = await accessToken();
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
// Netzwerkfehler und Zeitlimit: wiederholen, solange Versuche bleiben.
|
||||
if (attempt < MAX_ATTEMPTS) {
|
||||
await sleep(retryDelayMs(null, attempt));
|
||||
continue;
|
||||
}
|
||||
throw new GraphError(`Microsoft Graph nicht erreichbar: ${err.message}`, {
|
||||
status: 0,
|
||||
requestUrl: url,
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === 401 && !tokenRetried) {
|
||||
tokenRetried = true;
|
||||
resetTokenCache();
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((response.status === 429 || response.status >= 500) && attempt < MAX_ATTEMPTS) {
|
||||
await sleep(retryDelayMs(response, attempt));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
let code = null;
|
||||
let message = `HTTP ${response.status}`;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
code = parsed?.error?.code ?? null;
|
||||
message = parsed?.error?.message || message;
|
||||
} catch {
|
||||
if (text) message = text.slice(0, 300);
|
||||
}
|
||||
throw new GraphError(message, { status: response.status, code, requestUrl: url });
|
||||
}
|
||||
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
/** URL-sicherer Pfad auf das konfigurierte Postfach. */
|
||||
export function mailboxPath(suffix = '') {
|
||||
return `/users/${encodeURIComponent(MAILBOX)}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbindungstest: Token holen und einen minimalen Zugriff auf das Postfach
|
||||
* versuchen. Der zweite Schritt ist der eigentlich wichtige - ein Token bekommt
|
||||
* man auch dann, wenn Berechtigung oder Zugriffsrichtlinie fehlen. Genau daran
|
||||
* ist der Test des Altsystems vorbeigelaufen: er hat nur das Token geprueft.
|
||||
*/
|
||||
export async function testConnection() {
|
||||
resetTokenCache();
|
||||
|
||||
try {
|
||||
await accessToken();
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'token',
|
||||
error: err.message,
|
||||
hint: 'Tenant-ID, Client-ID und Client-Secret in der .env pruefen.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await graphRequest(
|
||||
`${mailboxPath(`/mailFolders/${encodeURIComponent(MAIL_FOLDER)}`)}?$select=displayName,totalItemCount,unreadItemCount`
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
stage: 'mailbox',
|
||||
mailbox: MAILBOX,
|
||||
folder: data?.displayName ?? null,
|
||||
totalItems: data?.totalItemCount ?? null,
|
||||
unreadItems: data?.unreadItemCount ?? null,
|
||||
};
|
||||
} catch (err) {
|
||||
// Die typischen Stolpersteine liefern eindeutige Graph-Codes.
|
||||
const hint =
|
||||
err.code === 'ErrorAccessDenied' || err.status === 403
|
||||
? 'Zugriff verweigert: Anwendungsberechtigung Mail.Read erteilt und Administratorzustimmung gegeben? Erlaubt die ApplicationAccessPolicy dieses Postfach?'
|
||||
: err.code === 'ResourceNotFound' || err.status === 404
|
||||
? `Postfach ${MAILBOX} wurde nicht gefunden - Adresse pruefen.`
|
||||
: 'Berechtigungen und Zugriffsrichtlinie in Exchange Online pruefen.';
|
||||
return { ok: false, stage: 'mailbox', error: err.message, code: err.code ?? null, hint };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* HTML aus Mails aufbereiten.
|
||||
*
|
||||
* Zwei Aufgaben, die im Altsystem beide zu kurz kamen:
|
||||
*
|
||||
* 1. htmlToText: Der alte Wandler hat jede Folge von Leerraum auf ein Leerzeichen
|
||||
* gestaucht - damit ist die Zeilenstruktur weg, und genau die braucht man, um
|
||||
* eine Signatur zu erkennen. Hier bleiben Zeilenumbrueche erhalten.
|
||||
*
|
||||
* 2. sanitizeHtml: Fremdes HTML wird im Browser dargestellt. Die Anzeige laeuft
|
||||
* zwar in einem abgeschotteten iframe ohne Skripte, aber Verteidigung in der
|
||||
* Tiefe kostet hier fast nichts: Skripte, eingebettete Rahmen, Formulare und
|
||||
* Ereignis-Attribute fliegen schon serverseitig raus.
|
||||
*/
|
||||
|
||||
const ENTITIES = {
|
||||
nbsp: ' ',
|
||||
amp: '&',
|
||||
lt: '<',
|
||||
gt: '>',
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
'#39': "'",
|
||||
euro: '€',
|
||||
auml: 'ä',
|
||||
ouml: 'ö',
|
||||
uuml: 'ü',
|
||||
Auml: 'Ä',
|
||||
Ouml: 'Ö',
|
||||
Uuml: 'Ü',
|
||||
szlig: 'ß',
|
||||
ndash: '–',
|
||||
mdash: '—',
|
||||
hellip: '…',
|
||||
laquo: '«',
|
||||
raquo: '»',
|
||||
shy: '',
|
||||
zwnj: '',
|
||||
};
|
||||
|
||||
function decodeEntities(text) {
|
||||
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+[0-9]*);/g, (match, entity) => {
|
||||
if (entity.startsWith('#x') || entity.startsWith('#X')) {
|
||||
const code = Number.parseInt(entity.slice(2), 16);
|
||||
return Number.isFinite(code) ? safeCodePoint(code) : match;
|
||||
}
|
||||
if (entity.startsWith('#')) {
|
||||
const code = Number.parseInt(entity.slice(1), 10);
|
||||
return Number.isFinite(code) ? safeCodePoint(code) : match;
|
||||
}
|
||||
return entity in ENTITIES ? ENTITIES[entity] : match;
|
||||
});
|
||||
}
|
||||
|
||||
function safeCodePoint(code) {
|
||||
try {
|
||||
return String.fromCodePoint(code);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Blockelemente, nach denen ein Zeilenumbruch stehen muss. */
|
||||
const BLOCK_TAGS =
|
||||
'address|article|aside|blockquote|div|dd|dl|dt|fieldset|figure|footer|h[1-6]|header|hr|li|main|nav|ol|p|pre|section|table|tbody|td|tfoot|th|thead|tr|ul';
|
||||
|
||||
/**
|
||||
* HTML in lesbaren Text wandeln - mit erhaltenen Zeilen. Ist der Text schon
|
||||
* reiner Text (contentType: text), passiert nur die Normalisierung.
|
||||
*/
|
||||
export function htmlToText(html) {
|
||||
if (!html) return '';
|
||||
|
||||
let text = String(html)
|
||||
// Nicht darstellbare Bereiche komplett entfernen.
|
||||
.replace(/<(script|style|head|noscript|template)\b[\s\S]*?<\/\1>/gi, ' ')
|
||||
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||||
/*
|
||||
* Ziel eines Links hinter den Linktext setzen. Signaturen verstecken Website
|
||||
* und LinkedIn-Profil fast immer hinter einem Wort ("LinkedIn", "Website") -
|
||||
* ohne diesen Schritt ist die Adresse im Text verloren und die Extraktion
|
||||
* findet sie nicht.
|
||||
*/
|
||||
.replace(
|
||||
/<a\b[^>]*\bhref\s*=\s*["']?(https?:\/\/[^"'\s>]+)["']?[^>]*>([\s\S]{0,120}?)<\/a>/gi,
|
||||
(match, href, label) => {
|
||||
const text = label.replace(/<[^>]+>/g, '').trim();
|
||||
if (!text) return ` ${href} `;
|
||||
// Ist der Linktext selbst die Adresse, waere die Wiederholung nur Ballast.
|
||||
const normalizedLabel = text.replace(/^https?:\/\//i, '').replace(/\/$/, '');
|
||||
const normalizedHref = href.replace(/^https?:\/\//i, '').replace(/\/$/, '');
|
||||
if (normalizedLabel.toLowerCase() === normalizedHref.toLowerCase()) return ` ${text} `;
|
||||
return ` ${text} (${href}) `;
|
||||
}
|
||||
)
|
||||
// Umbrueche erhalten.
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(new RegExp(`</(?:${BLOCK_TAGS})\\s*>`, 'gi'), '\n')
|
||||
.replace(new RegExp(`<(?:${BLOCK_TAGS})\\b[^>]*>`, 'gi'), '\n')
|
||||
// Zellen innerhalb einer Zeile trennen, damit Signaturtabellen lesbar bleiben.
|
||||
.replace(/<\/t[dh]\s*>/gi, ' \t')
|
||||
.replace(/<[^>]+>/g, '');
|
||||
|
||||
text = decodeEntities(text);
|
||||
|
||||
return text
|
||||
.replace(/\r\n?/g, '\n')
|
||||
// Geschuetzte Leerzeichen und Tabs zu normalen Leerzeichen, Zeilen bleiben.
|
||||
.replace(/[^\S\n]+/g, ' ')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
// Maximal eine Leerzeile in Folge - Outlook-HTML produziert Dutzende.
|
||||
.filter((line, index, lines) => line !== '' || (index > 0 && lines[index - 1] !== ''))
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Attribute, die in der Anzeige nichts zu suchen haben. */
|
||||
const EVENT_ATTR = /\s+on[a-z-]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi;
|
||||
const DANGEROUS_URL = /(?:href|src|action|formaction|xlink:href)\s*=\s*(?:"|')?\s*(?:javascript|vbscript|data:text\/html)/i;
|
||||
|
||||
/**
|
||||
* Entschaerftes HTML fuer die Anzeige. Kein vollstaendiger Sanitizer - die
|
||||
* eigentliche Absicherung ist das sandbox-iframe im Frontend. Was hier
|
||||
* verschwindet, kann dort also gar nicht erst ankommen.
|
||||
*/
|
||||
export function sanitizeHtml(html) {
|
||||
if (!html) return '';
|
||||
|
||||
let out = String(html)
|
||||
.replace(/<(script|style|iframe|frame|frameset|object|embed|applet|form|link|meta|base)\b[\s\S]*?<\/\1\s*>/gi, '')
|
||||
// Selbstschliessende bzw. nie geschlossene Varianten derselben Elemente.
|
||||
.replace(/<\/?(script|iframe|frame|frameset|object|embed|applet|form|link|meta|base)\b[^>]*>/gi, '')
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(EVENT_ATTR, '');
|
||||
|
||||
// Verbleibende gefaehrliche URL-Schemata neutralisieren.
|
||||
out = out.replace(/<[^>]+>/g, (tag) => (DANGEROUS_URL.test(tag) ? tag.replace(/(href|src|action|formaction|xlink:href)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '') : tag));
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import cron from 'node-cron';
|
||||
import { MAIL_CONFIGURED, SCHEDULER_ENABLED, mailSettings } from './config.js';
|
||||
import { getSyncState, saveSyncState } from './store.js';
|
||||
import { isMailSyncRunning, runMailSync } from './sync.js';
|
||||
|
||||
/*
|
||||
* Hintergrundabgleich des Postfachs.
|
||||
*
|
||||
* Wie beim Scanner prueft ein Minutentakt, ob ein Lauf faellig ist, statt ein
|
||||
* festes Intervall einzuplanen: ein in der Oberflaeche geaendertes Intervall
|
||||
* greift dadurch sofort, und der Zeitpunkt des letzten Laufs steht in der
|
||||
* Datenbank - der Zeitplan ueberlebt also Neustarts und Deployments.
|
||||
*
|
||||
* Anders als beim Scanner gibt es kein Zeitfenster und keine Wochentagssperre:
|
||||
* Mails kommen auch nachts und am Wochenend an, und der Abgleich kostet nichts
|
||||
* ausser einem Graph-Aufruf.
|
||||
*/
|
||||
|
||||
let job = null;
|
||||
|
||||
/** Steht noch eine Delta-Seite offen, wird ohne Wartezeit weitergemacht. */
|
||||
const CONTINUE_DELAY_MS = 15_000;
|
||||
|
||||
export function isMailSchedulerRunning() {
|
||||
return job !== null;
|
||||
}
|
||||
|
||||
export async function startMailScheduler() {
|
||||
if (!MAIL_CONFIGURED) {
|
||||
console.log('[Mail] Zugangsdaten fehlen - Abgleich bleibt aus, die Oberflaeche laeuft.');
|
||||
return;
|
||||
}
|
||||
if (!SCHEDULER_ENABLED) {
|
||||
console.log('[Mail] Abgleich per MAIL_SYNC=false abgeschaltet.');
|
||||
return;
|
||||
}
|
||||
if (job) return;
|
||||
|
||||
const settings = await mailSettings();
|
||||
console.log(
|
||||
`[Mail] Abgleich eingerichtet: alle ${settings.intervalMinutes} Minuten, ` +
|
||||
`Kontakt-Uebernahme ${settings.autoContacts ? 'automatisch' : 'nur nach Durchsicht'}.`
|
||||
);
|
||||
|
||||
job = cron.schedule('* * * * *', async () => {
|
||||
if (isMailSyncRunning()) return;
|
||||
|
||||
try {
|
||||
const active = await mailSettings();
|
||||
if (!active.active) return;
|
||||
|
||||
const state = await getSyncState();
|
||||
|
||||
/*
|
||||
* Faelligkeit aus last_run_at in der Datenbank. Fehlt der Wert, ist sofort
|
||||
* ein Lauf faellig - so kommt eine frische Installation ohne Wartezeit in
|
||||
* Gang.
|
||||
*/
|
||||
const lastRun = state.last_run_at ? Date.parse(`${state.last_run_at.replace(' ', 'T')}Z`) : 0;
|
||||
const waitMs = state.has_more ? CONTINUE_DELAY_MS : active.intervalMinutes * 60_000;
|
||||
|
||||
if (Number.isFinite(lastRun) && lastRun > 0 && Date.now() - lastRun < waitMs) return;
|
||||
|
||||
await runMailSync({ trigger: 'plan' });
|
||||
} catch (err) {
|
||||
console.error('[Mail] Fehler im geplanten Abgleich:', err);
|
||||
await saveSyncState({ last_error: String(err.message).slice(0, 1000) }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
job.start();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
MAILBOX,
|
||||
MAIL_FOLDER,
|
||||
MAIL_CONFIGURED,
|
||||
MAX_PAGES_PER_RUN,
|
||||
MAX_BODY_CHARS,
|
||||
mailSettings,
|
||||
missingCredentials,
|
||||
} from './config.js';
|
||||
import { GraphError, graphRequest, mailboxPath } from './graph.js';
|
||||
import { htmlToText, sanitizeHtml } from './html.js';
|
||||
import { extractCandidates, AUTO_ADOPT_MIN_CONFIDENCE } from './extract.js';
|
||||
import { adoptCandidate } from './contacts.js';
|
||||
import {
|
||||
getMessageStateByHash,
|
||||
getSyncState,
|
||||
insertCandidates,
|
||||
markMessageDeleted,
|
||||
saveSyncState,
|
||||
upsertMessage,
|
||||
} from './store.js';
|
||||
|
||||
/*
|
||||
* Abgleich des Postfachs ueber Microsoft Graph Delta Query.
|
||||
*
|
||||
* Warum Delta und nicht "ungelesene holen und als gelesen markieren" wie im
|
||||
* Altsystem:
|
||||
* - Es braucht keinen Schreibzugriff auf das Postfach. Mail.Read genuegt.
|
||||
* - Oeffnet ein Mensch die Mail vorher in Outlook, wird sie trotzdem erfasst.
|
||||
* Beim isRead-Ansatz war sie verloren.
|
||||
* - Der Fortschritt liegt in der Datenbank (delta_link) und ueberlebt Neustarts.
|
||||
*
|
||||
* Der Delta-Aufruf holt nur Metadaten. Der Mailtext wird einzeln nachgeladen und
|
||||
* ausschliesslich fuer Mails, deren Text noch fehlt.
|
||||
*/
|
||||
|
||||
/** Metadaten, die der Delta-Aufruf liefert. */
|
||||
const DELTA_SELECT = [
|
||||
'id',
|
||||
'internetMessageId',
|
||||
'conversationId',
|
||||
'subject',
|
||||
'from',
|
||||
'sender',
|
||||
'toRecipients',
|
||||
'ccRecipients',
|
||||
'bccRecipients',
|
||||
'receivedDateTime',
|
||||
'sentDateTime',
|
||||
'hasAttachments',
|
||||
'importance',
|
||||
'webLink',
|
||||
'isRead',
|
||||
].join(',');
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
let running = false;
|
||||
let lastResult = null;
|
||||
|
||||
export const isMailSyncRunning = () => running;
|
||||
export const lastMailSyncResult = () => lastResult;
|
||||
|
||||
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
/** ISO-Zeitstempel von Graph in MariaDB-DATETIME (UTC) wandeln. */
|
||||
function toSqlUtc(iso) {
|
||||
if (!iso) return null;
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date.toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
const addressList = (recipients) =>
|
||||
(recipients || [])
|
||||
.map((entry) => ({
|
||||
name: entry?.emailAddress?.name ?? null,
|
||||
email: entry?.emailAddress?.address ? String(entry.emailAddress.address).toLowerCase() : null,
|
||||
}))
|
||||
.filter((entry) => entry.email);
|
||||
|
||||
/** Graph-Nachricht in unsere Form bringen. */
|
||||
function normalizeMessage(raw) {
|
||||
const from = raw.from?.emailAddress ?? raw.sender?.emailAddress ?? {};
|
||||
|
||||
return {
|
||||
graphId: String(raw.id).slice(0, 1024),
|
||||
graphIdHash: hash(String(raw.id)),
|
||||
internetMessageId: raw.internetMessageId ? String(raw.internetMessageId).slice(0, 512) : null,
|
||||
conversationId: raw.conversationId ? String(raw.conversationId).slice(0, 512) : null,
|
||||
subject: raw.subject ? String(raw.subject).slice(0, 512) : null,
|
||||
fromName: from.name ? String(from.name).slice(0, 190) : null,
|
||||
fromEmail: from.address ? String(from.address).toLowerCase().slice(0, 190) : null,
|
||||
to: addressList(raw.toRecipients),
|
||||
cc: addressList(raw.ccRecipients),
|
||||
bcc: addressList(raw.bccRecipients),
|
||||
receivedAt: toSqlUtc(raw.receivedDateTime) ?? toSqlUtc(new Date().toISOString()),
|
||||
sentAt: toSqlUtc(raw.sentDateTime),
|
||||
hasAttachments: Boolean(raw.hasAttachments),
|
||||
importance: raw.importance ? String(raw.importance).slice(0, 16) : null,
|
||||
webLink: raw.webLink ? String(raw.webLink).slice(0, 1024) : null,
|
||||
isReadInMailbox: Boolean(raw.isRead),
|
||||
};
|
||||
}
|
||||
|
||||
/** Mailtext einzeln nachladen und in Text plus entschaerftes HTML wandeln. */
|
||||
async function loadBody(graphId) {
|
||||
const data = await graphRequest(
|
||||
`${mailboxPath(`/messages/${encodeURIComponent(graphId)}`)}?$select=body,bodyPreview`
|
||||
);
|
||||
|
||||
const contentType = String(data?.body?.contentType || '').toLowerCase();
|
||||
const content = data?.body?.content ? String(data.body.content) : '';
|
||||
const capped = content.slice(0, MAX_BODY_CHARS);
|
||||
|
||||
return {
|
||||
bodyPreview: data?.bodyPreview ? String(data.bodyPreview).slice(0, 512) : null,
|
||||
bodyText: contentType === 'html' ? htmlToText(capped) : capped.replace(/\r\n?/g, '\n').trim(),
|
||||
bodyHtml: contentType === 'html' ? sanitizeHtml(capped) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Erster Delta-Aufruf. `since` begrenzt den Erstabgleich. */
|
||||
function initialDeltaUrl(since) {
|
||||
const base = mailboxPath(`/mailFolders/${encodeURIComponent(MAIL_FOLDER)}/messages/delta`);
|
||||
const query = new URLSearchParams();
|
||||
query.set('$select', DELTA_SELECT);
|
||||
query.set('$top', String(PAGE_SIZE));
|
||||
if (since) query.set('$filter', `receivedDateTime ge ${since}`);
|
||||
// URLSearchParams kodiert die Doppelpunkte im Zeitstempel - Graph akzeptiert
|
||||
// beides, aber unkodiert ist es in Logs deutlich besser lesbar.
|
||||
return `${base}?${query.toString().replace(/%3A/g, ':')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delta-Seite holen. Der erste Aufruf versucht es mit Zeitfilter; lehnt Graph den
|
||||
* ab (die Filterunterstuetzung in Delta-Abfragen ist eng begrenzt), wird ohne
|
||||
* Filter wiederholt und der Zeitraum stattdessen beim Speichern beachtet.
|
||||
*/
|
||||
async function fetchDeltaPage(url, { allowFilterFallback = false, since = null } = {}) {
|
||||
try {
|
||||
return { data: await graphRequest(url), url };
|
||||
} catch (err) {
|
||||
if (allowFilterFallback && err instanceof GraphError && err.status === 400) {
|
||||
console.warn('[Mail] Delta mit Zeitfilter abgelehnt - wiederhole ohne Filter.');
|
||||
const fallback = initialDeltaUrl(null);
|
||||
return { data: await graphRequest(fallback), url: fallback, filtered: false, since };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Abgleichslauf. `trigger` steht nur im Protokoll ('plan' oder 'manuell').
|
||||
* Laeuft immer nur einer; ein zweiter Aufruf kommt sofort mit skipped zurueck.
|
||||
*/
|
||||
export async function runMailSync({ trigger = 'manuell' } = {}) {
|
||||
if (!MAIL_CONFIGURED) {
|
||||
return { skipped: 'nicht-konfiguriert', missing: missingCredentials() };
|
||||
}
|
||||
if (running) return { skipped: 'laeuft-bereits' };
|
||||
|
||||
running = true;
|
||||
const startedAt = new Date();
|
||||
let newMessages = 0;
|
||||
let updatedMessages = 0;
|
||||
let deletedMessages = 0;
|
||||
let newCandidates = 0;
|
||||
let adoptedContacts = 0;
|
||||
let pages = 0;
|
||||
|
||||
try {
|
||||
const settings = await mailSettings();
|
||||
await saveSyncState({ mailbox: MAILBOX, last_run_at: toSqlUtc(startedAt.toISOString()) });
|
||||
|
||||
const state = await getSyncState();
|
||||
const isFirstRun = !state.delta_link;
|
||||
|
||||
// Grenze fuer den Erstabgleich. Auch der Filter-Ersatzweg nutzt sie.
|
||||
const cutoff = new Date(Date.now() - settings.sinceDays * 86_400_000);
|
||||
const cutoffSql = toSqlUtc(cutoff.toISOString());
|
||||
|
||||
let url = state.delta_link || initialDeltaUrl(cutoff.toISOString().replace(/\.\d{3}Z$/, 'Z'));
|
||||
// Wird nur gesetzt, wenn Graph den Zeitfilter ablehnt und wir ohne Filter
|
||||
// durchlaufen - dann muss die Zeitgrenze hier greifen.
|
||||
let applyCutoffLocally = false;
|
||||
let deltaLink = null;
|
||||
let hasMore = false;
|
||||
|
||||
while (url && pages < MAX_PAGES_PER_RUN) {
|
||||
const page = await fetchDeltaPage(url, {
|
||||
allowFilterFallback: isFirstRun && pages === 0,
|
||||
since: cutoff.toISOString(),
|
||||
});
|
||||
if (page.filtered === false) applyCutoffLocally = true;
|
||||
|
||||
pages += 1;
|
||||
const data = page.data ?? {};
|
||||
|
||||
for (const raw of data.value || []) {
|
||||
if (!raw?.id) continue;
|
||||
|
||||
// Im Postfach entfernte Mail: Kennzeichnen, nicht loeschen.
|
||||
if (raw['@removed']) {
|
||||
if (await markMessageDeleted(hash(String(raw.id)))) deletedMessages += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const message = normalizeMessage(raw);
|
||||
|
||||
// Beim Ersatzweg ohne Zeitfilter greift die Grenze hier.
|
||||
if (applyCutoffLocally && cutoffSql && message.receivedAt < cutoffSql) continue;
|
||||
|
||||
const existing = await getMessageStateByHash(message.graphIdHash);
|
||||
if (!existing || !existing.hasBody) {
|
||||
try {
|
||||
Object.assign(message, await loadBody(message.graphId));
|
||||
} catch (err) {
|
||||
// Ohne Text ist die Mail immer noch nuetzlich - der Rest steht schon.
|
||||
console.warn(`[Mail] Text konnte nicht geladen werden (${message.subject ?? 'ohne Betreff'}): ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const stored = await upsertMessage(message);
|
||||
if (stored.isNew) newMessages += 1;
|
||||
else updatedMessages += 1;
|
||||
|
||||
// Vorschlaege entstehen nur einmal je Mail - bei erneutem Durchlauf
|
||||
// sperrt der Unique-Schluessel (message_id, dedupe_hash).
|
||||
if (stored.isNew) {
|
||||
const candidates = extractCandidates(
|
||||
{ ...message, bodyText: message.bodyText ?? '' },
|
||||
{ internalDomains: settings.internalDomains, mailbox: MAILBOX }
|
||||
);
|
||||
const created = await insertCandidates(stored.id, candidates);
|
||||
newCandidates += created.length;
|
||||
|
||||
if (settings.autoContacts) {
|
||||
for (const candidate of created) {
|
||||
if (!candidate.email) continue;
|
||||
if (candidate.confidence < AUTO_ADOPT_MIN_CONFIDENCE) continue;
|
||||
try {
|
||||
const result = await adoptCandidate(
|
||||
{
|
||||
id: candidate.id,
|
||||
message_id: stored.id,
|
||||
first_name: candidate.firstName,
|
||||
last_name: candidate.lastName,
|
||||
salutation: candidate.salutation,
|
||||
email: candidate.email,
|
||||
phone: candidate.phone,
|
||||
mobile: candidate.mobile,
|
||||
company: candidate.company,
|
||||
position: candidate.position,
|
||||
street: candidate.street,
|
||||
zip: candidate.zip,
|
||||
city: candidate.city,
|
||||
website: candidate.website,
|
||||
linkedin: candidate.linkedin,
|
||||
},
|
||||
{ mode: 'automatisch' }
|
||||
);
|
||||
if (result.status === 'uebernommen') adoptedContacts += 1;
|
||||
} catch (err) {
|
||||
console.warn(`[Mail] Kontakt-Vorschlag konnte nicht uebernommen werden: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nach jeder Seite den Fortschritt sichern: ein Abbruch mitten im
|
||||
// Erstabgleich soll nicht alles noch einmal durchlaufen.
|
||||
if (data['@odata.nextLink']) {
|
||||
url = data['@odata.nextLink'];
|
||||
hasMore = true;
|
||||
await saveSyncState({ delta_link: url, has_more: 1 });
|
||||
} else {
|
||||
deltaLink = data['@odata.deltaLink'] ?? null;
|
||||
hasMore = false;
|
||||
url = null;
|
||||
if (deltaLink) await saveSyncState({ delta_link: deltaLink, has_more: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
// Seitenlimit erreicht - der naechste Lauf macht an derselben Stelle weiter.
|
||||
const truncated = Boolean(url);
|
||||
if (truncated) hasMore = true;
|
||||
|
||||
await saveSyncState({
|
||||
has_more: hasMore ? 1 : 0,
|
||||
last_success_at: toSqlUtc(new Date().toISOString()),
|
||||
last_error: null,
|
||||
last_new_messages: newMessages,
|
||||
last_candidates: newCandidates,
|
||||
});
|
||||
|
||||
lastResult = {
|
||||
ok: true,
|
||||
trigger,
|
||||
startedAt: startedAt.toISOString(),
|
||||
durationMs: Date.now() - startedAt.getTime(),
|
||||
pages,
|
||||
newMessages,
|
||||
updatedMessages,
|
||||
deletedMessages,
|
||||
newCandidates,
|
||||
adoptedContacts,
|
||||
hasMore: hasMore || truncated,
|
||||
};
|
||||
|
||||
if (newMessages || newCandidates || deletedMessages) {
|
||||
console.log(
|
||||
`[Mail] Abgleich (${trigger}): ${newMessages} neu, ${updatedMessages} aktualisiert, ` +
|
||||
`${deletedMessages} entfernt, ${newCandidates} Kontakt-Vorschlaege, ${adoptedContacts} Kontakte angelegt.`
|
||||
);
|
||||
}
|
||||
if (hasMore || truncated) {
|
||||
console.log('[Mail] Es sind noch weitere Seiten offen - der naechste Lauf setzt fort.');
|
||||
}
|
||||
|
||||
return lastResult;
|
||||
} catch (err) {
|
||||
const message = err instanceof GraphError ? `${err.message}${err.code ? ` (${err.code})` : ''}` : err.message;
|
||||
console.error(`[Mail] Abgleich fehlgeschlagen: ${message}`);
|
||||
|
||||
/*
|
||||
* Ist der Delta-Verweis abgelaufen oder ungueltig (Graph: 410 Gone bzw.
|
||||
* resyncRequired), wird er verworfen. Der naechste Lauf beginnt dann mit
|
||||
* einem frischen Erstabgleich - vorhandene Mails werden dabei erkannt und
|
||||
* nicht doppelt angelegt.
|
||||
*/
|
||||
if (err instanceof GraphError && (err.status === 410 || err.code === 'resyncRequired' || err.code === 'SyncStateNotFound')) {
|
||||
await saveSyncState({ delta_link: null, has_more: 0 });
|
||||
console.warn('[Mail] Delta-Verweis war ungueltig - der naechste Lauf beginnt von vorn.');
|
||||
}
|
||||
|
||||
await saveSyncState({ last_error: message.slice(0, 1000) });
|
||||
|
||||
lastResult = {
|
||||
ok: false,
|
||||
trigger,
|
||||
startedAt: startedAt.toISOString(),
|
||||
durationMs: Date.now() - startedAt.getTime(),
|
||||
error: message,
|
||||
pages,
|
||||
newMessages,
|
||||
newCandidates,
|
||||
};
|
||||
return lastResult;
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
import express from 'express';
|
||||
import { pool } from '../db.js';
|
||||
import { requireAdmin } from '../middleware/auth.js';
|
||||
import { setSetting } from '../settings.js';
|
||||
import {
|
||||
MAILBOX,
|
||||
MAIL_CONFIGURED,
|
||||
MAIL_FOLDER,
|
||||
MAIL_SETTING_DEFAULTS,
|
||||
SCHEDULER_ENABLED,
|
||||
mailSettings,
|
||||
missingCredentials,
|
||||
} from '../mail/config.js';
|
||||
import { testConnection } from '../mail/graph.js';
|
||||
import { getSyncState, resetDelta } from '../mail/store.js';
|
||||
import { isMailSyncRunning, lastMailSyncResult, runMailSync } from '../mail/sync.js';
|
||||
import { adoptCandidate, markCandidate } from '../mail/contacts.js';
|
||||
|
||||
/*
|
||||
* Endpunkte der Postfachanbindung. Lesen darf jeder Angemeldete - das Postfach
|
||||
* crm@perinco.de ist ein gemeinsames Vertriebspostfach. Konfigurieren,
|
||||
* Verbindungstest und manueller Abgleich sind Administratoren vorbehalten.
|
||||
*/
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const parseJsonArray = (raw) => {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const clampInt = (value, fallback, min, max) => {
|
||||
const parsed = Number.parseInt(String(value ?? ''), 10);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.min(max, Math.max(min, parsed));
|
||||
};
|
||||
|
||||
/** Listenform einer Mail - ohne Textkoerper, damit die Liste schlank bleibt. */
|
||||
function mapMessageRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
subject: row.subject,
|
||||
from_name: row.from_name,
|
||||
from_email: row.from_email,
|
||||
to: parseJsonArray(row.to_json),
|
||||
cc: parseJsonArray(row.cc_json),
|
||||
received_at: row.received_at,
|
||||
body_preview: row.body_preview,
|
||||
has_attachments: Boolean(row.has_attachments),
|
||||
is_deleted: Boolean(row.is_deleted),
|
||||
web_link: row.web_link,
|
||||
link_count: Number(row.link_count ?? 0),
|
||||
open_candidates: Number(row.open_candidates ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status und Einstellungen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/mail/status
|
||||
router.get('/status', async (req, res, next) => {
|
||||
try {
|
||||
const settings = await mailSettings();
|
||||
const state = await getSyncState();
|
||||
|
||||
const [[counts]] = await pool.query(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM crm_mail_messages WHERE is_deleted = 0) AS messages,
|
||||
(SELECT COUNT(*) FROM crm_mail_contact_candidates WHERE status = 'offen') AS open_candidates,
|
||||
(SELECT COUNT(*) FROM crm_mail_contact_candidates WHERE status = 'uebernommen') AS adopted_candidates,
|
||||
(SELECT COUNT(*) FROM crm_mail_links) AS links`
|
||||
);
|
||||
|
||||
res.json({
|
||||
configured: MAIL_CONFIGURED,
|
||||
missing: MAIL_CONFIGURED ? [] : missingCredentials(),
|
||||
mailbox: MAILBOX,
|
||||
folder: MAIL_FOLDER,
|
||||
scheduler_enabled: SCHEDULER_ENABLED,
|
||||
active: settings.active,
|
||||
interval_minutes: settings.intervalMinutes,
|
||||
auto_contacts: settings.autoContacts,
|
||||
since_days: settings.sinceDays,
|
||||
running: isMailSyncRunning(),
|
||||
has_more: Boolean(state.has_more),
|
||||
initialized: Boolean(state.delta_link),
|
||||
last_run_at: state.last_run_at,
|
||||
last_success_at: state.last_success_at,
|
||||
last_error: state.last_error,
|
||||
last_new_messages: state.last_new_messages,
|
||||
last_candidates: state.last_candidates,
|
||||
counts: {
|
||||
messages: Number(counts.messages),
|
||||
open_candidates: Number(counts.open_candidates),
|
||||
adopted_candidates: Number(counts.adopted_candidates),
|
||||
links: Number(counts.links),
|
||||
},
|
||||
last_result: lastMailSyncResult(),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/mail/settings
|
||||
router.get('/settings', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const settings = await mailSettings();
|
||||
// Es gibt hier keine Geheimnisse zu maskieren - Zugangsdaten stehen in der
|
||||
// .env und werden bewusst nie ausgeliefert.
|
||||
res.json(settings.raw);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/mail/settings
|
||||
router.post('/settings', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const allowed = Object.keys(MAIL_SETTING_DEFAULTS);
|
||||
const incoming = Object.entries(req.body ?? {}).filter(([key]) => allowed.includes(key));
|
||||
if (incoming.length === 0) {
|
||||
return res.status(400).json({ error: 'Keine bekannten Einstellungen uebergeben.' });
|
||||
}
|
||||
|
||||
for (const [key, value] of incoming) {
|
||||
if (key === 'mail_sync_interval') {
|
||||
const minutes = clampInt(value, 10, 2, 1440);
|
||||
await setSetting(key, String(minutes));
|
||||
continue;
|
||||
}
|
||||
if (key === 'mail_since_days') {
|
||||
const days = clampInt(value, 30, 1, 3650);
|
||||
await setSetting(key, String(days));
|
||||
continue;
|
||||
}
|
||||
if (key === 'mail_sync_active' || key === 'mail_auto_contacts') {
|
||||
await setSetting(key, value === true || value === 'true' ? 'true' : 'false');
|
||||
continue;
|
||||
}
|
||||
await setSetting(key, value === null || value === undefined ? '' : String(value));
|
||||
}
|
||||
|
||||
const settings = await mailSettings();
|
||||
res.json(settings.raw);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/mail/test - Token holen und wirklich auf das Postfach zugreifen
|
||||
router.post('/test', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!MAIL_CONFIGURED) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
stage: 'konfiguration',
|
||||
error: `Zugangsdaten fehlen: ${missingCredentials().join(', ')}`,
|
||||
hint: 'Werte in server/.env setzen und den Dienst neu starten.',
|
||||
});
|
||||
}
|
||||
res.json(await testConnection());
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/mail/sync - Abgleich sofort anstossen
|
||||
router.post('/sync', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!MAIL_CONFIGURED) {
|
||||
return res.status(409).json({ error: `Zugangsdaten fehlen: ${missingCredentials().join(', ')}` });
|
||||
}
|
||||
if (isMailSyncRunning()) {
|
||||
return res.status(409).json({ error: 'Ein Abgleich laeuft bereits. Bitte kurz warten.' });
|
||||
}
|
||||
|
||||
// Vollstaendiger Neuaufbau auf Wunsch: Delta-Verweis verwerfen. Vorhandene
|
||||
// Mails werden dabei erkannt, es entstehen keine Doppelten.
|
||||
if (req.body?.reset === true) await resetDelta();
|
||||
|
||||
const result = await runMailSync({ trigger: 'manuell' });
|
||||
if (result.skipped) return res.status(409).json({ error: `Abgleich uebersprungen: ${result.skipped}` });
|
||||
if (!result.ok) return res.status(502).json({ error: result.error, result });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nachrichten
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/mail/messages?search=&limit=&offset=&filter=
|
||||
router.get('/messages', async (req, res, next) => {
|
||||
try {
|
||||
const limit = clampInt(req.query.limit, 50, 1, 200);
|
||||
const offset = clampInt(req.query.offset, 0, 0, 100_000);
|
||||
|
||||
const where = [];
|
||||
const params = [];
|
||||
|
||||
if (req.query.include_deleted !== 'true') where.push('m.is_deleted = 0');
|
||||
|
||||
const search = String(req.query.search ?? '').trim();
|
||||
if (search) {
|
||||
where.push('(m.subject LIKE ? OR m.from_email LIKE ? OR m.from_name LIKE ? OR m.body_text LIKE ?)');
|
||||
const like = `%${search}%`;
|
||||
params.push(like, like, like, like);
|
||||
}
|
||||
|
||||
// "unverknuepft" ist die Arbeitsliste: Mails, die noch an keiner Opportunity
|
||||
// und an keinem Kontakt haengen.
|
||||
if (req.query.filter === 'unlinked') {
|
||||
where.push('NOT EXISTS (SELECT 1 FROM crm_mail_links l WHERE l.message_id = m.id)');
|
||||
} else if (req.query.filter === 'linked') {
|
||||
where.push('EXISTS (SELECT 1 FROM crm_mail_links l WHERE l.message_id = m.id)');
|
||||
} else if (req.query.filter === 'candidates') {
|
||||
where.push(
|
||||
"EXISTS (SELECT 1 FROM crm_mail_contact_candidates c WHERE c.message_id = m.id AND c.status = 'offen')"
|
||||
);
|
||||
}
|
||||
|
||||
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT m.id, m.subject, m.from_name, m.from_email, m.to_json, m.cc_json, m.received_at,
|
||||
m.body_preview, m.has_attachments, m.is_deleted, m.web_link,
|
||||
(SELECT COUNT(*) FROM crm_mail_links l WHERE l.message_id = m.id) AS link_count,
|
||||
(SELECT COUNT(*) FROM crm_mail_contact_candidates c
|
||||
WHERE c.message_id = m.id AND c.status = 'offen') AS open_candidates
|
||||
FROM crm_mail_messages m
|
||||
${whereSql}
|
||||
ORDER BY m.received_at DESC, m.id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, limit, offset]
|
||||
);
|
||||
|
||||
const [[total]] = await pool.query(
|
||||
`SELECT COUNT(*) AS count FROM crm_mail_messages m ${whereSql}`,
|
||||
params
|
||||
);
|
||||
|
||||
res.json({ total: Number(total.count), limit, offset, messages: rows.map(mapMessageRow) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/mail/messages/:id
|
||||
router.get('/messages/:id', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Mail-ID.' });
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT * FROM crm_mail_messages WHERE id = ? LIMIT 1', [id]);
|
||||
const row = rows[0];
|
||||
if (!row) return res.status(404).json({ error: 'Mail nicht gefunden.' });
|
||||
|
||||
const [links] = await pool.query(
|
||||
`SELECT l.id, l.lead_id, l.opportunity_id, l.origin, l.note, l.created_at,
|
||||
l.created_by, u.name AS created_by_name,
|
||||
CONCAT_WS(' ', lead.first_name, lead.last_name) AS lead_name,
|
||||
lead.company AS lead_company,
|
||||
opp.title AS opportunity_title,
|
||||
opp.stage AS opportunity_stage
|
||||
FROM crm_mail_links l
|
||||
LEFT JOIN crm_leads lead ON lead.id = l.lead_id
|
||||
LEFT JOIN crm_opportunities opp ON opp.id = l.opportunity_id
|
||||
LEFT JOIN crm_users u ON u.id = l.created_by
|
||||
WHERE l.message_id = ?
|
||||
ORDER BY l.id`,
|
||||
[id]
|
||||
);
|
||||
|
||||
const [candidates] = await pool.query(
|
||||
`SELECT c.*,
|
||||
CONCAT_WS(' ', lead.first_name, lead.last_name) AS lead_name,
|
||||
(SELECT lx.id FROM crm_leads lx
|
||||
WHERE c.email IS NOT NULL AND LOWER(lx.email) = LOWER(c.email)
|
||||
ORDER BY lx.id LIMIT 1) AS existing_lead_id
|
||||
FROM crm_mail_contact_candidates c
|
||||
LEFT JOIN crm_leads lead ON lead.id = c.lead_id
|
||||
WHERE c.message_id = ?
|
||||
ORDER BY c.confidence DESC, c.id`,
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
...mapMessageRow({ ...row, link_count: links.length }),
|
||||
bcc: parseJsonArray(row.bcc_json),
|
||||
sent_at: row.sent_at,
|
||||
importance: row.importance,
|
||||
conversation_id: row.conversation_id,
|
||||
internet_message_id: row.internet_message_id,
|
||||
is_read_in_mailbox: Boolean(row.is_read_in_mailbox),
|
||||
body_text: row.body_text,
|
||||
body_html: row.body_html,
|
||||
links,
|
||||
candidates: candidates.map((candidate) => ({
|
||||
...candidate,
|
||||
confidence: Number(candidate.confidence),
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/mail/messages/:id/links - Mail an Kontakt und/oder Opportunity haengen
|
||||
router.post('/messages/:id/links', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Mail-ID.' });
|
||||
|
||||
const leadId = req.body?.lead_id === undefined || req.body.lead_id === null ? null : Number(req.body.lead_id);
|
||||
const opportunityId =
|
||||
req.body?.opportunity_id === undefined || req.body.opportunity_id === null
|
||||
? null
|
||||
: Number(req.body.opportunity_id);
|
||||
|
||||
if (leadId === null && opportunityId === null) {
|
||||
return res.status(400).json({ error: 'Kontakt oder Opportunity angeben.' });
|
||||
}
|
||||
if (leadId !== null && !Number.isInteger(leadId)) {
|
||||
return res.status(400).json({ error: 'Ungueltige Kontakt-ID.' });
|
||||
}
|
||||
if (opportunityId !== null && !Number.isInteger(opportunityId)) {
|
||||
return res.status(400).json({ error: 'Ungueltige Opportunity-ID.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [messages] = await pool.query('SELECT id FROM crm_mail_messages WHERE id = ? LIMIT 1', [id]);
|
||||
if (!messages[0]) return res.status(404).json({ error: 'Mail nicht gefunden.' });
|
||||
|
||||
// Erst beide Ziele pruefen, dann schreiben. Sonst haengt bei einer falschen
|
||||
// Opportunity-ID der Kontakt schon dran, obwohl die Anfrage mit 400 endet.
|
||||
if (leadId !== null) {
|
||||
const [exists] = await pool.query('SELECT id FROM crm_leads WHERE id = ? LIMIT 1', [leadId]);
|
||||
if (!exists[0]) return res.status(400).json({ error: 'Kontakt existiert nicht.' });
|
||||
}
|
||||
if (opportunityId !== null) {
|
||||
const [exists] = await pool.query('SELECT id FROM crm_opportunities WHERE id = ? LIMIT 1', [
|
||||
opportunityId,
|
||||
]);
|
||||
if (!exists[0]) return res.status(400).json({ error: 'Opportunity existiert nicht.' });
|
||||
}
|
||||
|
||||
const note = req.body?.note ? String(req.body.note).slice(0, 500) : null;
|
||||
const created = [];
|
||||
|
||||
// Kontakt und Opportunity werden als zwei Verknuepfungen gefuehrt - so laesst
|
||||
// sich jede einzeln wieder loesen.
|
||||
for (const [leadValue, opportunityValue] of [
|
||||
[leadId, null],
|
||||
[null, opportunityId],
|
||||
]) {
|
||||
if (leadValue === null && opportunityValue === null) continue;
|
||||
|
||||
const [result] = await pool.query(
|
||||
`INSERT IGNORE INTO crm_mail_links (message_id, lead_id, opportunity_id, origin, note, created_by)
|
||||
VALUES (?, ?, ?, 'manuell', ?, ?)`,
|
||||
[id, leadValue, opportunityValue, note, req.user.id]
|
||||
);
|
||||
if (result.affectedRows > 0) created.push(result.insertId);
|
||||
}
|
||||
|
||||
if (created.length === 0) {
|
||||
return res.status(409).json({ error: 'Diese Verknuepfung besteht bereits.' });
|
||||
}
|
||||
|
||||
res.status(201).json({ ok: true, created });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/mail/links/:id
|
||||
router.delete('/links/:id', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Verknuepfungs-ID.' });
|
||||
|
||||
try {
|
||||
const [result] = await pool.query('DELETE FROM crm_mail_links WHERE id = ?', [id]);
|
||||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Verknuepfung nicht gefunden.' });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kontakt-Vorschlaege
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CANDIDATE_STATUSES = new Set(['offen', 'uebernommen', 'verworfen', 'dublette']);
|
||||
|
||||
// GET /api/mail/candidates?status=offen
|
||||
router.get('/candidates', async (req, res, next) => {
|
||||
try {
|
||||
const limit = clampInt(req.query.limit, 100, 1, 500);
|
||||
const status = String(req.query.status ?? 'offen');
|
||||
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (status !== 'alle') {
|
||||
if (!CANDIDATE_STATUSES.has(status)) return res.status(400).json({ error: 'Ungueltiger Status.' });
|
||||
where.push('c.status = ?');
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT c.*,
|
||||
m.subject AS message_subject,
|
||||
m.from_email AS message_from,
|
||||
m.received_at AS message_received_at,
|
||||
CONCAT_WS(' ', lead.first_name, lead.last_name) AS lead_name,
|
||||
u.name AS decided_by_name,
|
||||
/* Gibt es diese Adresse schon als Kontakt? Dann ist es keine
|
||||
Neuanlage, sondern eine Ergaenzung. */
|
||||
(SELECT lx.id FROM crm_leads lx
|
||||
WHERE c.email IS NOT NULL AND LOWER(lx.email) = LOWER(c.email)
|
||||
ORDER BY lx.id LIMIT 1) AS existing_lead_id
|
||||
FROM crm_mail_contact_candidates c
|
||||
JOIN crm_mail_messages m ON m.id = c.message_id
|
||||
LEFT JOIN crm_leads lead ON lead.id = c.lead_id
|
||||
LEFT JOIN crm_users u ON u.id = c.decided_by
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
ORDER BY c.confidence DESC, m.received_at DESC, c.id DESC
|
||||
LIMIT ?`,
|
||||
[...params, limit]
|
||||
);
|
||||
|
||||
res.json(
|
||||
rows.map((row) => ({
|
||||
...row,
|
||||
confidence: Number(row.confidence),
|
||||
existing_lead_id: row.existing_lead_id ?? null,
|
||||
}))
|
||||
);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadCandidate(id) {
|
||||
const [rows] = await pool.query('SELECT * FROM crm_mail_contact_candidates WHERE id = ? LIMIT 1', [id]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// POST /api/mail/candidates/:id/adopt - Vorschlag zum Kontakt machen
|
||||
router.post('/candidates/:id/adopt', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Vorschlags-ID.' });
|
||||
|
||||
try {
|
||||
const candidate = await loadCandidate(id);
|
||||
if (!candidate) return res.status(404).json({ error: 'Vorschlag nicht gefunden.' });
|
||||
if (candidate.status !== 'offen') {
|
||||
return res.status(409).json({ error: `Vorschlag ist bereits bearbeitet (${candidate.status}).` });
|
||||
}
|
||||
|
||||
// Zustaendigkeit: uebergeben, sonst der Bearbeitende selbst.
|
||||
const ownerId =
|
||||
req.body?.owner_id === undefined || req.body.owner_id === null
|
||||
? req.user.id
|
||||
: Number(req.body.owner_id);
|
||||
if (!Number.isInteger(ownerId)) return res.status(400).json({ error: 'Ungueltige Benutzer-ID.' });
|
||||
|
||||
const result = await adoptCandidate(candidate, {
|
||||
userId: req.user.id,
|
||||
ownerId,
|
||||
mode: 'manuell',
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/mail/candidates/:id/reject
|
||||
router.post('/candidates/:id/reject', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Vorschlags-ID.' });
|
||||
|
||||
try {
|
||||
const candidate = await loadCandidate(id);
|
||||
if (!candidate) return res.status(404).json({ error: 'Vorschlag nicht gefunden.' });
|
||||
|
||||
await markCandidate(id, 'verworfen', { userId: req.user.id });
|
||||
res.json({ ok: true, status: 'verworfen' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -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);
|
||||
|
||||
@@ -9,10 +9,12 @@ import connectionRoutes from './routes/connections.js';
|
||||
import eventRoutes from './routes/events.js';
|
||||
import opportunityRoutes from './routes/opportunities.js';
|
||||
import scannerRoutes from './routes/scanner.js';
|
||||
import mailRoutes from './routes/mail.js';
|
||||
import { requireAuth } from './middleware/auth.js';
|
||||
import { initScannerDatabase } from './scanner/models/index.js';
|
||||
import { startScheduler } from './scanner/services/scheduler.js';
|
||||
import { SCANNER_KEY_CONFIGURED } from './scanner/utils/crypto.js';
|
||||
import { startMailScheduler } from './mail/scheduler.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = Number(process.env.PORT || 5006);
|
||||
@@ -50,6 +52,10 @@ app.use('/api/opportunities', opportunityRoutes);
|
||||
// HTTP-Basic-Auth-Zugang des Scanners entfaellt damit.
|
||||
app.use('/api/scanner', requireAuth, scannerRoutes);
|
||||
|
||||
// Postfachanbindung crm@perinco.de. Lesen darf jeder Angemeldete, konfigurieren
|
||||
// und anstossen nur Administratoren - das regeln die Routen selbst.
|
||||
app.use('/api/mail', requireAuth, mailRoutes);
|
||||
|
||||
// Das Diktat laeuft komplett im Browser (Whisper ueber transformers.js) - das
|
||||
// Backend ist daran nicht beteiligt und sieht keine Audiodaten.
|
||||
|
||||
@@ -110,6 +116,13 @@ try {
|
||||
console.log('[Scanner] Scheduler deaktiviert (SCANNER_SCHEDULER=false).');
|
||||
}
|
||||
|
||||
/*
|
||||
* Postfachabgleich (Microsoft Graph). Fehlen die Zugangsdaten in der .env,
|
||||
* meldet der Scheduler das und bleibt aus - die Oberflaeche laeuft weiter und
|
||||
* zeigt in den Einstellungen, welche Werte fehlen.
|
||||
*/
|
||||
await startMailScheduler();
|
||||
|
||||
app.listen(PORT, HOST, () => {
|
||||
console.log(`[perinco CRM] Backend laeuft auf http://${HOST}:${PORT}`);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { pool } from './db.js';
|
||||
|
||||
/*
|
||||
* Allgemeine CRM-Einstellungen (Tabelle crm_settings) als Schluessel/Wert.
|
||||
*
|
||||
* Absichtlich getrennt von den Scanner-Einstellungen: der Scanner hat seine
|
||||
* eigene Datenbank und sein eigenes Setting-Modell. Und absichtlich ohne
|
||||
* Geheimnisse - Zugangsdaten fuer Microsoft 365 stehen ausschliesslich in der
|
||||
* .env, damit ein Datenbank-Dump keine Secrets enthaelt.
|
||||
*
|
||||
* Alle Werte liegen als Text in der Datenbank, auch Zahlen und Schalter. Die
|
||||
* Helfer unten wandeln beim Lesen.
|
||||
*/
|
||||
|
||||
/** Einzelnen Wert lesen; `fallback`, wenn der Schluessel fehlt. */
|
||||
export async function getSetting(key, fallback = null) {
|
||||
const [rows] = await pool.query('SELECT value FROM crm_settings WHERE key_name = ? LIMIT 1', [key]);
|
||||
return rows[0] ? rows[0].value : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mehrere Werte auf einmal, aufgefuellt mit den Vorgaben. `defaults` ist
|
||||
* gleichzeitig die Liste der erlaubten Schluessel.
|
||||
*/
|
||||
export async function getSettings(defaults) {
|
||||
const keys = Object.keys(defaults);
|
||||
if (keys.length === 0) return {};
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT key_name, value FROM crm_settings WHERE key_name IN (${keys.map(() => '?').join(', ')})`,
|
||||
keys
|
||||
);
|
||||
|
||||
const result = { ...defaults };
|
||||
for (const row of rows) {
|
||||
// Ein leerer Eintrag soll nicht die Vorgabe ueberschreiben.
|
||||
if (row.value !== null && row.value !== '') result[row.key_name] = row.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Wert setzen (anlegen oder ueberschreiben). */
|
||||
export async function setSetting(key, value) {
|
||||
await pool.query(
|
||||
`INSERT INTO crm_settings (key_name, value) VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE value = VALUES(value)`,
|
||||
[key, value === null || value === undefined ? null : String(value)]
|
||||
);
|
||||
}
|
||||
|
||||
/** `'true'` gilt als an, alles andere als aus. */
|
||||
export function asBool(value, fallback = false) {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
return String(value) === 'true' || String(value) === '1';
|
||||
}
|
||||
|
||||
/** Zahl mit Grenzen; ausserhalb oder unlesbar heisst Vorgabe. */
|
||||
export function asInt(value, fallback, { min = -Infinity, max = Infinity } = {}) {
|
||||
const parsed = Number.parseInt(String(value ?? ''), 10);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.min(max, Math.max(min, parsed));
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/*
|
||||
* Pruefungen fuer die Postfachanbindung: `npm test` im Verzeichnis server/.
|
||||
*
|
||||
* Absichtlich ohne Test-Framework - reines Node, kein zusaetzliches Paket. Es
|
||||
* geht um zwei Teile, die unbeaufsichtigt laufen und sich von Hand kaum pruefen
|
||||
* lassen:
|
||||
*
|
||||
* 1. extract.js - was aus einer Mail als Kontakt herausfaellt. Fehler hier
|
||||
* landen still in der Kontaktliste.
|
||||
* 2. graph.js - Token, Wiederholungen, Fehlerbehandlung. Der echte Dienst
|
||||
* liefert 429 und 401 erst dann, wenn es unpassend ist.
|
||||
*
|
||||
* Alles, was eine Datenbank braucht (store.js, contacts.js, sync.js), ist hier
|
||||
* nicht abgedeckt.
|
||||
*/
|
||||
|
||||
process.env.MS_TENANT_ID ||= 'test-tenant';
|
||||
process.env.MS_CLIENT_ID ||= 'test-client';
|
||||
process.env.MS_CLIENT_SECRET ||= 'test-secret';
|
||||
process.env.MS_MAILBOX ||= 'crm@perinco.de';
|
||||
|
||||
const { htmlToText, sanitizeHtml } = await import('../src/mail/html.js');
|
||||
const { extractCandidates, parsePersonName, parseSignature, isFunctionalAddress } = await import(
|
||||
'../src/mail/extract.js'
|
||||
);
|
||||
const { accessToken, graphRequest, resetTokenCache, testConnection, GraphError, mailboxPath } =
|
||||
await import('../src/mail/graph.js');
|
||||
|
||||
let failures = 0;
|
||||
let currentGroup = '';
|
||||
|
||||
function group(name) {
|
||||
currentGroup = name;
|
||||
console.log(`\n${name}`);
|
||||
}
|
||||
|
||||
function check(name, condition, detail = '') {
|
||||
if (condition) {
|
||||
console.log(` ok ${name}${detail ? ` - ${detail}` : ''}`);
|
||||
} else {
|
||||
console.log(` FAIL ${name}${detail ? ` - ${detail}` : ''}`);
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const OPTIONS = {
|
||||
internalDomains: ['perinco.de', 'perinco.onmicrosoft.com'],
|
||||
mailbox: 'crm@perinco.de',
|
||||
};
|
||||
|
||||
const byEmail = (candidates, email) => candidates.find((entry) => entry.email === email);
|
||||
const byName = (candidates, name) =>
|
||||
candidates.find((entry) => [entry.firstName, entry.lastName].filter(Boolean).join(' ') === name);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
group('HTML in Text');
|
||||
|
||||
const outlookHtml = `<html><head><style>p{margin:0}</style></head><body>
|
||||
<div>Hallo Herr Wolf,</div><div><br></div>
|
||||
<div>vielen Dank für das Gespräch.</div><div><br></div>
|
||||
<div>Mit freundlichen Grüßen</div>
|
||||
<table><tr><td>Dr. Michael Wagner<br>Head of IT Sourcing<br>
|
||||
Beispiel Consulting GmbH<br>Musterstraße 12a<br>60311 Frankfurt am Main<br>
|
||||
Tel.: +49 69 1234-567<br>Mobil: +49 171 9876543<br>
|
||||
michael.wagner@beispiel-consulting.de<br>
|
||||
<a href="https://www.linkedin.com/in/mwagner">LinkedIn</a></td></tr></table>
|
||||
</body></html>`;
|
||||
|
||||
const text = htmlToText(outlookHtml);
|
||||
check('Zeilenumbrueche bleiben erhalten', text.split('\n').length > 5, `${text.split('\n').length} Zeilen`);
|
||||
check('Style-Block ist entfernt', !text.includes('margin:0'));
|
||||
check('Entitaeten sind aufgeloest', text.includes('Grüßen') && text.includes('Musterstraße'));
|
||||
check(
|
||||
'Linkziel steht hinter dem Linktext',
|
||||
text.includes('LinkedIn (https://www.linkedin.com/in/mwagner)')
|
||||
);
|
||||
check('Keine drei Leerzeilen in Folge', !/\n\s*\n\s*\n/.test(text));
|
||||
|
||||
const dirty = `<div onclick="steal()">Text</div><script>alert(1)</script>
|
||||
<iframe src="https://evil.example"></iframe><a href="javascript:alert(1)">Klick</a>`;
|
||||
const clean = sanitizeHtml(dirty);
|
||||
check('Skript ist entfernt', !clean.includes('<script') && !clean.includes('alert(1)</script'));
|
||||
check('Rahmen ist entfernt', !clean.toLowerCase().includes('<iframe'));
|
||||
check('Ereignis-Attribut ist entfernt', !clean.includes('onclick'));
|
||||
check('javascript:-Adresse ist entfernt', !clean.includes('javascript:'));
|
||||
check('Nutzinhalt bleibt', clean.includes('Text'));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
group('Namen zerlegen');
|
||||
|
||||
const nameCases = [
|
||||
['Dr. Michael Wagner', 'm.wagner@x.de', 'Michael', 'Wagner'],
|
||||
['Wolf, Sascha', null, 'Sascha', 'Wolf'],
|
||||
['Sascha van der Berg', null, 'Sascha', 'van der Berg'],
|
||||
['Prof. Dr. Klaus Meier, MBA', null, 'Klaus', 'Meier'],
|
||||
['Wagner, Michael (Beispiel Consulting)', null, 'Michael', 'Wagner'],
|
||||
[null, 'anna.mueller@x.de', 'Anna', 'Mueller'],
|
||||
['sascha.wolf@perinco.de', 'sascha.wolf@perinco.de', 'Sascha', 'Wolf'],
|
||||
];
|
||||
|
||||
for (const [display, email, expectedFirst, expectedLast] of nameCases) {
|
||||
const parsed = parsePersonName(display, email);
|
||||
check(
|
||||
`${display ?? '(kein Anzeigename)'} + ${email ?? '(keine Adresse)'}`,
|
||||
parsed.firstName === expectedFirst && parsed.lastName === expectedLast,
|
||||
`${parsed.firstName} / ${parsed.lastName}`
|
||||
);
|
||||
}
|
||||
|
||||
// Umlaut aus dem Anzeigenamen schlaegt die Umschrift aus der Adresse.
|
||||
const umlaut = parsePersonName('Müller', 'k.mueller@x.de');
|
||||
check('Umlaut des Anzeigenamens gewinnt', umlaut.lastName === 'Müller', umlaut.lastName ?? '-');
|
||||
|
||||
check('Anrede wird erkannt', parsePersonName('Herr Klaus Meier').salutation === 'Herr');
|
||||
check('Funktionsadresse wird erkannt', isFunctionalAddress('info@x.de') && isFunctionalAddress('no-reply@x.de'));
|
||||
check('Personenadresse gilt nicht als Funktionsadresse', !isFunctionalAddress('anna.mueller@x.de'));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
group('Signatur lesen');
|
||||
|
||||
const signature = parseSignature(text);
|
||||
check('Festnetz trotz "Tel.:"', signature.phone === '+49 69 1234-567', signature.phone ?? '-');
|
||||
check('Mobil getrennt erkannt', signature.mobile === '+49 171 9876543', signature.mobile ?? '-');
|
||||
check('Firma an der Rechtsform', signature.company === 'Beispiel Consulting GmbH', signature.company ?? '-');
|
||||
check('Position erkannt', signature.position === 'Head of IT Sourcing', signature.position ?? '-');
|
||||
check('Strasse erkannt', signature.street === 'Musterstraße 12a', signature.street ?? '-');
|
||||
check('Ort mit Zusatz', signature.city === 'Frankfurt am Main', signature.city ?? '-');
|
||||
check('PLZ erkannt', signature.zip === '60311', signature.zip ?? '-');
|
||||
check('LinkedIn erkannt', signature.linkedin === 'https://www.linkedin.com/in/mwagner', signature.linkedin ?? '-');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
group('Kontakte aus einer Mail');
|
||||
|
||||
const external = extractCandidates(
|
||||
{
|
||||
fromName: 'Wagner, Michael (Beispiel Consulting)',
|
||||
fromEmail: 'Michael.Wagner@beispiel-consulting.de',
|
||||
to: [{ name: 'Sascha Wolf', email: 'sascha.wolf@perinco.de' }],
|
||||
cc: [{ name: null, email: 'crm@perinco.de' }],
|
||||
bcc: [],
|
||||
bodyText: text,
|
||||
},
|
||||
OPTIONS
|
||||
);
|
||||
|
||||
check('genau ein Vorschlag', external.length === 1, `${external.length}`);
|
||||
const wagner = byEmail(external, 'michael.wagner@beispiel-consulting.de');
|
||||
check('Absender gefunden', Boolean(wagner));
|
||||
check('Signatur bestaetigt den Absender', wagner?.confidence === 95, String(wagner?.confidence));
|
||||
check('Firma uebernommen', wagner?.company === 'Beispiel Consulting GmbH');
|
||||
check('Eigene Domain wird uebersprungen', !byEmail(external, 'sascha.wolf@perinco.de'));
|
||||
check('Postfach selbst wird uebersprungen', !byEmail(external, 'crm@perinco.de'));
|
||||
|
||||
// Interne Mail mit dem CRM auf Cc: die externen Empfaenger sind die Kontakte.
|
||||
const carbonCopy = extractCandidates(
|
||||
{
|
||||
fromName: 'Sascha Wolf',
|
||||
fromEmail: 'sascha.wolf@perinco.de',
|
||||
to: [
|
||||
{ name: 'Anna Müller', email: 'a.mueller@kunde-ag.de' },
|
||||
{ name: null, email: 'thomas.schmidt@kunde-ag.de' },
|
||||
],
|
||||
cc: [
|
||||
{ name: 'CRM', email: 'crm@perinco.de' },
|
||||
{ name: 'Info', email: 'info@kunde-ag.de' },
|
||||
],
|
||||
bcc: [],
|
||||
bodyText: 'Hallo zusammen,\n\nanbei das Angebot.\n\nViele Grüße\nSascha Wolf\nCEO\nperinco GmbH',
|
||||
},
|
||||
OPTIONS
|
||||
);
|
||||
|
||||
check('beide externen Empfaenger', carbonCopy.length === 2, `${carbonCopy.length}`);
|
||||
check('Anzeigename genutzt', byEmail(carbonCopy, 'a.mueller@kunde-ag.de')?.lastName === 'Müller');
|
||||
check('Name aus der Adresse abgeleitet', byEmail(carbonCopy, 'thomas.schmidt@kunde-ag.de')?.firstName === 'Thomas');
|
||||
check('Funktionsadresse verworfen', !byEmail(carbonCopy, 'info@kunde-ag.de'));
|
||||
check(
|
||||
'Empfaenger duerfen automatisch uebernommen werden',
|
||||
carbonCopy.every((entry) => entry.confidence >= 70)
|
||||
);
|
||||
|
||||
// Automat: darf nichts liefern.
|
||||
const automated = extractCandidates(
|
||||
{
|
||||
fromName: 'freelancermap Newsletter',
|
||||
fromEmail: 'no-reply@news.freelancermap.de',
|
||||
to: [{ name: null, email: 'crm@perinco.de' }],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
bodyText: 'Neue Projekte für Sie.\n\nAbmelden: https://news.freelancermap.de/unsubscribe',
|
||||
},
|
||||
OPTIONS
|
||||
);
|
||||
check('Automat liefert keinen Kontakt', automated.length === 0, `${automated.length}`);
|
||||
|
||||
// Personen im Fliesstext - nur mit Anrede, und nie automatisch.
|
||||
const mentioned = extractCandidates(
|
||||
{
|
||||
fromName: 'Petra Klein',
|
||||
fromEmail: 'p.klein@vermittler.de',
|
||||
to: [{ name: null, email: 'crm@perinco.de' }],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
bodyText:
|
||||
'Guten Tag,\n\nbitte melden Sie sich bei Herrn Max Mustermann Tel. 0221/3456789.\n' +
|
||||
'Zweiter Ansprechpartner ist Frau Julia Berg, 0170 1122334.\n' +
|
||||
'Das Angebot Neue Leistung Zwei liegt bei 0221/111111.\n\n' +
|
||||
'Mit freundlichen Grüßen\nPetra Klein\nVermittler GmbH\nTel: 030/998877',
|
||||
},
|
||||
OPTIONS
|
||||
);
|
||||
|
||||
check('Anrede-Treffer erkannt', Boolean(byName(mentioned, 'Max Mustermann') && byName(mentioned, 'Julia Berg')));
|
||||
check('Beschriftung nicht im Namen', !mentioned.some((entry) => /Tel/.test(entry.lastName ?? '')));
|
||||
check(
|
||||
'Wortfolge ohne Anrede wird nicht zum Kontakt',
|
||||
!byName(mentioned, 'Neue Leistung Zwei') && !byName(mentioned, 'Zweiter Ansprechpartner')
|
||||
);
|
||||
check(
|
||||
'Textfunde bleiben zur Durchsicht',
|
||||
mentioned.filter((entry) => entry.origin === 'text').every((entry) => entry.confidence < 70)
|
||||
);
|
||||
check('Absender behaelt seine Rufnummer', byEmail(mentioned, 'p.klein@vermittler.de')?.phone === '030/998877');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
group('Graph-Client');
|
||||
|
||||
let calls = [];
|
||||
let handlers = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async (url, options) => {
|
||||
calls.push({ url: String(url), method: options?.method ?? 'GET' });
|
||||
const handler = handlers.shift();
|
||||
if (!handler) throw new Error(`Kein Handler mehr fuer ${url}`);
|
||||
return typeof handler === 'function' ? handler(String(url), options) : handler;
|
||||
};
|
||||
|
||||
const json = (status, body, headers = {}) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
});
|
||||
const tokenOk = () => json(200, { access_token: 'TOKEN-A', expires_in: 3600 });
|
||||
const networkError = () => {
|
||||
throw new TypeError('fetch failed');
|
||||
};
|
||||
|
||||
function arrange(list) {
|
||||
calls = [];
|
||||
handlers = list;
|
||||
resetTokenCache();
|
||||
}
|
||||
|
||||
arrange([tokenOk()]);
|
||||
const first = await accessToken();
|
||||
const second = await accessToken();
|
||||
check('Token wird zwischengespeichert', calls.length === 1 && first === second, `${calls.length} Aufruf(e)`);
|
||||
|
||||
arrange([json(400, { error: 'invalid_client', error_description: 'AADSTS7000215: Invalid client secret provided.' })]);
|
||||
try {
|
||||
await accessToken();
|
||||
check('Anmeldefehler wird gemeldet', false);
|
||||
} catch (err) {
|
||||
check(
|
||||
'Anmeldefehler nennt den Grund',
|
||||
err instanceof GraphError && err.message.includes('AADSTS7000215'),
|
||||
err.message
|
||||
);
|
||||
}
|
||||
|
||||
arrange([tokenOk(), json(429, { error: {} }, { 'retry-after': '0' }), json(200, { value: [1, 2] })]);
|
||||
const throttled = await graphRequest('/users/x/messages');
|
||||
check('429 wird wiederholt', throttled.value.length === 2 && calls.length === 3, `${calls.length} Aufruf(e)`);
|
||||
|
||||
arrange([tokenOk(), json(401, { error: { code: 'InvalidAuthenticationToken' } }), tokenOk(), json(200, { ok: true })]);
|
||||
const renewed = await graphRequest('/users/x/messages');
|
||||
check('401 holt ein neues Token', renewed.ok === true && calls.length === 4, `${calls.length} Aufruf(e)`);
|
||||
|
||||
arrange([
|
||||
tokenOk(),
|
||||
json(401, { error: { code: 'InvalidAuthenticationToken' } }),
|
||||
tokenOk(),
|
||||
json(401, { error: { code: 'InvalidAuthenticationToken', message: 'weg' } }),
|
||||
]);
|
||||
try {
|
||||
await graphRequest('/users/x/messages');
|
||||
check('Zweites 401 bricht ab', false);
|
||||
} catch (err) {
|
||||
check('Zweites 401 bricht ab', err.status === 401 && calls.length === 4, `${calls.length} Aufruf(e)`);
|
||||
}
|
||||
|
||||
arrange([tokenOk(), json(403, { error: { code: 'ErrorAccessDenied', message: 'Access is denied.' } })]);
|
||||
try {
|
||||
await graphRequest('/users/x/messages');
|
||||
check('403 wird durchgereicht', false);
|
||||
} catch (err) {
|
||||
check('403 wird nicht wiederholt', err.code === 'ErrorAccessDenied' && calls.length === 2, `${calls.length} Aufruf(e)`);
|
||||
}
|
||||
|
||||
arrange([tokenOk(), json(200, { value: [] })]);
|
||||
const deltaUrl = 'https://graph.microsoft.com/v1.0/users/x/messages/delta?$deltatoken=abc';
|
||||
await graphRequest(deltaUrl);
|
||||
check('Absolute Delta-URL bleibt unveraendert', calls[1].url === deltaUrl, calls[1].url);
|
||||
|
||||
check(
|
||||
'Postfachpfad kodiert die Adresse',
|
||||
mailboxPath('/messages') === '/users/crm%40perinco.de/messages',
|
||||
mailboxPath('/messages')
|
||||
);
|
||||
|
||||
arrange([tokenOk(), json(403, { error: { code: 'ErrorAccessDenied', message: 'Access is denied.' } })]);
|
||||
const denied = await testConnection();
|
||||
check(
|
||||
'Test erkennt fehlende Berechtigung',
|
||||
denied.ok === false && denied.stage === 'mailbox' && denied.hint.includes('Mail.Read')
|
||||
);
|
||||
|
||||
arrange([tokenOk(), json(200, { displayName: 'Posteingang', totalItemCount: 1842, unreadItemCount: 3 })]);
|
||||
const granted = await testConnection();
|
||||
check('Test bestaetigt Postfachzugriff', granted.ok === true && granted.totalItems === 1842);
|
||||
|
||||
arrange([tokenOk(), networkError, networkError, networkError, networkError]);
|
||||
try {
|
||||
await graphRequest('/users/x/messages');
|
||||
check('Netzwerkfehler wird gemeldet', false);
|
||||
} catch (err) {
|
||||
check('Netzwerkfehler wird gemeldet', err instanceof GraphError && err.status === 0, err.message);
|
||||
}
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log(
|
||||
failures === 0
|
||||
? '\nAlle Pruefungen bestanden.'
|
||||
: `\n${failures} Pruefung(en) fehlgeschlagen (letzte Gruppe: ${currentGroup}).`
|
||||
);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user