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
+15 -2
View File
@@ -7,6 +7,7 @@ import { Opportunities } from './components/opportunities/Opportunities';
import { Calendar } from './components/calendar/Calendar';
import { parseSqlDateTime } from './components/calendar/calendarUtils';
import { Settings } from './components/settings/Settings';
import { Mails } from './components/mail/Mails';
import { ScannerDashboard } from './components/scanner/ScannerDashboard';
import { ScannerRejected } from './components/scanner/ScannerRejected';
import { ScannerAgents } from './components/scanner/ScannerAgents';
@@ -50,6 +51,11 @@ const App: React.FC = () => {
* nach Whisper, das keinen eigenen Menuepunkt mehr hat.
*/
const [settingsTab, setSettingsTab] = useState<SettingsTab | undefined>(undefined);
/**
* Kontakt, der in der Kontaktansicht sofort geoeffnet werden soll. Wird beim
* Sprung aus der Mailansicht gesetzt und von dort wieder zurueckgesetzt.
*/
const [focusLeadId, setFocusLeadId] = useState<number | null>(null);
const [leads, setLeads] = useState<Lead[]>([]);
const [connections, setConnections] = useState<LeadConnection[]>([]);
@@ -63,9 +69,10 @@ const App: React.FC = () => {
* gleich den passenden Reiter der Einstellungen; ohne Angabe entscheidet die
* Einstellungsseite selbst (Rolle admin: Nutzerverwaltung, sonst Mein Konto).
*/
const goTo = useCallback((view: ViewState, options?: { tab?: SettingsTab }) => {
const goTo = useCallback((view: ViewState, options?: { tab?: SettingsTab; leadId?: number }) => {
setCurrentView(view);
setSettingsTab(options?.tab);
setFocusLeadId(options?.leadId ?? null);
}, []);
const loadData = useCallback(
@@ -117,11 +124,12 @@ const App: React.FC = () => {
// Untertitel der Kopfzeile. Die Scanner-Reiter bringen ihre Kennzahlen selbst
// mit, deshalb steht hier nur, worum es auf der Seite geht.
const STATIC_SUBTITLES: Partial<Record<ViewState, string>> = {
Mails: 'Postfach crm@perinco.de - Mails zuordnen und Kontakte übernehmen',
Scanner: 'Projekt Scanner - Suchagenten, Portale und Projektübereinstimmungen',
Unpassend: 'Projekt Scanner - als „passt nicht" markierte Treffer',
Suchagenten: 'Projekt Scanner - Beraterprofile mit CV und Suchbegriffen',
'Börsen-Konten': 'Projekt Scanner - Verbindungen zu den Jobbörsen',
Einstellungen: 'Konto, Diktat, Benutzer und Scanner',
Einstellungen: 'Konto, Diktat, Benutzer, Postfach und Scanner',
};
const subtitle =
@@ -183,8 +191,13 @@ const App: React.FC = () => {
users={users}
isLoading={isDataLoading}
reload={() => loadData(true)}
focusLeadId={focusLeadId}
onFocusHandled={() => setFocusLeadId(null)}
/>
)}
{currentView === 'Mails' && (
<Mails leads={leads} opportunities={opportunities} reload={() => loadData(true)} />
)}
{currentView === 'Scanner' && <ScannerDashboard />}
{currentView === 'Unpassend' && <ScannerRejected />}
{currentView === 'Suchagenten' && <ScannerAgents />}
+4
View File
@@ -32,6 +32,10 @@ const CATEGORY_ITEMS: MenuItem[] = [
name: 'Kontakte',
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
},
{
name: 'Mails',
icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
},
];
/**
+64 -3
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { LeadGrid } from './LeadGrid';
import { LeadList, type SortConfig, type SortKey } from './LeadList';
import { LeadNetwork } from './LeadNetwork';
@@ -8,10 +8,12 @@ import { useToast } from '../Toast';
import { leadsApi } from '../../lib/api';
import {
LEAD_STATUSES,
MAIL_LEAD_SOURCE,
leadFullName,
type AppUser,
type Lead,
type LeadConnection,
type LeadOriginFilter,
type LeadStatus,
type LeadViewMode,
} from '../../types';
@@ -22,8 +24,21 @@ interface LeadsProps {
users: AppUser[];
isLoading: boolean;
reload: () => void;
/** Kontakt, der beim Wechsel in diese Ansicht direkt geoeffnet werden soll. */
focusLeadId?: number | null;
onFocusHandled?: () => void;
}
/**
* Herkunft der Kontakte. "Aus Mails" sind die aus dem Postfach uebernommenen -
* sie tragen `source = 'Mail'` und brauchen deshalb kein eigenes Feld.
*/
const ORIGIN_FILTERS: { id: LeadOriginFilter; label: string }[] = [
{ id: 'Alle', label: 'Alle Herkunft' },
{ id: 'Mail', label: 'Aus Mails' },
{ id: 'Manuell', label: 'Selbst angelegt' },
];
const VIEW_BUTTONS: { mode: LeadViewMode; label: string; icon: string }[] = [
{
mode: 'grid',
@@ -38,17 +53,40 @@ const VIEW_BUTTONS: { mode: LeadViewMode; label: string; icon: string }[] = [
},
];
export const Leads: React.FC<LeadsProps> = ({ leads, connections, users, isLoading, reload }) => {
export const Leads: React.FC<LeadsProps> = ({
leads,
connections,
users,
isLoading,
reload,
focusLeadId = null,
onFocusHandled,
}) => {
const { addToast } = useToast();
const [viewMode, setViewMode] = useState<LeadViewMode>('grid');
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<LeadStatus | 'Alle'>('Alle');
const [originFilter, setOriginFilter] = useState<LeadOriginFilter>('Alle');
const [sortConfig, setSortConfig] = useState<SortConfig>({ key: 'updated_at', direction: 'desc' });
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingLead, setEditingLead] = useState<Lead | null>(null);
const [leadToDelete, setLeadToDelete] = useState<Lead | null>(null);
/*
* Sprung aus der Mailansicht: den gemeinten Kontakt direkt oeffnen. Ist er noch
* nicht in der Liste (gerade erst angelegt), wartet der Effekt auf das
* Nachladen - deshalb haengt er auch an `leads`.
*/
useEffect(() => {
if (focusLeadId === null) return;
const lead = leads.find((entry) => entry.id === focusLeadId);
if (!lead) return;
setEditingLead(lead);
setIsModalOpen(true);
onFocusHandled?.();
}, [focusLeadId, leads, onFocusHandled]);
// Verbindungen pro Kontakt - einmal zaehlen statt pro Karte filtern.
const connectionCounts = useMemo(() => {
const counts = new Map<number, number>();
@@ -71,6 +109,8 @@ export const Leads: React.FC<LeadsProps> = ({ leads, connections, users, isLoadi
const matches = leads.filter((lead) => {
if (statusFilter !== 'Alle' && lead.status !== statusFilter) return false;
if (originFilter === 'Mail' && lead.source !== MAIL_LEAD_SOURCE) return false;
if (originFilter === 'Manuell' && lead.source === MAIL_LEAD_SOURCE) return false;
if (terms.length === 0) return true;
const haystack = [
@@ -110,7 +150,13 @@ export const Leads: React.FC<LeadsProps> = ({ leads, connections, users, isLoadi
}
}
});
}, [leads, searchTerm, statusFilter, sortConfig, connectionCounts]);
}, [leads, searchTerm, statusFilter, originFilter, sortConfig, connectionCounts]);
// Zaehler fuer den Herkunftsfilter - nur anzeigen, wenn es solche Kontakte gibt.
const mailLeadCount = useMemo(
() => leads.filter((lead) => lead.source === MAIL_LEAD_SOURCE).length,
[leads]
);
const handleSort = (key: SortKey) => {
setSortConfig((prev) =>
@@ -187,6 +233,21 @@ export const Leads: React.FC<LeadsProps> = ({ leads, connections, users, isLoadi
))}
</select>
{mailLeadCount > 0 && (
<select
value={originFilter}
onChange={(event) => setOriginFilter(event.target.value as LeadOriginFilter)}
title="Kontakte, die aus dem Postfach crm@perinco.de übernommen wurden"
className="rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500"
>
{ORIGIN_FILTERS.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.id === 'Mail' ? `${entry.label} (${mailLeadCount})` : entry.label}
</option>
))}
</select>
)}
<div className="flex items-center gap-3 lg:ml-auto">
<div className="flex rounded-xl border border-ink-200 bg-surface p-1">
{VIEW_BUTTONS.map((button) => (
+163
View File
@@ -0,0 +1,163 @@
import React, { useState } from 'react';
import {
Badge,
CANDIDATE_STATUS_LABELS,
CANDIDATE_STATUS_STYLES,
ConfidenceBar,
ORIGIN_LABELS,
ORIGIN_STYLES,
} from './mailUi';
import { useToast } from '../Toast';
import { mailApi, candidateName, type MailCandidate } from '../../lib/mailApi';
/*
* Ein Kontakt-Vorschlag mit Uebernehmen/Verwerfen.
*
* Zwei Faelle werden unterschieden und auch so benannt:
* - Die Adresse ist neu -> es entsteht ein Kontakt mit Herkunft "Mail".
* - Die Adresse existiert -> der vorhandene Kontakt wird nur ergaenzt; leere
* Felder werden gefuellt, gepflegte Werte bleiben unangetastet.
*
* Das Altsystem hat in beiden Faellen stumpf angelegt und dabei Platzhalter-
* Adressen erfunden, wenn keine vorhanden war.
*/
interface CandidateRowProps {
candidate: MailCandidate;
onChanged: () => void;
onOpenLead?: (leadId: number) => void;
/** In der Sammelliste zusaetzlich zeigen, aus welcher Mail der Vorschlag kommt. */
showMessage?: boolean;
onOpenMessage?: (messageId: number) => void;
}
export const CandidateRow: React.FC<CandidateRowProps> = ({
candidate,
onChanged,
onOpenLead,
showMessage = false,
onOpenMessage,
}) => {
const { addToast } = useToast();
const [isBusy, setIsBusy] = useState(false);
const isOpen = candidate.status === 'offen';
const willMerge = Boolean(candidate.existing_lead_id) && isOpen;
const details = [
candidate.position,
candidate.company,
candidate.phone && `Tel. ${candidate.phone}`,
candidate.mobile && `Mobil ${candidate.mobile}`,
[candidate.zip, candidate.city].filter(Boolean).join(' ') || null,
].filter(Boolean) as string[];
const adopt = async () => {
setIsBusy(true);
try {
const result = await mailApi.adoptCandidate(candidate.id);
addToast(
result.status === 'uebernommen'
? `${candidateName(candidate)} als Kontakt angelegt.`
: `${candidateName(candidate)} war vorhanden – ${result.enrichedFields} Feld(er) ergänzt.`,
'success'
);
onChanged();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Übernehmen fehlgeschlagen.', 'error');
} finally {
setIsBusy(false);
}
};
const reject = async () => {
setIsBusy(true);
try {
await mailApi.rejectCandidate(candidate.id);
addToast('Vorschlag verworfen.', 'success');
onChanged();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Verwerfen fehlgeschlagen.', 'error');
} finally {
setIsBusy(false);
}
};
return (
<li
className={`rounded-xl border px-4 py-3 transition ${
isOpen ? 'border-ink-200 bg-surface' : 'border-ink-100 bg-ink-50/60'
} ${isBusy ? 'pointer-events-none opacity-60' : ''}`}
>
<div className="flex flex-wrap items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-bold text-ink-900">{candidateName(candidate)}</p>
<Badge className={ORIGIN_STYLES[candidate.origin]}>{ORIGIN_LABELS[candidate.origin]}</Badge>
{!isOpen && (
<Badge className={CANDIDATE_STATUS_STYLES[candidate.status]}>
{CANDIDATE_STATUS_LABELS[candidate.status]}
</Badge>
)}
{willMerge && (
<Badge
className="border-sky-200 bg-sky-50 text-sky-700"
title="Diese Adresse ist bereits als Kontakt vorhanden. Beim Übernehmen werden nur leere Felder ergänzt."
>
Bereits vorhanden
</Badge>
)}
</div>
<p className="mt-0.5 truncate text-xs font-semibold text-ink-500">
{candidate.email || 'Keine E-Mail-Adresse'}
</p>
{details.length > 0 && (
<p className="mt-0.5 truncate text-xs text-ink-400" title={details.join(' · ')}>
{details.join(' · ')}
</p>
)}
{showMessage && (
<button
onClick={() => onOpenMessage?.(candidate.message_id)}
className="mt-1.5 max-w-full truncate text-left text-xs font-bold text-brand-600 transition hover:text-brand-700"
title={candidate.message_subject ?? undefined}
>
aus: {candidate.message_subject || 'Ohne Betreff'}
</button>
)}
{!isOpen && candidate.lead_id && onOpenLead && (
<button
onClick={() => onOpenLead(candidate.lead_id as number)}
className="mt-1.5 text-xs font-bold text-brand-600 transition hover:text-brand-700"
>
Kontakt {candidate.lead_name ? `„${candidate.lead_name}"` : ''} öffnen
</button>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<ConfidenceBar value={candidate.confidence} />
{isOpen && (
<div className="flex gap-2">
<button
onClick={reject}
className="rounded-lg border border-ink-200 px-3 py-1.5 text-xs font-bold text-ink-600 transition hover:bg-ink-100"
>
Verwerfen
</button>
<button
onClick={adopt}
className="rounded-lg bg-brand-500 px-3 py-1.5 text-xs font-bold text-white transition hover:bg-brand-600"
>
{willMerge ? 'Ergänzen' : 'Als Kontakt anlegen'}
</button>
</div>
)}
</div>
</div>
</li>
);
};
+125
View File
@@ -0,0 +1,125 @@
import React, { useState } from 'react';
/*
* Darstellung des Mailtexts.
*
* HTML aus fremden Mails laeuft in einem vollstaendig abgeschotteten iframe:
* `sandbox=""` ohne jedes allow-Flag. Das Dokument bekommt damit eine eigene,
* undurchsichtige Origin, kann keine Skripte ausfuehren und weder auf die
* Anwendung noch auf deren Sitzungsdaten zugreifen.
*
* Wichtig: allow-same-origin waere hier ein Fehler. Ein srcDoc-Dokument erbt mit
* diesem Flag die Origin der Anwendung und koennte dann localStorage lesen - also
* das Anmeldetoken. Das Backend entfernt Skripte und Ereignis-Attribute
* zusaetzlich schon vor dem Speichern; zwei unabhaengige Schranken.
*
* Weil ohne allow-same-origin die Hoehe des Inhalts nicht auslesbar ist, hat der
* Rahmen eine feste Hoehe mit eigenem Rollbalken und laesst sich aufklappen. Das
* ist der Preis fuer die Abschottung und in der Bedienung kaum spuerbar.
*
* Externe Bilder bleiben zunaechst gesperrt (Content-Security-Policy im
* eingebetteten Dokument): sonst meldet jedes Oeffnen einer Mail dem Absender,
* dass sie gelesen wurde. Pro Mail laesst sich das freigeben.
*/
interface MailBodyProps {
html: string | null;
text: string | null;
}
const FRAME_STYLE = `
html { color-scheme: light; }
body {
margin: 0;
padding: 14px 16px;
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
font-size: 14px;
line-height: 1.55;
color: #1f2429;
background: #ffffff;
word-break: break-word;
}
img { max-width: 100%; height: auto; }
table { max-width: 100%; border-collapse: collapse; }
a { color: #1f6feb; }
blockquote {
margin: 8px 0;
padding-left: 12px;
border-left: 3px solid #d8dcdf;
color: #5a6570;
}
pre { white-space: pre-wrap; word-break: break-word; }
`;
export const MailBody: React.FC<MailBodyProps> = ({ html, text }) => {
const [showImages, setShowImages] = useState(false);
const [showSource, setShowSource] = useState(false);
const [expanded, setExpanded] = useState(false);
const hasHtml = Boolean(html && html.trim());
const hasRemoteImages = hasHtml && /<img[^>]+src\s*=\s*["']?https?:/i.test(html ?? '');
if (!hasHtml || showSource) {
return (
<div className="space-y-3">
{hasHtml && (
<button
onClick={() => setShowSource(false)}
className="text-xs font-bold text-brand-600 transition hover:text-brand-700"
>
← Formatierte Ansicht
</button>
)}
<pre className="scrollbar-slim max-h-[60vh] overflow-auto whitespace-pre-wrap break-words rounded-xl border border-ink-200 bg-surface p-4 text-sm leading-relaxed text-ink-800">
{text || 'Kein Text vorhanden.'}
</pre>
</div>
);
}
// Bilder werden ueber die CSP des eingebetteten Dokuments freigegeben oder
// gesperrt. Skripte sind in beiden Faellen ausgeschlossen.
const imagePolicy = showImages ? 'img-src data: https:' : 'img-src data:';
const srcDocument = `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; ${imagePolicy}; style-src 'unsafe-inline'; font-src data:">
<style>${FRAME_STYLE}</style></head><body>${html}</body></html>`;
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3">
{hasRemoteImages && (
<button
onClick={() => setShowImages((value) => !value)}
className="rounded-lg border border-ink-200 px-3 py-1.5 text-xs font-bold text-ink-600 transition hover:bg-ink-100"
>
{showImages ? 'Externe Bilder wieder sperren' : 'Externe Bilder laden'}
</button>
)}
<button
onClick={() => setExpanded((value) => !value)}
className="rounded-lg border border-ink-200 px-3 py-1.5 text-xs font-bold text-ink-600 transition hover:bg-ink-100"
>
{expanded ? 'Kleiner' : 'Größer'}
</button>
<button
onClick={() => setShowSource(true)}
className="text-xs font-bold text-ink-400 transition hover:text-ink-700"
>
Nur Text anzeigen
</button>
{hasRemoteImages && !showImages && (
<span className="text-xs text-ink-400">
Externe Bilder gesperrt – sie würden dem Absender das Öffnen melden.
</span>
)}
</div>
<iframe
title="Mailinhalt"
sandbox=""
srcDoc={srcDocument}
className={`w-full rounded-xl border border-ink-200 bg-white ${expanded ? 'h-[78vh]' : 'h-[46vh]'}`}
/>
</div>
);
};
+220
View File
@@ -0,0 +1,220 @@
import React from 'react';
import { MailBody } from './MailBody';
import { CandidateRow } from './CandidateRow';
import {
Badge,
avatarColor,
formatAddresses,
initialsFromMail,
} from './mailUi';
import { useToast } from '../Toast';
import { mailApi, formatMailDateTime, type MailCandidate, type MailMessageDetail } from '../../lib/mailApi';
/*
* Eine Mail im Detail: Kopfdaten, Zuordnungen, Kontakt-Vorschlaege, Inhalt.
*
* Die Zuordnungen stehen oben und nicht unten - sie sind der Grund, warum die
* Mail im CRM liegt. Der Mailtext ist beim Lesen zwar das Groesste, aber selten
* das Wichtigste.
*/
interface MailDetailProps {
message: MailMessageDetail;
isLoading: boolean;
onLink: () => void;
onReload: () => void;
onOpenLead: (leadId: number) => void;
}
const AddressRow: React.FC<{ label: string; value: string }> = ({ label, value }) => (
<div className="flex gap-2 text-xs">
<span className="w-12 shrink-0 font-extrabold uppercase tracking-wider text-ink-400">{label}</span>
<span className="min-w-0 break-words font-semibold text-ink-700">{value}</span>
</div>
);
export const MailDetail: React.FC<MailDetailProps> = ({
message,
isLoading,
onLink,
onReload,
onOpenLead,
}) => {
const { addToast } = useToast();
const unlink = async (linkId: number) => {
try {
await mailApi.unlink(linkId);
addToast('Zuordnung entfernt.', 'success');
onReload();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Entfernen fehlgeschlagen.', 'error');
}
};
const openCandidates = message.candidates.filter((candidate) => candidate.status === 'offen');
const decidedCandidates = message.candidates.filter((candidate) => candidate.status !== 'offen');
return (
<div className={`space-y-5 ${isLoading ? 'opacity-60' : ''}`}>
{/* Kopf */}
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
<div className="flex items-start gap-4">
<div
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-sm font-extrabold ${avatarColor(
message.from_email
)}`}
>
{initialsFromMail(message.from_name, message.from_email)}
</div>
<div className="min-w-0 flex-1">
<h2 className="break-words text-lg leading-snug text-ink-900">
{message.subject || 'Ohne Betreff'}
</h2>
<p className="mt-1 break-words text-sm font-semibold text-ink-600">
{message.from_name || message.from_email || 'Unbekannter Absender'}
{message.from_name && message.from_email && (
<span className="font-normal text-ink-400"> · {message.from_email}</span>
)}
</p>
<p className="mt-0.5 text-xs text-ink-400">{formatMailDateTime(message.received_at)}</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
<button
onClick={onLink}
className="whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2 text-sm font-bold text-white shadow-card transition hover:bg-brand-600"
>
Zuordnen
</button>
{message.web_link && (
<a
href={message.web_link}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-bold text-ink-400 transition hover:text-brand-600"
>
In Outlook öffnen ↗
</a>
)}
</div>
</div>
<div className="mt-4 space-y-1 border-t border-ink-100 pt-4">
<AddressRow label="An" value={formatAddresses(message.to, 6)} />
{message.cc.length > 0 && <AddressRow label="Cc" value={formatAddresses(message.cc, 6)} />}
{message.bcc.length > 0 && <AddressRow label="Bcc" value={formatAddresses(message.bcc, 6)} />}
</div>
{(message.has_attachments || message.is_deleted || message.importance === 'high') && (
<div className="mt-3 flex flex-wrap gap-2">
{message.has_attachments && (
<Badge className="border-ink-200 bg-ink-100 text-ink-600" title="Anhänge werden in Phase 1 nicht übernommen">
Anhänge vorhanden
</Badge>
)}
{message.importance === 'high' && (
<Badge className="border-red-200 bg-red-50 text-red-700">Wichtig</Badge>
)}
{message.is_deleted && (
<Badge
className="border-amber-200 bg-amber-50 text-amber-700"
title="Die Mail wurde im Postfach gelöscht. Im CRM bleibt sie erhalten, damit Zuordnungen nicht verloren gehen."
>
Im Postfach gelöscht
</Badge>
)}
</div>
)}
</section>
{/* Zuordnungen */}
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base text-ink-900">Zuordnungen</h3>
<span className="text-xs font-bold text-ink-400">{message.links.length}</span>
</div>
{message.links.length === 0 ? (
<p className="mt-3 text-sm text-ink-400">
Noch nicht zugeordnet. Über „Zuordnen" hängt die Mail an einer Opportunity oder einem Kontakt.
</p>
) : (
<ul className="mt-3 space-y-2">
{message.links.map((link) => (
<li
key={link.id}
className="flex items-center gap-3 rounded-xl border border-ink-200 px-4 py-2.5"
>
<span className="shrink-0 text-base" aria-hidden>
{link.opportunity_id ? '◆' : '👤'}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-ink-800">
{link.opportunity_id ? link.opportunity_title : link.lead_name}
</p>
<p className="truncate text-xs text-ink-400">
{link.opportunity_id
? `Opportunity · ${link.opportunity_stage}`
: `Kontakt${link.lead_company ? ` · ${link.lead_company}` : ''}`}
{link.note ? ` · ${link.note}` : ''}
{link.origin === 'automatisch' ? ' · automatisch' : ''}
</p>
</div>
{link.lead_id && (
<button
onClick={() => onOpenLead(link.lead_id as number)}
className="shrink-0 rounded-lg px-2 py-1 text-xs font-bold text-brand-600 transition hover:bg-brand-50"
>
Kontakt öffnen
</button>
)}
<button
onClick={() => unlink(link.id)}
title="Zuordnung entfernen"
className="shrink-0 rounded-lg p-1.5 text-ink-400 transition hover:bg-red-50 hover:text-red-600"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</li>
))}
</ul>
)}
</section>
{/* Kontakt-Vorschlaege */}
{message.candidates.length > 0 && (
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base text-ink-900">Kontakte aus dieser Mail</h3>
{openCandidates.length > 0 && (
<Badge className="border-amber-200 bg-amber-50 text-amber-700">
{openCandidates.length} offen
</Badge>
)}
</div>
<ul className="mt-3 space-y-2">
{[...openCandidates, ...decidedCandidates].map((candidate: MailCandidate) => (
<CandidateRow
key={candidate.id}
candidate={candidate}
onChanged={onReload}
onOpenLead={onOpenLead}
/>
))}
</ul>
</section>
)}
{/* Inhalt */}
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
<h3 className="mb-3 text-base text-ink-900">Inhalt</h3>
<MailBody html={message.body_html} text={message.body_text} />
</section>
</div>
);
};
+240
View File
@@ -0,0 +1,240 @@
import React, { useMemo, useState } from 'react';
import { Modal } from '../ui/Modal';
import { inputClass, labelClass } from '../leads/leadUi';
import { useToast } from '../Toast';
import { mailApi, type MailMessageDetail } from '../../lib/mailApi';
import { leadFullName, type Lead, type Opportunity } from '../../types';
/*
* Mail an eine Opportunity und/oder einen Kontakt haengen.
*
* Der Vorschlag oben ist der eigentliche Nutzen: aus der Absenderadresse laesst
* sich meist direkt der passende Kontakt und daraus die Opportunity ableiten. Im
* Altsystem musste dafuer ein Sprachmodell die komplette Mail bewerten - fuer die
* Zuordnung per Hand genuegt ein Adressvergleich.
*/
interface MailLinkDialogProps {
message: MailMessageDetail;
leads: Lead[];
opportunities: Opportunity[];
onClose: () => void;
onLinked: () => void;
}
export const MailLinkDialog: React.FC<MailLinkDialogProps> = ({
message,
leads,
opportunities,
onClose,
onLinked,
}) => {
const { addToast } = useToast();
const [leadId, setLeadId] = useState<number | ''>('');
const [opportunityId, setOpportunityId] = useState<number | ''>('');
const [note, setNote] = useState('');
const [isSaving, setIsSaving] = useState(false);
const linkedLeadIds = new Set(message.links.map((link) => link.lead_id).filter(Boolean) as number[]);
const linkedOpportunityIds = new Set(
message.links.map((link) => link.opportunity_id).filter(Boolean) as number[]
);
/**
* Alle an der Mail beteiligten Adressen - Absender und Empfaenger. Damit wird
* auch der Fall getroffen, dass eine interne Mail das CRM auf Cc gesetzt hat.
*/
const involvedEmails = useMemo(() => {
const all = [
message.from_email,
...message.to.map((entry) => entry.email),
...message.cc.map((entry) => entry.email),
...message.bcc.map((entry) => entry.email),
];
return new Set(all.filter(Boolean).map((entry) => String(entry).toLowerCase()));
}, [message]);
/** Kontakte, deren Adresse in der Mail vorkommt. */
const suggestedLeads = useMemo(
() =>
leads.filter(
(lead) => lead.email && involvedEmails.has(lead.email.toLowerCase()) && !linkedLeadIds.has(lead.id)
),
[leads, involvedEmails, linkedLeadIds]
);
/**
* Opportunities, die an einem dieser Kontakte haengen - plus solche, deren
* Firma zur Absenderdomain passt.
*/
const suggestedOpportunities = useMemo(() => {
const suggestedLeadIds = new Set(suggestedLeads.map((lead) => lead.id));
const domain = message.from_email?.split('@')[1]?.toLowerCase() ?? null;
const companies = new Set(
leads
.filter((lead) => suggestedLeadIds.has(lead.id) && lead.company)
.map((lead) => String(lead.company).toLowerCase())
);
return opportunities.filter((opportunity) => {
if (linkedOpportunityIds.has(opportunity.id)) return false;
if (opportunity.contacts.some((contact) => suggestedLeadIds.has(contact.lead_id))) return true;
if (opportunity.company && companies.has(opportunity.company.toLowerCase())) return true;
// Grober Domainabgleich: "kunde-ag.de" gegen "Kunde AG".
if (domain && opportunity.company) {
const stem = domain.split('.')[0].replace(/[^a-z0-9]/g, '');
const company = opportunity.company.toLowerCase().replace(/[^a-z0-9]/g, '');
if (stem.length >= 4 && company.includes(stem)) return true;
}
return false;
});
}, [opportunities, suggestedLeads, leads, linkedOpportunityIds, message.from_email]);
const sortedLeads = useMemo(
() => [...leads].sort((a, b) => leadFullName(a).localeCompare(leadFullName(b), 'de')),
[leads]
);
const sortedOpportunities = useMemo(
() => [...opportunities].sort((a, b) => a.title.localeCompare(b.title, 'de')),
[opportunities]
);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
if (leadId === '' && opportunityId === '') {
addToast('Kontakt oder Opportunity auswaehlen.', 'error');
return;
}
setIsSaving(true);
try {
await mailApi.link(message.id, {
lead_id: leadId === '' ? null : leadId,
opportunity_id: opportunityId === '' ? null : opportunityId,
note: note.trim() || undefined,
});
addToast('Mail verknüpft.', 'success');
onLinked();
onClose();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Verknüpfen fehlgeschlagen.', 'error');
} finally {
setIsSaving(false);
}
};
return (
<Modal
title="Mail zuordnen"
subtitle={message.subject ?? 'Ohne Betreff'}
onClose={onClose}
footer={
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="rounded-xl border border-ink-200 px-5 py-2.5 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
>
Abbrechen
</button>
<button
type="submit"
form="mail-link-form"
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 ? 'Verknüpfe...' : 'Verknüpfen'}
</button>
</div>
}
>
<form id="mail-link-form" onSubmit={submit} className="space-y-5">
{(suggestedLeads.length > 0 || suggestedOpportunities.length > 0) && (
<section className="rounded-xl border border-brand-200 bg-brand-50/60 p-4">
<p className="text-[11px] font-extrabold uppercase tracking-wider text-brand-700">
Vorschläge anhand der Mailadressen
</p>
<div className="mt-3 flex flex-wrap gap-2">
{suggestedLeads.map((lead) => (
<button
key={`lead-${lead.id}`}
type="button"
onClick={() => setLeadId(lead.id)}
className={`rounded-lg border px-3 py-1.5 text-xs font-bold transition ${
leadId === lead.id
? 'border-brand-500 bg-brand-500 text-white'
: 'border-brand-200 bg-surface text-brand-700 hover:bg-brand-100'
}`}
>
{leadFullName(lead)}
{lead.company ? ` · ${lead.company}` : ''}
</button>
))}
{suggestedOpportunities.map((opportunity) => (
<button
key={`opp-${opportunity.id}`}
type="button"
onClick={() => setOpportunityId(opportunity.id)}
className={`rounded-lg border px-3 py-1.5 text-xs font-bold transition ${
opportunityId === opportunity.id
? 'border-brand-500 bg-brand-500 text-white'
: 'border-brand-200 bg-surface text-brand-700 hover:bg-brand-100'
}`}
>
◆ {opportunity.title}
</button>
))}
</div>
</section>
)}
<div>
<label className={labelClass}>Opportunity</label>
<select
value={opportunityId}
onChange={(event) => setOpportunityId(event.target.value === '' ? '' : Number(event.target.value))}
className={inputClass}
>
<option value="">— keine —</option>
{sortedOpportunities.map((opportunity) => (
<option key={opportunity.id} value={opportunity.id} disabled={linkedOpportunityIds.has(opportunity.id)}>
{opportunity.title}
{opportunity.company ? ` · ${opportunity.company}` : ''}
{linkedOpportunityIds.has(opportunity.id) ? ' (bereits verknüpft)' : ''}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Kontakt</label>
<select
value={leadId}
onChange={(event) => setLeadId(event.target.value === '' ? '' : Number(event.target.value))}
className={inputClass}
>
<option value="">— keiner —</option>
{sortedLeads.map((lead) => (
<option key={lead.id} value={lead.id} disabled={linkedLeadIds.has(lead.id)}>
{leadFullName(lead)}
{lead.company ? ` · ${lead.company}` : ''}
{linkedLeadIds.has(lead.id) ? ' (bereits verknüpft)' : ''}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Notiz (optional)</label>
<input
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="z. B. Angebot nachgefragt"
maxLength={500}
className={inputClass}
/>
</div>
</form>
</Modal>
);
};
+84
View File
@@ -0,0 +1,84 @@
import React from 'react';
import { Badge, avatarColor, formatAddresses, initialsFromMail } from './mailUi';
import { formatMailDate, formatMailDateTime, type MailMessageSummary } from '../../lib/mailApi';
interface MailListProps {
messages: MailMessageSummary[];
selectedId: number | null;
onSelect: (id: number) => void;
}
export const MailList: React.FC<MailListProps> = ({ messages, selectedId, onSelect }) => (
<ul className="divide-y divide-ink-100">
{messages.map((message) => {
const isSelected = message.id === selectedId;
return (
<li key={message.id}>
<button
onClick={() => onSelect(message.id)}
aria-current={isSelected ? 'true' : undefined}
className={`flex w-full gap-3 px-4 py-3 text-left transition ${
isSelected ? 'bg-brand-50' : 'hover:bg-ink-50'
}`}
>
<div
className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-[11px] font-extrabold ${avatarColor(
message.from_email
)}`}
>
{initialsFromMail(message.from_name, message.from_email)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<p
className={`min-w-0 flex-1 truncate text-sm ${
isSelected ? 'font-extrabold text-brand-800' : 'font-bold text-ink-800'
}`}
>
{message.from_name || message.from_email || 'Unbekannt'}
</p>
<span
className="shrink-0 text-[11px] font-bold text-ink-400"
title={formatMailDateTime(message.received_at)}
>
{formatMailDate(message.received_at)}
</span>
</div>
<p className="mt-0.5 truncate text-[13px] font-semibold text-ink-700">
{message.subject || 'Ohne Betreff'}
</p>
<p className="mt-0.5 truncate text-xs text-ink-400">
{message.body_preview || `An ${formatAddresses(message.to)}`}
</p>
{(message.link_count > 0 || message.open_candidates > 0 || message.has_attachments) && (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{message.link_count > 0 && (
<Badge className="border-brand-200 bg-brand-50 text-brand-700" title="Zuordnungen">
{message.link_count}× zugeordnet
</Badge>
)}
{message.open_candidates > 0 && (
<Badge
className="border-amber-200 bg-amber-50 text-amber-700"
title="Offene Kontakt-Vorschläge"
>
{message.open_candidates} Kontakt{message.open_candidates === 1 ? '' : 'e'}
</Badge>
)}
{message.has_attachments && (
<span className="text-xs text-ink-300" title="Anhänge vorhanden" aria-hidden>
📎
</span>
)}
</div>
)}
</div>
</button>
</li>
);
})}
</ul>
);
+497
View File
@@ -0,0 +1,497 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MailList } from './MailList';
import { MailDetail } from './MailDetail';
import { MailLinkDialog } from './MailLinkDialog';
import { CandidateRow } from './CandidateRow';
import { Badge, emptyStateClass } from './mailUi';
import { useToast } from '../Toast';
import { useAuth } from '../../lib/auth';
import { useNavigation } from '../../lib/navigation';
import {
mailApi,
formatMailDateTime,
type MailCandidate,
type MailListFilter,
type MailMessageDetail,
type MailMessageSummary,
type MailStatus,
} from '../../lib/mailApi';
import type { Lead, Opportunity } from '../../types';
/*
* Postfach crm@perinco.de im CRM.
*
* Zwei Bereiche: die Mailliste mit Detailansicht und die Sammelliste der offenen
* Kontakt-Vorschlaege. Letztere ist die eigentliche Arbeitsliste - dort wird
* entschieden, was Kontakt wird und was nicht.
*/
interface MailsProps {
leads: Lead[];
opportunities: Opportunity[];
/** Kontakte im CRM neu laden - nach Uebernahme eines Vorschlags. */
reload: () => void;
}
type Tab = 'postfach' | 'vorschlaege';
const FILTERS: { id: MailListFilter; label: string; hint: string }[] = [
{ id: 'alle', label: 'Alle', hint: 'Alle abgeglichenen Mails' },
{ id: 'unlinked', label: 'Nicht zugeordnet', hint: 'Hängen noch an keiner Opportunity und keinem Kontakt' },
{ id: 'candidates', label: 'Mit Vorschlägen', hint: 'Enthalten offene Kontakt-Vorschläge' },
{ id: 'linked', label: 'Zugeordnet', hint: 'Bereits verknüpfte Mails' },
];
const PAGE_SIZE = 50;
/** Hinweisbalken oben - erklaert Zustand und naechsten Schritt. */
const StatusBanner: React.FC<{ status: MailStatus; isAdmin: boolean; onOpenSettings: () => void }> = ({
status,
isAdmin,
onOpenSettings,
}) => {
if (!status.configured) {
return (
<div className="rounded-2xl border-l-4 border-l-amber-500 border border-ink-200 bg-amber-50/70 p-4">
<p className="text-sm font-bold text-amber-900">Postfachanbindung ist noch nicht eingerichtet.</p>
<p className="mt-1 text-xs text-amber-800">
In <code className="rounded bg-amber-100 px-1">server/.env</code> fehlt:{' '}
<strong>{status.missing.join(', ')}</strong>. Danach den Dienst neu starten.
{isAdmin && (
<>
{' '}
<button onClick={onOpenSettings} className="font-bold underline">
Zu den Einstellungen
</button>
</>
)}
</p>
</div>
);
}
if (status.last_error) {
return (
<div className="rounded-2xl border-l-4 border-l-red-500 border border-ink-200 bg-red-50/70 p-4">
<p className="text-sm font-bold text-red-900">Der letzte Abgleich ist fehlgeschlagen.</p>
<p className="mt-1 break-words text-xs text-red-800">{status.last_error}</p>
{isAdmin && (
<button onClick={onOpenSettings} className="mt-1 text-xs font-bold text-red-900 underline">
In den Einstellungen prüfen
</button>
)}
</div>
);
}
if (!status.active) {
return (
<div className="rounded-2xl border-l-4 border-l-ink-300 border border-ink-200 bg-ink-50 p-4">
<p className="text-sm font-bold text-ink-700">Der Abgleich ist pausiert.</p>
<p className="mt-1 text-xs text-ink-500">
Vorhandene Mails bleiben sichtbar, neue werden nicht geholt.
{isAdmin && (
<>
{' '}
<button onClick={onOpenSettings} className="font-bold underline">
Einstellungen
</button>
</>
)}
</p>
</div>
);
}
if (status.has_more) {
return (
<div className="rounded-2xl border-l-4 border-l-sky-500 border border-ink-200 bg-sky-50/70 p-4">
<p className="text-sm font-bold text-sky-900">Erstabgleich läuft noch.</p>
<p className="mt-1 text-xs text-sky-800">
Ältere Mails werden in mehreren Durchläufen nachgeholt – die Liste wächst also noch.
</p>
</div>
);
}
return null;
};
export const Mails: React.FC<MailsProps> = ({ leads, opportunities, reload }) => {
const { addToast } = useToast();
const { isAdmin } = useAuth();
const { goTo } = useNavigation();
const [tab, setTab] = useState<Tab>('postfach');
const [status, setStatus] = useState<MailStatus | null>(null);
const [messages, setMessages] = useState<MailMessageSummary[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [filter, setFilter] = useState<MailListFilter>('alle');
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [selectedId, setSelectedId] = useState<number | null>(null);
const [detail, setDetail] = useState<MailMessageDetail | null>(null);
const [isDetailLoading, setIsDetailLoading] = useState(false);
const [isListLoading, setIsListLoading] = useState(true);
const [isLinkOpen, setIsLinkOpen] = useState(false);
const [candidates, setCandidates] = useState<MailCandidate[]>([]);
const [isCandidatesLoading, setIsCandidatesLoading] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
// Die Suche soll nicht bei jedem Tastendruck eine Abfrage aufloesen.
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search.trim()), 350);
return () => clearTimeout(timer);
}, [search]);
const loadStatus = useCallback(async () => {
try {
setStatus(await mailApi.status());
} catch (err) {
addToast(err instanceof Error ? err.message : 'Status nicht abrufbar.', 'error');
}
}, [addToast]);
const loadMessages = useCallback(
async (nextOffset = 0, silent = false) => {
if (!silent) setIsListLoading(true);
try {
const data = await mailApi.messages({
search: debouncedSearch || undefined,
filter,
limit: PAGE_SIZE,
offset: nextOffset,
});
setMessages(data.messages);
setTotal(data.total);
setOffset(nextOffset);
} catch (err) {
addToast(err instanceof Error ? err.message : 'Mails nicht abrufbar.', 'error');
} finally {
setIsListLoading(false);
}
},
[debouncedSearch, filter, addToast]
);
const loadCandidates = useCallback(async () => {
setIsCandidatesLoading(true);
try {
setCandidates(await mailApi.candidates({ status: 'offen', limit: 300 }));
} catch (err) {
addToast(err instanceof Error ? err.message : 'Vorschläge nicht abrufbar.', 'error');
} finally {
setIsCandidatesLoading(false);
}
}, [addToast]);
const loadDetail = useCallback(
async (id: number) => {
setIsDetailLoading(true);
try {
setDetail(await mailApi.message(id));
} catch (err) {
addToast(err instanceof Error ? err.message : 'Mail nicht abrufbar.', 'error');
setDetail(null);
} finally {
setIsDetailLoading(false);
}
},
[addToast]
);
useEffect(() => {
loadStatus();
loadCandidates();
// Einmal beim Oeffnen; die Callbacks sind stabil.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
loadMessages(0);
}, [loadMessages]);
useEffect(() => {
if (selectedId !== null) loadDetail(selectedId);
else setDetail(null);
}, [selectedId, loadDetail]);
// Erste Mail der Liste vorauswaehlen, solange nichts gewaehlt ist.
const didAutoSelect = useRef(false);
useEffect(() => {
if (didAutoSelect.current) return;
if (messages.length > 0 && selectedId === null) {
didAutoSelect.current = true;
setSelectedId(messages[0].id);
}
}, [messages, selectedId]);
/** Nach jeder Aenderung an Vorschlaegen oder Zuordnungen alles nachziehen. */
const refreshAll = useCallback(async () => {
await Promise.all([
loadStatus(),
loadCandidates(),
loadMessages(offset, true),
selectedId !== null ? loadDetail(selectedId) : Promise.resolve(),
]);
// Die Kontaktliste der App kennt den neuen Kontakt noch nicht.
reload();
}, [loadStatus, loadCandidates, loadMessages, loadDetail, offset, selectedId, reload]);
const runSync = async () => {
setIsSyncing(true);
try {
const result = await mailApi.sync();
addToast(
`Abgleich fertig: ${result.newMessages ?? 0} neue Mails, ${result.newCandidates ?? 0} Kontakt-Vorschläge.`,
'success'
);
await refreshAll();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Abgleich fehlgeschlagen.', 'error');
await loadStatus();
} finally {
setIsSyncing(false);
}
};
const openLead = (leadId: number) => goTo('Kontakte', { leadId });
const openCandidateCount = status?.counts.open_candidates ?? candidates.length;
const pageInfo = useMemo(() => {
if (total === 0) return 'Keine Mails';
const from = offset + 1;
const to = Math.min(offset + messages.length, total);
return `${from}–${to} von ${total}`;
}, [offset, messages.length, total]);
return (
<div className="space-y-4">
{status && (
<StatusBanner
status={status}
isAdmin={isAdmin}
onOpenSettings={() => goTo('Einstellungen', { tab: 'mail' })}
/>
)}
{/* Steuerleiste */}
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex rounded-xl border border-ink-200 bg-surface p-1">
<button
onClick={() => setTab('postfach')}
className={`rounded-lg px-4 py-2 text-sm font-bold transition ${
tab === 'postfach' ? 'bg-brand-500 text-white' : 'text-ink-500 hover:bg-brand-50 hover:text-brand-700'
}`}
>
Postfach
</button>
<button
onClick={() => setTab('vorschlaege')}
className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-bold transition ${
tab === 'vorschlaege'
? 'bg-brand-500 text-white'
: 'text-ink-500 hover:bg-brand-50 hover:text-brand-700'
}`}
>
Kontakt-Vorschläge
{openCandidateCount > 0 && (
<span
className={`rounded-md px-1.5 py-0.5 text-[10px] font-extrabold ${
tab === 'vorschlaege' ? 'bg-white/25 text-white' : 'bg-amber-100 text-amber-700'
}`}
>
{openCandidateCount}
</span>
)}
</button>
</div>
{tab === 'postfach' && (
<>
<div className="relative w-full lg:max-w-sm">
<input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Betreff, Absender, Inhalt suchen"
className="w-full rounded-xl border border-ink-200 bg-surface py-2.5 pl-10 pr-4 text-sm text-ink-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25"
/>
<svg
className="absolute left-3.5 top-3 h-4 w-4 text-ink-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<select
value={filter}
onChange={(event) => setFilter(event.target.value as MailListFilter)}
title={FILTERS.find((entry) => entry.id === filter)?.hint}
className="rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-semibold text-ink-700 outline-none transition focus:border-brand-500"
>
{FILTERS.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.label}
</option>
))}
</select>
</>
)}
<div className="flex items-center gap-3 lg:ml-auto">
{status?.last_success_at && (
<span className="hidden text-xs text-ink-400 xl:inline" title="Letzter erfolgreicher Abgleich">
Stand {formatMailDateTime(status.last_success_at)}
</span>
)}
{isAdmin && status?.configured && (
<button
onClick={runSync}
disabled={isSyncing || status.running}
className="flex items-center gap-2 whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-60"
>
<svg
className={`h-4 w-4 ${isSyncing || status.running ? 'animate-spin' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{isSyncing || status.running ? 'Gleiche ab...' : 'Jetzt abgleichen'}
</button>
)}
</div>
</div>
{tab === 'postfach' ? (
isListLoading && messages.length === 0 ? (
<div className="flex items-center justify-center rounded-2xl border border-ink-200 bg-surface py-24">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
</div>
) : messages.length === 0 ? (
<div className={emptyStateClass}>
{status?.configured
? debouncedSearch || filter !== 'alle'
? 'Keine Mails passen zur Auswahl.'
: 'Noch keine Mails abgeglichen.'
: 'Sobald die Anbindung eingerichtet ist, erscheinen hier die Mails aus crm@perinco.de.'}
</div>
) : (
<div className="grid gap-4 xl:grid-cols-[380px_1fr]">
<div className="flex max-h-[calc(100vh-260px)] flex-col overflow-hidden rounded-2xl border border-ink-200 bg-surface">
<div className="scrollbar-slim flex-1 overflow-y-auto">
<MailList messages={messages} selectedId={selectedId} onSelect={setSelectedId} />
</div>
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-ink-100 px-4 py-2.5">
<span className="text-xs font-bold text-ink-400">{pageInfo}</span>
<div className="flex gap-1">
<button
onClick={() => loadMessages(Math.max(0, offset - PAGE_SIZE))}
disabled={offset === 0}
className="rounded-lg border border-ink-200 px-2.5 py-1 text-xs font-bold text-ink-600 transition hover:bg-ink-100 disabled:opacity-40"
>
Zurück
</button>
<button
onClick={() => loadMessages(offset + PAGE_SIZE)}
disabled={offset + messages.length >= total}
className="rounded-lg border border-ink-200 px-2.5 py-1 text-xs font-bold text-ink-600 transition hover:bg-ink-100 disabled:opacity-40"
>
Weiter
</button>
</div>
</div>
</div>
<div className="min-w-0">
{detail ? (
<MailDetail
message={detail}
isLoading={isDetailLoading}
onLink={() => setIsLinkOpen(true)}
onReload={refreshAll}
onOpenLead={openLead}
/>
) : (
<div className={emptyStateClass}>Eine Mail auswählen.</div>
)}
</div>
</div>
)
) : isCandidatesLoading && candidates.length === 0 ? (
<div className="flex items-center justify-center rounded-2xl border border-ink-200 bg-surface py-24">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
</div>
) : candidates.length === 0 ? (
<div className={emptyStateClass}>
Keine offenen Vorschläge.
{status?.auto_contacts && (
<p className="mx-auto mt-2 max-w-md text-xs font-normal text-ink-400">
Vorschläge mit gesicherter E-Mail-Adresse werden automatisch übernommen. Hier landet nur, was
eine Entscheidung braucht – etwa Personen, die nur mit Telefonnummer im Text stehen.
</p>
)}
</div>
) : (
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-base text-ink-900">Offene Kontakt-Vorschläge</h3>
<p className="mt-0.5 text-xs text-ink-400">
Übernommene Kontakte erscheinen unter Kontakte mit der Herkunft „Mail".
</p>
</div>
<Badge className="border-amber-200 bg-amber-50 text-amber-700">{candidates.length} offen</Badge>
</div>
<ul className="space-y-2">
{candidates.map((candidate) => (
<CandidateRow
key={candidate.id}
candidate={candidate}
onChanged={refreshAll}
onOpenLead={openLead}
showMessage
onOpenMessage={(messageId) => {
setTab('postfach');
setSelectedId(messageId);
}}
/>
))}
</ul>
</section>
)}
{isLinkOpen && detail && (
<MailLinkDialog
message={detail}
leads={leads}
opportunities={opportunities}
onClose={() => setIsLinkOpen(false)}
onLinked={refreshAll}
/>
)}
</div>
);
};
+92
View File
@@ -0,0 +1,92 @@
import React from 'react';
import type { CandidateOrigin, CandidateStatus, MailAddress } from '../../lib/mailApi';
/** Herkunft eines Vorschlags - je hoeher die Zuversicht, je kraeftiger die Farbe. */
export const ORIGIN_LABELS: Record<CandidateOrigin, string> = {
absender: 'Absender',
empfaenger: 'Empfänger',
signatur: 'Signatur',
text: 'Im Text genannt',
};
export const ORIGIN_STYLES: Record<CandidateOrigin, string> = {
absender: 'bg-brand-50 text-brand-700 border-brand-200',
empfaenger: 'bg-sky-50 text-sky-700 border-sky-200',
signatur: 'bg-amber-50 text-amber-700 border-amber-200',
text: 'bg-ink-100 text-ink-600 border-ink-200',
};
export const CANDIDATE_STATUS_LABELS: Record<CandidateStatus, string> = {
offen: 'Offen',
uebernommen: 'Übernommen',
verworfen: 'Verworfen',
dublette: 'Ergänzt',
};
export const CANDIDATE_STATUS_STYLES: Record<CandidateStatus, string> = {
offen: 'bg-amber-50 text-amber-700 border-amber-200',
uebernommen: 'bg-brand-50 text-brand-700 border-brand-200',
verworfen: 'bg-ink-100 text-ink-500 border-ink-200',
dublette: 'bg-sky-50 text-sky-700 border-sky-200',
};
export const Badge: React.FC<{ className?: string; children: React.ReactNode; title?: string }> = ({
className = '',
children,
title,
}) => (
<span
title={title}
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider ${className}`}
>
{children}
</span>
);
/** Balken fuer die Zuversicht eines Vorschlags. */
export const ConfidenceBar: React.FC<{ value: number }> = ({ value }) => {
const tone = value >= 78 ? 'bg-brand-500' : value >= 50 ? 'bg-amber-500' : 'bg-ink-300';
return (
<div className="flex items-center gap-2" title={`Zuversicht ${value} von 100`}>
<div className="h-1.5 w-14 overflow-hidden rounded-full bg-ink-100">
<div className={`h-full rounded-full ${tone}`} style={{ width: `${Math.max(4, value)}%` }} />
</div>
<span className="text-[11px] font-bold text-ink-400">{value}</span>
</div>
);
};
/** Adressliste kompakt: "Anna Müller, Thomas Schmidt +2". */
export function formatAddresses(list: MailAddress[], max = 2): string {
if (list.length === 0) return '—';
const names = list.map((entry) => entry.name || entry.email);
if (names.length <= max) return names.join(', ');
return `${names.slice(0, max).join(', ')} +${names.length - max}`;
}
export function initialsFromMail(name: string | null, email: string | null): string {
const source = (name || email || '?').trim();
const parts = source.split(/[\s.@_-]+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return source.slice(0, 2).toUpperCase();
}
/** Stabile Farbe je Absender, damit die Liste schneller lesbar ist. */
const AVATAR_COLORS = [
'bg-brand-100 text-brand-700',
'bg-sky-100 text-sky-700',
'bg-amber-100 text-amber-700',
'bg-violet-100 text-violet-700',
'bg-rose-100 text-rose-700',
'bg-teal-100 text-teal-700',
];
export function avatarColor(seed: string | null): string {
if (!seed) return 'bg-ink-100 text-ink-500';
let hash = 0;
for (let i = 0; i < seed.length; i += 1) hash = (hash * 31 + seed.charCodeAt(i)) % 997;
return AVATAR_COLORS[hash % AVATAR_COLORS.length];
}
export const emptyStateClass =
'rounded-2xl border-2 border-dashed border-ink-200 bg-surface py-16 text-center text-sm font-bold text-ink-500';
+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>
+264
View File
@@ -0,0 +1,264 @@
import { apiRequest } from './api';
/*
* Postfachanbindung crm@perinco.de. Alle Endpunkte liegen unter /api/mail und
* haengen an derselben CRM-Anmeldung wie der Rest der App.
*
* Zeitangaben kommen als MariaDB-DATETIME in UTC (`YYYY-MM-DD HH:MM:SS`), so wie
* Microsoft Graph sie liefert. Fuer die Anzeige gibt es parseMailDate().
*/
export interface MailAddress {
name: string | null;
email: string;
}
export interface MailMessageSummary {
id: number;
subject: string | null;
from_name: string | null;
from_email: string | null;
to: MailAddress[];
cc: MailAddress[];
received_at: string;
body_preview: string | null;
has_attachments: boolean;
is_deleted: boolean;
web_link: string | null;
link_count: number;
open_candidates: number;
}
export interface MailLink {
id: number;
lead_id: number | null;
opportunity_id: number | null;
origin: 'manuell' | 'automatisch';
note: string | null;
created_at: string;
created_by: number | null;
created_by_name: string | null;
lead_name: string | null;
lead_company: string | null;
opportunity_title: string | null;
opportunity_stage: string | null;
}
export type CandidateOrigin = 'absender' | 'empfaenger' | 'signatur' | 'text';
export type CandidateStatus = 'offen' | 'uebernommen' | 'verworfen' | 'dublette';
export interface MailCandidate {
id: number;
message_id: number;
first_name: string | null;
last_name: string | null;
salutation: string | null;
email: string | null;
phone: string | null;
mobile: string | null;
company: string | null;
position: string | null;
street: string | null;
zip: string | null;
city: string | null;
website: string | null;
linkedin: string | null;
origin: CandidateOrigin;
confidence: number;
status: CandidateStatus;
lead_id: number | null;
lead_name: string | null;
decided_at: string | null;
decided_by_name?: string | null;
created_at: string;
/** Aus dem Join: nur in der Vorschlagsliste gesetzt. */
message_subject?: string | null;
message_from?: string | null;
message_received_at?: string;
/** Adresse existiert schon als Kontakt - dann wird ergaenzt, nicht angelegt. */
existing_lead_id?: number | null;
}
export interface MailMessageDetail extends MailMessageSummary {
bcc: MailAddress[];
sent_at: string | null;
importance: string | null;
conversation_id: string | null;
internet_message_id: string | null;
is_read_in_mailbox: boolean;
body_text: string | null;
body_html: string | null;
links: MailLink[];
candidates: MailCandidate[];
}
export interface MailStatus {
configured: boolean;
/** Namen der fehlenden .env-Werte, wenn nicht konfiguriert. */
missing: string[];
mailbox: string;
folder: string;
scheduler_enabled: boolean;
active: boolean;
interval_minutes: number;
auto_contacts: boolean;
since_days: number;
running: boolean;
/** Es sind noch Delta-Seiten offen - der Erstabgleich laeuft noch. */
has_more: boolean;
/** Ein erster Abgleich hat stattgefunden. */
initialized: boolean;
last_run_at: string | null;
last_success_at: string | null;
last_error: string | null;
last_new_messages: number;
last_candidates: number;
counts: {
messages: number;
open_candidates: number;
adopted_candidates: number;
links: number;
};
last_result: MailSyncResult | null;
}
export interface MailSyncResult {
ok: boolean;
trigger: string;
startedAt: string;
durationMs: number;
pages?: number;
newMessages?: number;
updatedMessages?: number;
deletedMessages?: number;
newCandidates?: number;
adoptedContacts?: number;
hasMore?: boolean;
error?: string;
}
export interface MailConnectionTest {
ok: boolean;
stage: 'konfiguration' | 'token' | 'mailbox';
error?: string;
code?: string | null;
hint?: string;
mailbox?: string;
folder?: string | null;
totalItems?: number | null;
unreadItems?: number | null;
}
/** Alle Werte liegen als Text in crm_settings - auch Zahlen und Schalter. */
export interface MailSettingsValues {
mail_sync_active: string;
mail_sync_interval: string;
mail_auto_contacts: string;
mail_since_days: string;
mail_internal_domains: string;
}
export const MAIL_SETTINGS_DEFAULTS: MailSettingsValues = {
mail_sync_active: 'true',
mail_sync_interval: '10',
mail_auto_contacts: 'true',
mail_since_days: '30',
mail_internal_domains: 'perinco.de,perinco.onmicrosoft.com',
};
export type MailListFilter = 'alle' | 'unlinked' | 'linked' | 'candidates';
export const mailApi = {
status: () => apiRequest<MailStatus>('/mail/status'),
messages: (params: { search?: string; filter?: MailListFilter; limit?: number; offset?: number } = {}) => {
const query = new URLSearchParams();
if (params.search) query.set('search', params.search);
if (params.filter && params.filter !== 'alle') query.set('filter', params.filter);
if (params.limit) query.set('limit', String(params.limit));
if (params.offset) query.set('offset', String(params.offset));
const suffix = query.toString() ? `?${query}` : '';
return apiRequest<{ total: number; limit: number; offset: number; messages: MailMessageSummary[] }>(
`/mail/messages${suffix}`
);
},
message: (id: number) => apiRequest<MailMessageDetail>(`/mail/messages/${id}`),
link: (messageId: number, input: { lead_id?: number | null; opportunity_id?: number | null; note?: string }) =>
apiRequest<{ ok: true; created: number[] }>(`/mail/messages/${messageId}/links`, {
method: 'POST',
body: JSON.stringify(input),
}),
unlink: (linkId: number) => apiRequest<{ ok: true }>(`/mail/links/${linkId}`, { method: 'DELETE' }),
candidates: (params: { status?: CandidateStatus | 'alle'; limit?: number } = {}) => {
const query = new URLSearchParams();
query.set('status', params.status ?? 'offen');
if (params.limit) query.set('limit', String(params.limit));
return apiRequest<MailCandidate[]>(`/mail/candidates?${query}`);
},
adoptCandidate: (id: number, input: { owner_id?: number | null } = {}) =>
apiRequest<{ status: 'uebernommen' | 'dublette'; leadId: number; enrichedFields: number }>(
`/mail/candidates/${id}/adopt`,
{ method: 'POST', body: JSON.stringify(input) }
),
rejectCandidate: (id: number) =>
apiRequest<{ ok: true; status: 'verworfen' }>(`/mail/candidates/${id}/reject`, { method: 'POST' }),
// ---------- nur Administratoren ----------
settings: () => apiRequest<Partial<MailSettingsValues>>('/mail/settings'),
saveSettings: (input: Partial<MailSettingsValues>) =>
apiRequest<MailSettingsValues>('/mail/settings', { method: 'POST', body: JSON.stringify(input) }),
test: () => apiRequest<MailConnectionTest>('/mail/test', { method: 'POST' }),
sync: (options: { reset?: boolean } = {}) =>
apiRequest<MailSyncResult>('/mail/sync', { method: 'POST', body: JSON.stringify(options) }),
};
/**
* MariaDB-DATETIME in UTC nach Date. Ohne das angehaengte Z liest der Browser
* den Wert als Ortszeit und die Anzeige waere je Zeitzone um Stunden verschoben.
*/
export function parseMailDate(value: string | null): Date | null {
if (!value) return null;
const date = new Date(`${value.replace(' ', 'T')}Z`);
return Number.isNaN(date.getTime()) ? null : date;
}
/** Heute nur Uhrzeit, dieses Jahr Tag und Monat, sonst mit Jahr. */
export function formatMailDate(value: string | null): string {
const date = parseMailDate(value);
if (!date) return '—';
const now = new Date();
const sameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
if (sameDay) return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
if (date.getFullYear() === now.getFullYear()) {
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short' });
}
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' });
}
/** Vollstaendige Angabe fuer die Detailansicht und Titelhinweise. */
export function formatMailDateTime(value: string | null): string {
const date = parseMailDate(value);
if (!date) return '—';
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function candidateName(candidate: MailCandidate): string {
return [candidate.first_name, candidate.last_name].filter(Boolean).join(' ').trim() || 'Ohne Namen';
}
+5 -1
View File
@@ -8,10 +8,14 @@ import type { SettingsTab, ViewState } from '../types';
*
* `tab` waehlt beim Sprung in die Einstellungen gleich den richtigen Reiter aus;
* Whisper ist genau so ein Ziel und hat deshalb keinen eigenen Menuepunkt mehr.
*
* `leadId` oeffnet in der Kontaktansicht direkt den passenden Kontakt - die
* Mailansicht springt so von einer Zuordnung zum Kontakt, ohne dass der Benutzer
* ihn in der Liste suchen muss.
*/
interface NavigationContextValue {
currentView: ViewState;
goTo: (view: ViewState, options?: { tab?: SettingsTab }) => void;
goTo: (view: ViewState, options?: { tab?: SettingsTab; leadId?: number }) => void;
}
const NavigationContext = createContext<NavigationContextValue | null>(null);
+16 -5
View File
@@ -2,6 +2,7 @@ export type ViewState =
| 'Opportunities'
| 'Kalender'
| 'Kontakte'
| 'Mails'
| 'Scanner'
| 'Unpassend'
| 'Suchagenten'
@@ -12,13 +13,13 @@ export type ViewState =
* Reiter der Einstellungen. Whisper sitzt hier und nicht mehr im Menue - es sind
* Geraeteeinstellungen, die man einmal einrichtet.
*
* Rollen: `users` und `scanner` sind Administratoren vorbehalten, `account` und
* `whisper` stehen jedem offen - jeder pflegt sein eigenes Konto und richtet sein
* eigenes Diktat ein.
* Rollen: `users`, `scanner` und `mail` sind Administratoren vorbehalten,
* `account` und `whisper` stehen jedem offen - jeder pflegt sein eigenes Konto
* und richtet sein eigenes Diktat ein.
*/
export type SettingsTab = 'users' | 'account' | 'scanner' | 'whisper';
export type SettingsTab = 'users' | 'account' | 'mail' | 'scanner' | 'whisper';
export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['users', 'scanner'];
export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['users', 'mail', 'scanner'];
/**
* Reiter des Projekt Scanners. "Scanner" selbst ist das Dashboard und damit die
@@ -80,6 +81,16 @@ export const LEAD_STATUSES = [
export type LeadStatus = (typeof LEAD_STATUSES)[number];
/**
* Herkunft von Kontakten, die aus dem Postfach entstanden sind. Steht in
* `Lead.source` und traegt damit die Unterkategorie in der Kontaktliste - es
* braucht dafuer kein eigenes Feld.
*/
export const MAIL_LEAD_SOURCE = 'Mail';
/** Filter der Kontaktliste nach Herkunft. */
export type LeadOriginFilter = 'Alle' | 'Mail' | 'Manuell';
export interface Lead {
id: number;
salutation: string | null;