feat: IMAP-Posteingang fürs Ticketsystem (Mail-Antworten -> Tickets)

Neues Postfach lässt sich unter Einstellungen > E-Mail > IMAP-Posteingang
einrichten (Host/Port/Verschlüsselung/Zugangsdaten/Ordner, An/Aus,
Verbindungstest). Der Worker ruft es alle 2 Minuten ab (mailbox.ts) und
ordnet eingehende Mails zu:

1. Betreff enthält eine Ticketnummer (T-123) UND Absender gehört
   nachweislich zum Kunden dieses Tickets -> wird als Kundennachricht
   angehängt (Status pending_staff, Discord-Benachrichtigung).
2. Sonst, wenn der Absender ein bekannter aktiver Kunde ist -> neues
   Ticket wird automatisch unter seinem Kundenkonto angelegt.
3. Sonst (unbekannter/nicht verifizierbarer Absender) -> landet im
   neuen Posteingang (/admin/inbox, UI unter /admin/posteingang) zur
   manuellen Zuordnung: Personal wählt einen Kunden (legt Ticket an),
   hängt die Mail an ein bestehendes Ticket, oder ignoriert sie.

Beim ersten Aktivieren wird kein Mailbox-Bestand verarbeitet, nur ab
dann neu Eingehendes (last_uid wird auf den aktuellen Stand gesetzt).

Migration 024: imap_settings (Verbindung, verschlüsseltes Passwort,
last_uid/last_error), inbox_messages (Warteschlange). Rechte:
email.read/write für die IMAP-Verbindung (wie SMTP), tickets.read/write
für den Posteingang (Zuordnung ist eine Ticket-Aktion, daher im
Tickets-Modul statt eines eigenen Moduls, um keine Modulgrenzen zu
verletzen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-09-28 17:35:58 +02:00
parent 7d2b2947f4
commit fe18bf0bce
11 changed files with 651 additions and 3 deletions

View file

@ -0,0 +1,67 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { api, errMsg } from '@/lib/api';
import { Alert, Empty, fmt } from '@/components/ui';
interface Msg { id: string; fromEmail: string; fromName: string | null; subject: string | null; bodyText: string | null; receivedAt: string; status: 'unmatched' | 'matched' | 'created' | 'ignored'; ticketId: string | null; ticketNumber: string | null }
interface Cust { id: string; name: string; customerNumber: string }
const STATUS_LABEL: Record<Msg['status'], string> = { unmatched: 'Unzugeordnet', matched: 'An Ticket angehängt', created: 'Ticket erstellt', ignored: 'Ignoriert' };
/** Eine unzugeordnete Mail: Kunde zuweisen (legt neues Ticket an), an bestehendes Ticket anhängen, oder ignorieren. */
function UnmatchedRow({ m, custs, onDone, onError }: { m: Msg; custs: Cust[]; onDone: (t: string) => void; onError: (m: string) => void }) {
const [org, setOrg] = useState(''); const [ticketNumber, setTicketNumber] = useState(''); const [busy, setBusy] = useState(false); const [open, setOpen] = useState(false);
async function assign() {
if (!org) return; setBusy(true);
try { const r = await api<{ number: string }>('POST', `/admin/inbox/${m.id}/assign`, { orgId: org }); onDone(`Ticket ${r.number} angelegt.`); }
catch (x) { onError(errMsg(x)); } finally { setBusy(false); }
}
async function link() {
if (!ticketNumber.trim()) return; setBusy(true);
try { await api('POST', `/admin/inbox/${m.id}/link`, { ticketNumber: ticketNumber.trim() }); onDone(`An Ticket ${ticketNumber.trim()} angehängt.`); }
catch (x) { onError(errMsg(x)); } finally { setBusy(false); }
}
async function ignore() {
setBusy(true);
try { await api('POST', `/admin/inbox/${m.id}/ignore`); onDone('Ignoriert.'); }
catch (x) { onError(errMsg(x)); } finally { setBusy(false); }
}
return (<div className="card">
<div className="row between"><div><strong>{m.subject || '(kein Betreff)'}</strong><p className="small muted" style={{ margin: '2px 0 0' }}>Von: {m.fromName ? `${m.fromName} <${m.fromEmail}>` : m.fromEmail} · {fmt(m.receivedAt)}</p></div>
<button className="btn small" aria-expanded={open} onClick={() => setOpen(!open)}>{open ? 'Schließen' : 'Text anzeigen'}</button></div>
{open && <pre className="small" style={{ whiteSpace: 'pre-wrap', background: 'var(--surface-2)', padding: 12, borderRadius: 'var(--radius-sm)', margin: '12px 0', maxHeight: 300, overflow: 'auto' }}>{m.bodyText || '(kein Text)'}</pre>}
<div className="row" style={{ marginTop: 12, gap: 16, flexWrap: 'wrap' }}>
<div className="row" style={{ gap: 8 }}>
<select aria-label="Kunde zuweisen" value={org} onChange={(e) => setOrg(e.target.value)} style={{ minWidth: 200 }}><option value="">Kunde wählen …</option>{custs.map((c) => <option key={c.id} value={c.id}>{c.customerNumber} · {c.name}</option>)}</select>
<button className="btn small primary" disabled={!org || busy} onClick={assign}>Zuweisen (neues Ticket)</button>
</div>
<div className="row" style={{ gap: 8 }}>
<input aria-label="Ticketnummer" placeholder="T-42" value={ticketNumber} onChange={(e) => setTicketNumber(e.target.value)} style={{ width: 100 }} />
<button className="btn small" disabled={!ticketNumber.trim() || busy} onClick={link}>An Ticket anhängen</button>
</div>
<button className="btn small danger" disabled={busy} onClick={ignore}>Ignorieren</button>
</div>
</div>);
}
export default function Posteingang() {
const [status, setStatus] = useState<'unmatched' | 'matched' | 'created' | 'ignored' | ''>('unmatched');
const [list, setList] = useState<Msg[] | null>(null); const [custs, setCusts] = useState<Cust[]>([]); const [err, setErr] = useState(''); const [ok, setOk] = useState('');
const load = useCallback(() => api<Msg[]>('GET', `/admin/inbox${status ? `?status=${status}` : ''}`).then(setList).catch((e) => setErr(errMsg(e))), [status]);
useEffect(() => { void load(); }, [load]);
useEffect(() => { api<Cust[]>('GET', '/admin/customers').then(setCusts).catch(() => undefined); }, []);
return (<>
<div className="pagehead"><h1>Posteingang</h1></div>
<p className="muted small" style={{ marginTop: -12, marginBottom: 16 }}>Mails aus dem konfigurierten Ticket-Postfach, die nicht automatisch zugeordnet werden konnten (siehe Einstellungen &gt; E-Mail &gt; IMAP-Posteingang).</p>
{err && <Alert kind="err">{err}</Alert>}
{ok && <Alert kind="ok">{ok}</Alert>}
<div className="row small" style={{ marginBottom: 16, gap: 8 }}>
{(['unmatched', 'matched', 'created', 'ignored', ''] as const).map((s) => <button key={s || 'all'} className="btn small" style={status === s ? { background: 'var(--surface-2)', fontWeight: 600, borderColor: 'var(--accent)' } : undefined} onClick={() => setStatus(s)}>{s ? STATUS_LABEL[s] : 'Alle'}</button>)}
</div>
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <div className="card"><Empty title="Keine Einträge">{status === 'unmatched' ? 'Aktuell liegen keine unzugeordneten Mails vor.' : 'Für diesen Filter gibt es keine Einträge.'}</Empty></div> :
list.map((m) => m.status === 'unmatched'
? <UnmatchedRow key={m.id} m={m} custs={custs} onDone={(t) => { setOk(t); setErr(''); void load(); }} onError={setErr} />
: <div className="card" key={m.id}><div className="row between"><div><strong>{m.subject || '(kein Betreff)'}</strong><p className="small muted" style={{ margin: '2px 0 0' }}>Von: {m.fromName ? `${m.fromName} <${m.fromEmail}>` : m.fromEmail} · {fmt(m.receivedAt)}</p></div>
<div className="row" style={{ gap: 8 }}><span className="badge info">{STATUS_LABEL[m.status]}</span>{m.ticketNumber && <Link className="btn small" href={`/tickets/${m.ticketId}`}>{m.ticketNumber} ansehen</Link>}</div></div></div>)}
</>);
}

View file

@ -1,5 +1,6 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import Link from 'next/link';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Field, fmt } from '@/components/ui';
@ -9,7 +10,9 @@ type Category = 'kundencenter' | 'stoerungsticket' | 'rechnungen';
interface Template { key: string; name: string; trigger: string; category: Category; variables: string[]; subject: string; body: string; enabled: boolean; updatedAt: string }
interface Sender { category: Category; name: string; fromName: string | null; fromEmail: string | null; updatedAt: string }
interface LogRow { id: number; to: string; template: string; status: string; error: string | null; createdAt: string }
interface ImapSettings { host: string | null; port: number; secureMode: 'tls' | 'starttls'; username: string | null; hasPassword: boolean; folder: string; enabled: boolean; configured: boolean; lastCheckedAt: string | null; lastError: string | null }
const SECURE_LABEL: Record<string, string> = { none: 'Keine Verschlüsselung', starttls: 'STARTTLS (empfohlen, meist Port 587)', tls: 'TLS/SSL direkt (meist Port 465)' };
const IMAP_SECURE_LABEL: Record<string, string> = { starttls: 'STARTTLS (meist Port 143)', tls: 'TLS/SSL direkt (meist Port 993)' };
const CATEGORY_LABEL: Record<Category, string> = { kundencenter: 'Kundencenter', stoerungsticket: 'Störungsticket', rechnungen: 'Rechnungen' };
const STATUS: Record<string, { cls: string; icon: string; label: string }> = {
sent: { cls: 'ok', icon: '✓', label: 'Gesendet' }, failed: { cls: 'err', icon: '✕', label: 'Fehlgeschlagen' },
@ -85,6 +88,47 @@ function SenderProfiles({ senders, s, w, onSaved }: { senders: Sender[]; s: Sett
</div>);
}
/** IMAP-Posteingang fürs Ticketsystem: Verbindung + An/Aus-Schalter. Die eigentliche Zuordnungslogik läuft im Worker (mailbox.ts). */
function ImapForm({ s, w, onSaved }: { s: ImapSettings; w: boolean; onSaved: () => void }) {
const [busy, setBusy] = useState(false); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [testBusy, setTestBusy] = useState(false);
async function save(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); if (!w) return; setBusy(true); setMsg(null);
const form = e.currentTarget;
const f = new FormData(form); const v = (k: string) => (String(f.get(k) ?? '').trim() || null);
const pw = String(f.get('password') ?? '');
try {
await api('PUT', '/admin/email/imap-settings', { host: v('host'), port: Number(f.get('port')) || 993, secureMode: f.get('secureMode'), username: v('username'), folder: v('folder') || 'INBOX', enabled: f.get('enabled') === 'on', ...(pw ? { password: pw } : {}) });
setMsg({ k: 'ok', t: 'Gespeichert.' }); (form.elements.namedItem('password') as HTMLInputElement).value = ''; onSaved();
} catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(false); }
}
async function test() {
setTestBusy(true); setMsg(null);
try { const r = await api<{ ok: boolean; error: string | null; count: number | null }>('POST', '/admin/email/imap-settings/test'); setMsg({ k: r.ok ? 'ok' : 'err', t: r.ok ? `Verbindung erfolgreich, ${r.count ?? 0} Nachrichten im Ordner.` : `Fehlgeschlagen – ${r.error}` }); }
catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setTestBusy(false); }
}
return (<div className="card">
<h2>IMAP-Posteingang (Ticketsystem)</h2>
<p className="muted small">Wird ein Postfach eingetragen und aktiviert, ruft das Kundencenter dort regelmäßig neue Mails ab: Antworten mit Ticketnummer im Betreff werden dem Ticket angehängt, neue Mails bekannter Kunden werden als neues Ticket angelegt, alles andere landet zur manuellen Zuordnung im <Link href="/admin/posteingang">Posteingang</Link>.</p>
{!s.configured && <Alert kind="warn">Noch nicht eingerichtet.</Alert>}
{s.configured && !s.enabled && <Alert kind="info">Eingerichtet, aber deaktiviert – es werden keine Mails abgerufen.</Alert>}
{s.lastError && <Alert kind="err">Letzter Abruf fehlgeschlagen: {s.lastError}</Alert>}
{msg && <Alert kind={msg.k}>{msg.t}</Alert>}
<form onSubmit={save}>
<div className="cols">
<Field id="ihost" label="IMAP-Server (Host)"><input id="ihost" name="host" defaultValue={s.host ?? ''} placeholder="imap.example.de" disabled={!w} /></Field>
<Field id="iport" label="Port"><input id="iport" name="port" type="number" min={1} max={65535} defaultValue={s.port} disabled={!w} /></Field>
<Field id="isecureMode" label="Verschlüsselung"><select id="isecureMode" name="secureMode" defaultValue={s.secureMode} disabled={!w}>{Object.entries(IMAP_SECURE_LABEL).map(([k, l]) => <option key={k} value={k}>{l}</option>)}</select></Field>
<Field id="iusername" label="Benutzername"><input id="iusername" name="username" defaultValue={s.username ?? ''} disabled={!w} /></Field>
<Field id="ipassword" label="Passwort" hint={s.hasPassword ? 'Ein Passwort ist gespeichert. Leer lassen, um es unverändert zu lassen.' : 'Noch kein Passwort gespeichert.'}><input id="ipassword" name="password" type="password" autoComplete="new-password" disabled={!w} /></Field>
<Field id="ifolder" label="Ordner"><input id="ifolder" name="folder" defaultValue={s.folder} disabled={!w} /></Field>
</div>
<label className="row small" style={{ marginBottom: 12 }}><input type="checkbox" name="enabled" defaultChecked={s.enabled} disabled={!w} style={{ width: 20, minHeight: 20 }} /> Abruf aktiv</label>
{w && <div className="row"><button className="btn primary" type="submit" disabled={busy}>{busy ? 'Bitte warten …' : 'Speichern'}</button>{s.configured && <button className="btn" type="button" disabled={testBusy} onClick={test}>{testBusy ? 'Bitte warten …' : 'Verbindung testen'}</button>}</div>}
</form>
<p className="small muted" style={{ marginTop: 8 }}>Letzter Abruf: {s.lastCheckedAt ? fmt(s.lastCheckedAt) : 'noch nie'}</p>
</div>);
}
/** Eine Vorlage: feste Definition (Auslöser, verfügbare Daten) + editierbarer Betreff/Text. */
function TemplateCard({ t, w, onSaved }: { t: Template; w: boolean; onSaved: () => void }) {
const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null);
@ -124,12 +168,13 @@ function TemplateCard({ t, w, onSaved }: { t: Template; w: boolean; onSaved: ()
export default function EmailSettings() {
const { can } = useSession(); const w = can('email.write');
const [s, setS] = useState<Settings | null>(null); const [senders, setSenders] = useState<Sender[] | null>(null); const [tpls, setTpls] = useState<Template[] | null>(null); const [log, setLog] = useState<LogRow[] | null>(null); const [err, setErr] = useState('');
const [s, setS] = useState<Settings | null>(null); const [senders, setSenders] = useState<Sender[] | null>(null); const [tpls, setTpls] = useState<Template[] | null>(null); const [log, setLog] = useState<LogRow[] | null>(null); const [imap, setImap] = useState<ImapSettings | null>(null); const [err, setErr] = useState('');
const load = useCallback(() => {
api<Settings>('GET', '/admin/email/settings').then(setS).catch((e) => setErr(errMsg(e)));
api<Sender[]>('GET', '/admin/email/senders').then(setSenders).catch((e) => setErr(errMsg(e)));
api<Template[]>('GET', '/admin/email/templates').then(setTpls).catch((e) => setErr(errMsg(e)));
api<LogRow[]>('GET', '/admin/email/log').then(setLog).catch(() => undefined);
api<ImapSettings>('GET', '/admin/email/imap-settings').then(setImap).catch((e) => setErr(errMsg(e)));
}, []);
useEffect(() => { void load(); }, [load]);
return (<>
@ -147,5 +192,6 @@ export default function EmailSettings() {
{log === null ? <tr><td colSpan={4} className="muted">Wird geladen …</td></tr> : log.length === 0 ? <tr><td colSpan={4} className="muted">Noch keine Einträge.</td></tr> :
log.map((l) => <tr key={l.id}><td className="small">{fmt(l.createdAt)}</td><td className="small">{l.to}</td><td className="small">{l.template}</td><td><StatusBadge v={l.status} />{l.error && <div className="small muted">{l.error}</div>}</td></tr>)}
</tbody></table></div></div>
<div style={{ marginTop: 32 }}>{imap ? <ImapForm s={imap} w={w} onSaved={load} /> : <p className="muted" role="status">Wird geladen …</p>}</div>
</>);
}

View file

@ -28,7 +28,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
{!enrollNeeded && link('/dashboard', 'Übersicht')}
{!enrollNeeded && produkte.length > 0 && group('Produkte', produkte.map(([h]) => h), produkte.map(([h, l]) => <span key={h}>{link(h, l)}</span>))}
{!enrollNeeded && (me.kind === 'customer' || can('invoices.read')) && group('Abrechnung', ['/rechnungen'], link('/rechnungen', 'Rechnungen'))}
{!enrollNeeded && (me.kind === 'customer' || can('tickets.read')) && group('Support', ['/tickets'], link('/tickets', 'Tickets'))}
{!enrollNeeded && (me.kind === 'customer' || can('tickets.read')) && group('Support', ['/tickets', '/admin/posteingang'], <>{link('/tickets', 'Tickets')}{can('tickets.read') && <span>{link('/admin/posteingang', 'Posteingang')}</span>}</>)}
{!enrollNeeded && me.organizations.length > 0 && link('/organisation', 'Organisation')}
{!enrollNeeded && link('/domains', 'Domain prüfen')}
{!enrollNeeded && me.kind === 'staff' && verwaltung.length > 0 && group('Verwaltung', verwaltung.map(([h]) => h), verwaltung.map(([h, l]) => <span key={h}>{link(h, l)}</span>))}