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>)}
</>);
}