feat(postfach): crm@perinco.de lesend anbinden, Kontakte aus Mails

Spiegelt das Vertriebspostfach ueber Microsoft Graph ins CRM. Aus jeder Mail
entstehen Kontakt-Vorschlaege, und jede Mail laesst sich an Opportunities und
Kontakte haengen.

Uebernommen ist die Idee aus dem Altsystem KeepTrack, nicht die Umsetzung:

- Delta Query statt "$filter=isRead eq false" mit anschliessendem PATCH. Damit
  genuegt die Anwendungsberechtigung Mail.Read, es braucht keinen Schreibzugriff
  auf das Postfach, und eine vorher in Outlook geoeffnete Mail geht nicht
  verloren. Der Fortschritt liegt als delta_link in der Datenbank und ueberlebt
  Neustarts; ein Lauf ist gedeckelt und fortsetzbar.
- Tenant- und Client-ID kommen aus der .env, nicht aus dem Browser. Im
  Altsystem konnte jeder Angemeldete das Server-Secret gegen einen fremden
  Tenant verwenden.
- Kontaktdaten werden aus den Graph-Feldern und der Signatur gelesen, ohne
  Sprachmodell. Mailinhalte verlassen den Server nicht.
- Ein Vorschlag ohne E-Mail-Adresse wird nicht mit einer erfundenen Adresse
  angelegt, sondern bleibt zur Durchsicht liegen. Ist die Adresse schon als
  Kontakt vorhanden, werden nur leere Felder ergaenzt.
- Mails haengen als eigene Verknuepfung an Opportunity und Kontakt, nicht als
  Text im Notizfeld - loesbar und mehrfach moeglich.
- Fremdes Mail-HTML wird serverseitig entschaerft und in einem iframe mit
  sandbox="" gezeigt. Externe Bilder sind per CSP gesperrt, damit das Oeffnen
  dem Absender nichts meldet.
- Der Verbindungstest prueft Anmeldung und tatsaechlichen Postfachzugriff und
  benennt den wahrscheinlichen Grund. Der alte Test holte nur ein Token.

Fehlen die MS_*-Werte, startet das CRM normal; die Einstellungen nennen die
fehlende Variable. Neue Tabellen: crm_mail_messages, crm_mail_links,
crm_mail_contact_candidates, crm_mail_sync_state, crm_settings.

Uebernommene Kontakte tragen source = 'Mail'; die Kontaktliste hat dafuer einen
Herkunftsfilter.

Nicht enthalten: Senden, Antworten, Weiterleiten, Anhaenge und die automatische
Zuordnung zu Opportunities.

npm test im Verzeichnis server prueft Kontaktextraktion und Graph-Client
(47 Pruefungen, ohne Framework und ohne Datenbank).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-11 00:06:01 +02:00
co-authored by Claude Opus 5
parent 3647201111
commit 6fd713497d
42 changed files with 5926 additions and 32 deletions
+346
View File
@@ -0,0 +1,346 @@
import React, { useEffect, useState } from 'react';
import { inputClass, labelClass } from '../leads/leadUi';
import { useToast } from '../Toast';
import {
MAIL_SETTINGS_DEFAULTS,
mailApi,
formatMailDateTime,
type MailConnectionTest,
type MailSettingsValues,
type MailStatus,
} from '../../lib/mailApi';
/*
* Einstellungen der Postfachanbindung.
*
* Zugangsdaten stehen bewusst nicht hier, sondern in server/.env - sie tauchen
* damit weder in der Oberflaeche noch in einem Datenbank-Dump auf. Diese Seite
* zeigt nur, ob sie vorhanden sind, und laesst den Betrieb steuern.
*/
const Section: React.FC<{
title: string;
description?: string;
children: React.ReactNode;
accent?: string;
}> = ({ title, description, children, accent }) => (
<section className={`rounded-2xl border border-ink-200 bg-surface p-5 ${accent ? `border-l-4 ${accent}` : ''}`}>
<h3 className="text-base text-ink-900">{title}</h3>
{description && <p className="mt-1 text-xs text-ink-400">{description}</p>}
<div className="mt-4">{children}</div>
</section>
);
const Checkbox: React.FC<{ checked: boolean; onChange: (checked: boolean) => void; label: string }> = ({
checked,
onChange,
label,
}) => (
<label className="flex cursor-pointer items-center gap-3 rounded-xl border border-ink-200 px-4 py-3">
<input
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="h-4 w-4 accent-brand-500"
/>
<span className="text-sm font-semibold text-ink-700">{label}</span>
</label>
);
const Fact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<div className="flex justify-between gap-3 text-sm">
<dt className="text-ink-400">{label}</dt>
<dd className="truncate text-right font-semibold text-ink-800">{value}</dd>
</div>
);
export const MailSettings: React.FC = () => {
const { addToast } = useToast();
const [values, setValues] = useState<MailSettingsValues>(MAIL_SETTINGS_DEFAULTS);
const [status, setStatus] = useState<MailStatus | null>(null);
const [test, setTest] = useState<MailConnectionTest | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const load = async () => {
setIsLoading(true);
try {
const nextStatus = await mailApi.status();
setStatus(nextStatus);
// Ohne Zugangsdaten liefert der Einstellungs-Endpunkt zwar Werte, sie
// haetten aber keine Wirkung - dann bleibt es bei den Vorgaben.
const data = await mailApi.settings();
setValues({ ...MAIL_SETTINGS_DEFAULTS, ...data });
} catch (err) {
addToast(err instanceof Error ? err.message : 'Einstellungen nicht erreichbar.', 'error');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const set = <K extends keyof MailSettingsValues>(key: K, value: string) =>
setValues((prev) => ({ ...prev, [key]: value }));
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setIsSaving(true);
try {
const saved = await mailApi.saveSettings(values);
setValues({ ...MAIL_SETTINGS_DEFAULTS, ...saved });
addToast('Einstellungen gespeichert.', 'success');
setStatus(await mailApi.status());
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setIsSaving(false);
}
};
const runTest = async () => {
setIsTesting(true);
setTest(null);
try {
const result = await mailApi.test();
setTest(result);
addToast(result.ok ? 'Verbindung steht.' : 'Verbindung fehlgeschlagen.', result.ok ? 'success' : 'error');
} catch (err) {
addToast(err instanceof Error ? err.message : 'Test fehlgeschlagen.', 'error');
} finally {
setIsTesting(false);
}
};
const runSync = async (reset = false) => {
setIsSyncing(true);
try {
const result = await mailApi.sync({ reset });
addToast(
`Abgleich fertig: ${result.newMessages ?? 0} neue Mails, ${result.newCandidates ?? 0} Kontakt-Vorschläge.`,
'success'
);
setStatus(await mailApi.status());
} catch (err) {
addToast(err instanceof Error ? err.message : 'Abgleich fehlgeschlagen.', 'error');
setStatus(await mailApi.status().catch(() => null));
} finally {
setIsSyncing(false);
}
};
if (isLoading) {
return (
<div className="flex justify-center py-14">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
</div>
);
}
const isActive = values.mail_sync_active === 'true';
const autoContacts = values.mail_auto_contacts === 'true';
const configured = status?.configured ?? false;
return (
<form onSubmit={handleSubmit} className="space-y-5">
<Section
title="Verbindung zu Microsoft 365"
description="Lesender Zugriff über Microsoft Graph mit App-only-Berechtigung. Zugangsdaten stehen in server/.env und werden nie an den Browser gegeben."
accent={configured ? (status?.last_error ? 'border-l-red-500' : 'border-l-brand-500') : 'border-l-amber-500'}
>
{!configured ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
<p className="text-sm font-bold text-amber-900">Zugangsdaten fehlen.</p>
<p className="mt-1 text-xs text-amber-800">
In <code className="rounded bg-amber-100 px-1">server/.env</code> setzen:{' '}
<strong>{status?.missing.join(', ')}</strong>. Anschließend den Dienst neu starten – die Werte
werden beim Start gelesen.
</p>
<p className="mt-2 text-xs text-amber-800">
In Entra genügt eine Anwendungsberechtigung: <strong>Mail.Read</strong> mit
Administratorzustimmung. In Exchange Online zusätzlich den Zugriff per
<code className="mx-1 rounded bg-amber-100 px-1">New-ApplicationAccessPolicy</code>
auf dieses Postfach begrenzen.
</p>
</div>
) : (
<>
<dl className="space-y-2">
<Fact label="Postfach" value={status?.mailbox} />
<Fact label="Ordner" value={status?.folder} />
<Fact
label="Hintergrundabgleich"
value={
status?.scheduler_enabled
? isActive
? `alle ${status?.interval_minutes} Min`
: 'pausiert'
: 'per MAIL_SYNC=false aus'
}
/>
<Fact label="Letzter Lauf" value={formatMailDateTime(status?.last_run_at ?? null)} />
<Fact label="Letzter Erfolg" value={formatMailDateTime(status?.last_success_at ?? null)} />
<Fact label="Mails im CRM" value={status?.counts.messages ?? 0} />
<Fact label="Offene Vorschläge" value={status?.counts.open_candidates ?? 0} />
<Fact label="Angelegte Kontakte" value={status?.counts.adopted_candidates ?? 0} />
</dl>
{status?.last_error && (
<div className="mt-4 rounded-xl border border-red-200 bg-red-50 p-3">
<p className="text-xs font-bold uppercase tracking-wider text-red-700">Letzter Fehler</p>
<p className="mt-1 break-words text-xs text-red-800">{status.last_error}</p>
</div>
)}
<div className="mt-4 flex flex-wrap gap-3">
<button
type="button"
onClick={runTest}
disabled={isTesting}
className="rounded-xl border border-ink-200 px-4 py-2 text-sm font-bold text-ink-700 transition hover:bg-ink-100 disabled:opacity-60"
>
{isTesting ? 'Teste...' : 'Verbindung testen'}
</button>
<button
type="button"
onClick={() => runSync(false)}
disabled={isSyncing || status?.running}
className="rounded-xl border border-ink-200 px-4 py-2 text-sm font-bold text-ink-700 transition hover:bg-ink-100 disabled:opacity-60"
>
{isSyncing || status?.running ? 'Gleiche ab...' : 'Jetzt abgleichen'}
</button>
<button
type="button"
onClick={() => runSync(true)}
disabled={isSyncing || status?.running}
title="Verwirft den Delta-Verweis und liest den eingestellten Zeitraum erneut. Vorhandene Mails werden erkannt, es entstehen keine Doppelten."
className="rounded-xl border border-ink-200 px-4 py-2 text-sm font-bold text-ink-500 transition hover:bg-ink-100 disabled:opacity-60"
>
Vollständig neu einlesen
</button>
</div>
{test && (
<div
className={`mt-4 rounded-xl border p-4 ${
test.ok ? 'border-brand-200 bg-brand-50' : 'border-red-200 bg-red-50'
}`}
>
{test.ok ? (
<>
<p className="text-sm font-bold text-brand-800">
Zugriff auf {test.mailbox} bestätigt.
</p>
<p className="mt-1 text-xs text-brand-700">
Ordner „{test.folder}" · {test.totalItems ?? '?'} Nachrichten, davon{' '}
{test.unreadItems ?? '?'} ungelesen. Berechtigung und Zugriffsrichtlinie passen.
</p>
</>
) : (
<>
<p className="text-sm font-bold text-red-900">
Fehlgeschlagen{test.stage === 'token' ? ' bei der Anmeldung' : ' beim Postfachzugriff'}.
</p>
<p className="mt-1 break-words text-xs text-red-800">{test.error}</p>
{test.hint && <p className="mt-2 text-xs font-semibold text-red-900">{test.hint}</p>}
</>
)}
</div>
)}
</>
)}
</Section>
<Section
title="Abgleich"
description="Der Abgleich holt neue und geänderte Mails über eine Delta-Abfrage. Dafür genügt Leserecht – im Postfach wird nichts verändert, insbesondere wird keine Mail als gelesen markiert."
>
<Checkbox
checked={isActive}
onChange={(checked) => set('mail_sync_active', checked ? 'true' : 'false')}
label="Abgleich aktiviert"
/>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<label className={labelClass}>Intervall (Minuten)</label>
<input
type="number"
min={2}
max={1440}
value={values.mail_sync_interval}
onChange={(event) => set('mail_sync_interval', event.target.value)}
className={inputClass}
/>
<p className="mt-1 text-xs text-ink-400">
Wie oft im Hintergrund nachgesehen wird. 10 Minuten sind ein guter Wert; kürzer bringt wenig,
weil Mails ohnehin nicht sekundengenau bearbeitet werden.
</p>
</div>
<div>
<label className={labelClass}>Erstabgleich: Zeitraum (Tage)</label>
<input
type="number"
min={1}
max={3650}
value={values.mail_since_days}
onChange={(event) => set('mail_since_days', event.target.value)}
className={inputClass}
/>
<p className="mt-1 text-xs text-ink-400">
Gilt nur für den ersten Durchlauf: so weit wird zurückgelesen. Danach kommt jede Änderung
ohnehin über die Delta-Abfrage.
</p>
</div>
</div>
</Section>
<Section
title="Kontakte aus Mails"
description="Aus Absender, Empfängerliste und Signatur werden Kontaktdaten gelesen – ohne Sprachmodell, die Mailinhalte verlassen das Haus nicht."
>
<Checkbox
checked={autoContacts}
onChange={(checked) => set('mail_auto_contacts', checked ? 'true' : 'false')}
label="Gesicherte Vorschläge automatisch übernehmen"
/>
<p className="mt-2 text-xs text-ink-400">
Automatisch übernommen wird nur, wer eine echte E-Mail-Adresse hat und aus Absender oder
Empfängerliste stammt. Alles Unsichere – etwa nur im Text genannte Personen – bleibt zur Durchsicht
liegen. Neue Kontakte erhalten die Herkunft <strong>Mail</strong> und lassen sich in der
Kontaktliste darüber filtern.
</p>
<div className="mt-4">
<label className={labelClass}>Eigene Domains (nicht als Kontakt vorschlagen)</label>
<input
value={values.mail_internal_domains}
onChange={(event) => set('mail_internal_domains', event.target.value)}
placeholder="perinco.de,perinco.onmicrosoft.com"
className={inputClass}
/>
<p className="mt-1 text-xs text-ink-400">
Kommagetrennt. Kollegen stehen in der Benutzerverwaltung und sollen nicht zusätzlich als Kontakt
auftauchen. Funktionsadressen wie info@ oder no-reply@ werden immer übersprungen.
</p>
</div>
</Section>
<div className="flex justify-end">
<button
type="submit"
disabled={isSaving}
className="rounded-xl bg-brand-500 px-6 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-60"
>
{isSaving ? 'Speichere...' : 'Einstellungen speichern'}
</button>
</div>
</form>
);
};
+3
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { UserManagement } from './UserManagement';
import { MailSettings } from './MailSettings';
import { ScannerSettings } from './ScannerSettings';
import { ColorPicker } from './ColorPicker';
import { AvatarPicker } from '../ui/AvatarPicker';
@@ -13,6 +14,7 @@ import { ADMIN_SETTINGS_TABS, userColor, type SettingsTab } from '../../types';
const TABS: { id: SettingsTab; label: string }[] = [
{ id: 'users', label: 'Nutzerverwaltung' },
{ id: 'account', label: 'Mein Konto' },
{ id: 'mail', label: 'Postfach' },
{ id: 'scanner', label: 'Scanner' },
{ id: 'whisper', label: 'Whisper' },
];
@@ -215,6 +217,7 @@ export const Settings: React.FC<{ initialTab?: SettingsTab }> = ({ initialTab })
{tab === 'users' && isAdmin && <UserManagement />}
{tab === 'account' && <AccountTab />}
{tab === 'mail' && isAdmin && <MailSettings />}
{tab === 'scanner' && isAdmin && <ScannerSettings />}
{tab === 'whisper' && <WhisperSettings />}
</div>