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

@ -22,6 +22,7 @@
"@kc/platform": "workspace:*",
"@node-rs/argon2": "^2.2.1",
"fastify": "^5.12.5",
"imapflow": "^1.0.191",
"mysql2": "^3.24.4",
"otpauth": "^9.5.2",
"pdfkit": "^0.20.2",

View file

@ -1,8 +1,9 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { ImapFlow } from 'imapflow';
import { one, query, run } from '../../core/db.js';
import { audit } from '../../core/audit.js';
import { encrypt } from '../../core/crypto.js';
import { encrypt, decrypt } from '../../core/crypto.js';
import { clientIp, requirePermission } from '../../core/auth.js';
import { badRequest, notFound } from '../../core/errors.js';
import { rl } from '../../core/config.js';
@ -109,5 +110,45 @@ export const mailModule: KcModule = {
return (await query('SELECT id, to_email, template, status, error, created_at FROM mail_log ORDER BY id DESC LIMIT 100'))
.map((r) => ({ id: r.id, to: r.to_email, template: r.template, status: r.status, error: r.error, createdAt: r.created_at }));
});
// ---- IMAP-Postfach für eingehende Ticket-Mails (Einstellungen > E-Mail) ------------------------
const imapView = (s: any) => ({
host: s.host, port: s.port, secureMode: s.secure_mode, username: s.username, hasPassword: !!s.secrets_enc, folder: s.folder, enabled: !!s.enabled,
lastCheckedAt: s.last_checked_at, lastError: s.last_error, configured: !!s.host && !!s.username && !!s.secrets_enc,
});
app.get('/admin/email/imap-settings', async (req) => { requirePermission(req, 'email.read'); return imapView((await one('SELECT * FROM imap_settings WHERE id = 1'))!); });
app.put('/admin/email/imap-settings', async (req) => {
const a = requirePermission(req, 'email.write');
const b = z.object({
host: z.string().trim().max(253).nullable(), port: z.number().int().min(1).max(65535).default(993),
secureMode: z.enum(['tls', 'starttls']).default('tls'), username: z.string().trim().max(200).nullable(),
password: z.string().max(500).nullable().optional(), folder: z.string().trim().min(1).max(200).default('INBOX'), enabled: z.boolean().default(false),
}).parse(req.body);
const sets = ['host = ?', 'port = ?', 'secure_mode = ?', 'username = ?', 'folder = ?', 'enabled = ?', 'updated_by = ?'];
const params: unknown[] = [b.host, b.port, b.secureMode, b.username, b.folder, b.enabled ? 1 : 0, a.user.id];
if (b.password !== undefined) { sets.push('secrets_enc = ?'); params.push(b.password ? encrypt(JSON.stringify({ password: b.password })) : null); }
// Verbindungsdaten geändert: nächster Abruf beginnt wieder beim aktuellen Postfachstand (kein Bestand wird nachverarbeitet)
if (b.host || b.username || b.password) { sets.push('last_uid = NULL'); }
await run(`UPDATE imap_settings SET ${sets.join(', ')} WHERE id = 1`, params);
await audit({ actorType: 'user', actorId: a.user.id, action: 'email.imap.update', resourceType: 'imap_settings', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { ...b, password: b.password !== undefined ? (b.password ? '***' : null) : undefined } });
return { ok: true };
});
app.post('/admin/email/imap-settings/test', { config: rl(5, '1 minute') }, async (req) => {
const a = requirePermission(req, 'email.write');
const s = await one('SELECT * FROM imap_settings WHERE id = 1');
if (!s?.host || !s.username || !s.secrets_enc) throw badRequest('Bitte zuerst Host, Benutzername und Passwort speichern.', 'IMAP_NOT_CONFIGURED');
const password = JSON.parse(decrypt(s.secrets_enc)).password as string;
const client = new ImapFlow({ host: s.host, port: s.port, secure: s.secure_mode === 'tls', auth: { user: s.username, pass: password }, logger: false });
let ok = false; let error: string | null = null; let count: number | null = null;
try {
await client.connect();
const lock = await client.getMailboxLock(s.folder || 'INBOX');
try { count = (client.mailbox as { exists: number }).exists; } finally { lock.release(); }
await client.logout();
ok = true;
} catch (e) { error = String((e as Error).message).slice(0, 300); try { client.close(); } catch { /* egal */ } }
await audit({ actorType: 'user', actorId: a.user.id, action: 'email.imap.test', resourceType: 'imap_settings', resourceId: '1', result: ok ? 'success' : 'failure', correlationId: req.correlationId, ip: clientIp(req), after: { ok, error } });
return { ok, error, count };
});
},
};

View file

@ -187,5 +187,56 @@ export const ticketsModule: KcModule = {
await audit({ actorType: 'user', actorId: a.user.id, orgId: t.org_id, action: 'ticket.close', resourceType: 'ticket', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { ok: true };
});
// ---- Posteingang: eingehende Mails, die der IMAP-Abruf keinem Ticket zuordnen konnte -----------
const inboxView = (m: any) => ({ id: m.id, fromEmail: m.from_email, fromName: m.from_name, subject: m.subject, bodyText: m.body_text, receivedAt: m.received_at, status: m.status, ticketId: m.ticket_id, ticketNumber: m.ticket_number ?? null });
app.get('/admin/inbox', async (req) => {
requirePermission(req, 'tickets.read');
const q = z.object({ status: z.enum(['unmatched', 'matched', 'created', 'ignored']).optional() }).parse(req.query);
const rows = await query('SELECT im.*, t.number AS ticket_number FROM inbox_messages im LEFT JOIN tickets t ON t.id = im.ticket_id WHERE (? IS NULL OR im.status = ?) ORDER BY im.received_at DESC LIMIT 200', [q.status ?? null, q.status ?? null]);
return rows.map(inboxView);
});
/** Unbekannter Absender wird einem Kunden zugeordnet: neues Ticket, Autor ist der (erste) Ansprechpartner des Kunden. */
app.post('/admin/inbox/:id/assign', async (req) => {
const a = requirePermission(req, 'tickets.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ orgId: z.string().uuid() }).parse(req.body);
const msg = await one('SELECT * FROM inbox_messages WHERE id = ?', [id]); if (!msg) throw notFound();
if (msg.status !== 'unmatched') throw conflict('Diese Nachricht wurde bereits bearbeitet.', 'ALREADY_HANDLED');
const owner = await one("SELECT user_id FROM memberships WHERE org_id = ? ORDER BY (role = 'owner') DESC LIMIT 1", [b.orgId]);
if (!owner) throw badRequest('Für diesen Kunden ist kein Ansprechpartner hinterlegt.', 'NO_MEMBER');
const ticketId = randomUUID();
const number = await tx(async (c) => {
const n = await nextNumber(c);
await run('INSERT INTO tickets (id, number, org_id, subject, status, priority, created_by) VALUES (?,?,?,?,?,?,?)', [ticketId, n, b.orgId, msg.subject || '(kein Betreff)', 'pending_staff', 'normal', owner.user_id], c);
await run('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [randomUUID(), ticketId, owner.user_id, 'customer', msg.body_text || '(kein Text)'], c);
return n;
});
await run("UPDATE inbox_messages SET status = 'created', ticket_id = ? WHERE id = ?", [ticketId, id]);
await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId, action: 'inbox.assign', resourceType: 'inbox_message', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { ticketId, number } });
await notify('ticket.created', { number, subject: (msg.subject || '').slice(0, 100) }, `mail-in-assign:${id}`, req.correlationId);
return { ticketId, number };
});
/** Nachricht wird als weitere Antwort an ein bestehendes Ticket angehängt (Autor: der Ersteller des Tickets). */
app.post('/admin/inbox/:id/link', async (req) => {
const a = requirePermission(req, 'tickets.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({ ticketNumber: z.string().trim().min(1).max(20) }).parse(req.body);
const msg = await one('SELECT * FROM inbox_messages WHERE id = ?', [id]); if (!msg) throw notFound();
if (msg.status !== 'unmatched') throw conflict('Diese Nachricht wurde bereits bearbeitet.', 'ALREADY_HANDLED');
const t = await one('SELECT * FROM tickets WHERE number = ?', [b.ticketNumber]); if (!t) throw badRequest('Ticket nicht gefunden.', 'TICKET_NOT_FOUND');
await run('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [randomUUID(), t.id, t.created_by, 'customer', msg.body_text || '(kein Text)']);
await run("UPDATE tickets SET status = 'pending_staff', last_message_at = UTC_TIMESTAMP(3), resolved_at = NULL, closed_at = NULL WHERE id = ?", [t.id]);
await run("UPDATE inbox_messages SET status = 'matched', ticket_id = ? WHERE id = ?", [t.id, id]);
await audit({ actorType: 'user', actorId: a.user.id, orgId: t.org_id, action: 'inbox.link', resourceType: 'inbox_message', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { ticketId: t.id } });
await notify('ticket.message', { number: t.number }, `mail-in-link:${id}`, req.correlationId);
return { ok: true };
});
app.post('/admin/inbox/:id/ignore', async (req) => {
const a = requirePermission(req, 'tickets.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const msg = await one('SELECT status FROM inbox_messages WHERE id = ?', [id]); if (!msg) throw notFound();
if (msg.status !== 'unmatched') throw conflict('Diese Nachricht wurde bereits bearbeitet.', 'ALREADY_HANDLED');
await run("UPDATE inbox_messages SET status = 'ignored' WHERE id = ?", [id]);
await audit({ actorType: 'user', actorId: a.user.id, action: 'inbox.ignore', resourceType: 'inbox_message', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) });
return { ok: true };
});
},
};

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

View file

@ -14,10 +14,13 @@
"@kc/connectors": "workspace:*",
"@kc/platform": "workspace:*",
"discord.js": "^14.27.0",
"imapflow": "^1.0.191",
"mailparser": "^3.7.4",
"mysql2": "^3.24.4",
"zod": "^4.6.5"
},
"devDependencies": {
"@types/mailparser": "^3.4.6",
"@types/node": "^26.6.3",
"tsx": "^4.23.15",
"typescript": "^7.0.2",

View file

@ -5,6 +5,7 @@ import { discordEnabled, discordReady, startDiscord, stopDiscord } from './disco
import { recoverStale, runOnce } from './jobs.js';
import { enqueue } from '@kc/platform/jobs';
import { processContractLifecycle, scheduleDueSyncs } from '@kc/connectors';
import { checkMailbox } from './mailbox.js';
const log = (m: string) => console.log(JSON.stringify({ t: new Date().toISOString(), svc: 'worker', msg: m }));
let lastLoop = Date.now();
@ -40,6 +41,10 @@ const checkBackup = async () => {
};
setInterval(() => void checkBackup(), 3600_000);
// Ticket-Posteingang: eingehende Mails abrufen und zuordnen (siehe mailbox.ts)
setInterval(() => void checkMailbox(log).catch((e) => log(`IMAP-Lauf fehlgeschlagen: ${(e as Error).message}`)), 120_000);
void checkMailbox(log).catch((e) => log(`IMAP-Lauf fehlgeschlagen: ${(e as Error).message}`));
for (const sig of ['SIGTERM', 'SIGINT'] as const) process.on(sig, async () => { stopping = true; await stopDiscord(); await pool.end(); process.exit(0); });
log('Worker gestartet');
while (!stopping) {

120
apps/worker/src/mailbox.ts Normal file
View file

@ -0,0 +1,120 @@
import { randomUUID } from 'node:crypto';
import { ImapFlow } from 'imapflow';
import { simpleParser } from 'mailparser';
import { pool } from '@kc/platform/db';
import { decrypt } from '@kc/platform/crypto';
import { enqueue } from '@kc/platform/jobs';
/** Sehr einfache HTML->Text-Reduktion für Mails ohne Text-Teil. Nur zur Anzeige, nie als HTML gerendert. */
function htmlToText(html: string): string {
return html.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, ' ').replace(/<br\s*\/?>/gi, '\n').replace(/<\/p>/gi, '\n\n').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
}
async function nextTicketNumber(): Promise<string> {
await pool.query("UPDATE number_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE name = 'ticket'");
const [r] = await pool.query('SELECT LAST_INSERT_ID() AS n') as any;
return `T-${r[0].n}`;
}
const notify = (event: string, extra: Record<string, unknown>, key: string) => enqueue('discord.notify', { event, ...extra }, { idempotencyKey: key });
/**
* Ruft das konfigurierte IMAP-Postfach ab und ordnet neue Mails Tickets zu:
* 1. Betreff enthält eine Ticketnummer (T-123) UND Absender gehört zum Kunden dieses Tickets → als Nachricht angehängt.
* 2. Sonst, wenn der Absender ein bekannter aktiver Kunde ist → neues Ticket unter seinem Kundenkonto.
* 3. Sonst → landet unzugeordnet im Posteingang, Personal weist manuell zu (siehe /admin/inbox).
* Beim ersten Aktivieren (last_uid ist NULL) wird kein Bestand verarbeitet, nur ab dann neu Eingehendes.
*/
export async function checkMailbox(log: (m: string) => void): Promise<void> {
const [rows] = await pool.query('SELECT * FROM imap_settings WHERE id = 1') as any;
const s = rows[0];
if (!s?.enabled || !s?.host || !s?.username || !s?.secrets_enc) return;
const password = JSON.parse(decrypt(s.secrets_enc)).password as string;
const client = new ImapFlow({ host: s.host, port: s.port, secure: s.secure_mode === 'tls', auth: { user: s.username, pass: password }, logger: false });
try {
await client.connect();
const lock = await client.getMailboxLock(s.folder || 'INBOX');
try {
const mailbox = client.mailbox as { uidNext: number };
if (s.last_uid == null) {
await pool.query('UPDATE imap_settings SET last_uid = ?, last_checked_at = UTC_TIMESTAMP(3), last_error = NULL WHERE id = 1', [Math.max(0, mailbox.uidNext - 1)]);
log('IMAP: Erstlauf, Bestand wird nicht verarbeitet (nur neu Eingehendes ab jetzt).');
return;
}
let maxUid = s.last_uid;
for await (const msg of client.fetch(`${s.last_uid + 1}:*`, { source: true }, { uid: true })) {
if (msg.uid <= s.last_uid) continue; // Server liefert bei leerem Bereich mitunter die letzte bekannte Nachricht
maxUid = Math.max(maxUid, msg.uid);
try { await processMessage(msg.uid, msg.source as Buffer, log); }
catch (e) { log(`IMAP: Nachricht UID ${msg.uid} konnte nicht verarbeitet werden: ${(e as Error).message}`); }
}
await pool.query('UPDATE imap_settings SET last_uid = ?, last_checked_at = UTC_TIMESTAMP(3), last_error = NULL WHERE id = 1', [maxUid]);
} finally { lock.release(); }
await client.logout();
} catch (e) {
const msg = String((e as Error).message).slice(0, 500);
await pool.query('UPDATE imap_settings SET last_checked_at = UTC_TIMESTAMP(3), last_error = ? WHERE id = 1', [msg]);
log(`IMAP-Abruf fehlgeschlagen: ${msg}`);
try { client.close(); } catch { /* Verbindung ohnehin gestört */ }
}
}
const TICKET_NUMBER = /\bT-(\d+)\b/i;
async function processMessage(uid: number, source: Buffer, log: (m: string) => void): Promise<void> {
const parsed = await simpleParser(source);
const fromAddr = parsed.from?.value?.[0];
const fromEmail = (fromAddr?.address ?? '').toLowerCase().slice(0, 254);
if (!fromEmail) { log(`IMAP: Nachricht UID ${uid} ohne lesbare Absenderadresse, übersprungen.`); return; }
const fromName = (fromAddr?.name ?? '').slice(0, 200) || null;
const subject = (parsed.subject ?? '').slice(0, 500) || null;
const bodyText = (parsed.text ?? (parsed.html ? htmlToText(parsed.html as string) : '')).slice(0, 20000);
const receivedAt = parsed.date ?? new Date();
const messageId = (parsed.messageId ?? '').slice(0, 998) || null;
const id = randomUUID();
// 1) Ticketnummer im Betreff + Absender gehört zum Kunden dieses Tickets
const m = subject ? TICKET_NUMBER.exec(subject) : null;
if (m) {
const [tRows] = await pool.query('SELECT * FROM tickets WHERE number = ?', [`T-${m[1]}`]) as any;
const ticket = tRows[0];
if (ticket) {
const [uRows] = await pool.query("SELECT id FROM users WHERE email = ? AND status = 'active'", [fromEmail]) as any;
const user = uRows[0];
if (user) {
const [memRows] = await pool.query('SELECT 1 AS x FROM memberships WHERE user_id = ? AND org_id = ?', [user.id, ticket.org_id]) as any;
if (memRows[0]) {
await pool.query('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [randomUUID(), ticket.id, user.id, 'customer', bodyText || '(kein Text)']);
await pool.query("UPDATE tickets SET status = 'pending_staff', last_message_at = UTC_TIMESTAMP(3), resolved_at = NULL, closed_at = NULL WHERE id = ?", [ticket.id]);
await pool.query('INSERT INTO inbox_messages (id, uid, message_id, from_email, from_name, subject, body_text, received_at, status, ticket_id, matched_user_id) VALUES (?,?,?,?,?,?,?,?,?,?,?)',
[id, uid, messageId, fromEmail, fromName, subject, bodyText, receivedAt, 'matched', ticket.id, user.id]);
await notify('ticket.message', { number: ticket.number }, `mail-in:${id}`);
log(`IMAP: Nachricht von ${fromEmail} zu Ticket ${ticket.number} hinzugefügt.`);
return;
}
}
}
}
// 2) Bekannter aktiver Kunde ohne (verifizierten) Ticketbezug → neues Ticket
const [uRows2] = await pool.query("SELECT id FROM users WHERE email = ? AND status = 'active' AND kind = 'customer'", [fromEmail]) as any;
const user2 = uRows2[0];
if (user2) {
const [memRows2] = await pool.query("SELECT org_id FROM memberships WHERE user_id = ? ORDER BY (role = 'owner') DESC LIMIT 1", [user2.id]) as any;
const org = memRows2[0];
if (org) {
const ticketId = randomUUID();
const number = await nextTicketNumber();
await pool.query('INSERT INTO tickets (id, number, org_id, subject, status, priority, created_by) VALUES (?,?,?,?,?,?,?)',
[ticketId, number, org.org_id, subject || '(kein Betreff)', 'pending_staff', 'normal', user2.id]);
await pool.query('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body) VALUES (?,?,?,?,?)', [randomUUID(), ticketId, user2.id, 'customer', bodyText || '(kein Text)']);
await pool.query('INSERT INTO inbox_messages (id, uid, message_id, from_email, from_name, subject, body_text, received_at, status, ticket_id, matched_user_id) VALUES (?,?,?,?,?,?,?,?,?,?,?)',
[id, uid, messageId, fromEmail, fromName, subject, bodyText, receivedAt, 'created', ticketId, user2.id]);
await notify('ticket.created', { number, subject: (subject || '').slice(0, 100) }, `mail-in:${id}`);
log(`IMAP: Neues Ticket ${number} aus Mail von ${fromEmail} angelegt.`);
return;
}
}
// 3) Unbekannter oder nicht zuordenbarer Absender → Posteingang zur manuellen Zuordnung
await pool.query('INSERT INTO inbox_messages (id, uid, message_id, from_email, from_name, subject, body_text, received_at, status) VALUES (?,?,?,?,?,?,?,?,?)',
[id, uid, messageId, fromEmail, fromName, subject, bodyText, receivedAt, 'unmatched']);
log(`IMAP: Nachricht von ${fromEmail} konnte niemandem zugeordnet werden, liegt im Posteingang.`);
}

View file

@ -0,0 +1,37 @@
-- IMAP-Postfach für das Ticketsystem: eingehende Mails werden abgerufen, bekannten Tickets/Kunden
-- zugeordnet oder als neues Ticket angelegt; unbekannte Absender landen zur manuellen Zuordnung
-- im Posteingang (siehe inbox_messages).
CREATE TABLE imap_settings (
id TINYINT PRIMARY KEY DEFAULT 1,
host VARCHAR(253) NULL,
port SMALLINT UNSIGNED NOT NULL DEFAULT 993,
secure_mode ENUM('tls','starttls') NOT NULL DEFAULT 'tls',
username VARCHAR(200) NULL,
secrets_enc TEXT NULL,
folder VARCHAR(200) NOT NULL DEFAULT 'INBOX',
enabled TINYINT(1) NOT NULL DEFAULT 0,
last_uid BIGINT UNSIGNED NULL,
last_checked_at DATETIME(3) NULL,
last_error VARCHAR(500) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_by CHAR(36) NULL REFERENCES users(id),
CONSTRAINT chk_imap_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO imap_settings (id) VALUES (1);
CREATE TABLE inbox_messages (
id CHAR(36) PRIMARY KEY,
uid BIGINT UNSIGNED NOT NULL,
message_id VARCHAR(998) NULL,
from_email VARCHAR(254) NOT NULL,
from_name VARCHAR(200) NULL,
subject VARCHAR(500) NULL,
body_text MEDIUMTEXT NULL,
received_at DATETIME(3) NOT NULL,
status ENUM('matched','created','unmatched','ignored') NOT NULL DEFAULT 'unmatched',
ticket_id CHAR(36) NULL REFERENCES tickets(id),
matched_user_id CHAR(36) NULL REFERENCES users(id),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_inbox_uid (uid),
KEY idx_inbox_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

277
pnpm-lock.yaml generated
View file

@ -37,6 +37,9 @@ importers:
fastify:
specifier: ^5.12.5
version: 5.12.5
imapflow:
specifier: ^1.0.191
version: 1.7.8
mysql2:
specifier: ^3.24.4
version: 3.24.4(@types/node@26.6.3)
@ -111,6 +114,12 @@ importers:
discord.js:
specifier: ^14.27.0
version: 14.27.0
imapflow:
specifier: ^1.0.191
version: 1.7.8
mailparser:
specifier: ^3.7.4
version: 3.9.30
mysql2:
specifier: ^3.24.4
version: 3.24.4(@types/node@26.6.3)
@ -118,6 +127,9 @@ importers:
specifier: ^4.6.5
version: 4.6.5
devDependencies:
'@types/mailparser':
specifier: ^3.4.6
version: 3.4.6
'@types/node':
specifier: ^26.6.3
version: 26.6.3
@ -882,6 +894,11 @@ packages:
resolution: {integrity: sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==}
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
'@selderee/plugin-htmlparser2@0.12.0':
resolution: {integrity: sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==}
peerDependencies:
selderee: ~0.12.0
'@swc/helpers@0.5.23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
@ -894,6 +911,9 @@ packages:
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/mailparser@3.4.6':
resolution: {integrity: sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==}
'@types/node@26.6.3':
resolution: {integrity: sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==}
@ -1055,6 +1075,12 @@ packages:
resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==}
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
'@zone-eu/mailsplit@5.4.16':
resolution: {integrity: sha512-zQ9iXvlT3Wi/hazeC1MdI4rQc1UJwJ6IQ6QzSZ5KDxLZZWQSazWLOzImLFluXadKShJ9WJvI1xH+AyVS8b9azg==}
'@zone-eu/mailsplit@5.4.18':
resolution: {integrity: sha512-ufNquC1VWilkF6IUyy7+qYA19Q1JXNkeiiSIgS9+f7ojNs5ijHLg03FUcXDCR2tDDJ51zSoPNTsUu4DQz613KQ==}
abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
@ -1150,6 +1176,10 @@ packages:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
deepmerge-ts@8.0.2:
resolution: {integrity: sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==}
engines: {node: '>=16.9.0'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@ -1171,9 +1201,38 @@ packages:
resolution: {integrity: sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A==}
engines: {node: '>=18'}
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
encoding-japanese@2.3.0:
resolution: {integrity: sha512-eQyh1vzHz13DUkZcJO+0IOAoKXRQwKV5IBffeuYsWZyRLGiSzfzXObCqWvqFXdX0UU8qOk+lBXbkUhMCpdJe4Q==}
engines: {node: '>=18.0.0'}
encoding-japanese@2.4.0:
resolution: {integrity: sha512-PE0KjlsDy9JSsoU4C2Q8NXb1kC4c+bZRac9ZvHXUE4q1Ht63gScKMdEy9jst9+Fi3M4eDkqzkEhBZvix4blP2A==}
engines: {node: '>=18.0.0'}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
es-module-lexer@2.3.2:
resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
@ -1251,14 +1310,32 @@ packages:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
helmet@8.3.0:
resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==}
engines: {node: '>=18.0.0'}
html-to-text@10.0.1:
resolution: {integrity: sha512-GiVhRI1BatGARSCmlXWNCjDT0cWrwBWoeduLoV0WSKAgaV/wa+hUWy5LiQLUs4UwiUrE52ZCMfBGiKD87TDPrg==}
engines: {node: '>=20.19.0'}
htmlparser2@10.1.0:
resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
iconv-lite@0.7.3:
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
engines: {node: '>=0.10.0'}
imapflow@1.7.8:
resolution: {integrity: sha512-dJoCIdZOJh26Rn2PdwEzwj0bRDgGBxxX38pio534FagIHVuR2l0SAfLr6sJo32YfuJHiSs/U2ntNDHnMg3/Hlg==}
ip-address@10.7.2:
resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==}
engines: {node: '>= 12'}
@ -1280,6 +1357,21 @@ packages:
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
leac@0.7.0:
resolution: {integrity: sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==}
libbase64@1.3.0:
resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==}
libmime@5.4.3:
resolution: {integrity: sha512-di9BoDabBUMqjeD/wGj+hHpSgdqAph5ui7w6OdY6NpzU6O6VFLQsMOg9tqCjm/zf9OHzAM9EZxSOF7uIb8O8Hw==}
libmime@5.4.5:
resolution: {integrity: sha512-5xZSF0j71GqXMEolQAnS/gOD4nHrAhf9nPWdqvfIXXeFODx0T4BlO9XudlXe9AgDKAoOkR2YeFagQ1Dwy1v6RQ==}
libqp@2.1.1:
resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==}
light-my-request@6.6.0:
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
@ -1356,6 +1448,9 @@ packages:
linebreak@1.1.0:
resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
linkify-it@5.0.2:
resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
@ -1379,6 +1474,10 @@ packages:
magic-string@1.4.2:
resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==}
mailparser@3.9.30:
resolution: {integrity: sha512-jtLxunaB3fxrX4x0scARwHd9Ctj3uWKt/Ps3Ge+DUnfdHf8LtQMF8ENUpptprxde+mwfGW0PSanb4hMfyE/xxg==}
engines: {node: '>=20.0.0'}
mysql2@3.24.4:
resolution: {integrity: sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==}
engines: {node: '>= 8.0'}
@ -1419,6 +1518,10 @@ packages:
resolution: {integrity: sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==}
engines: {node: '>=20.0.0'}
nodemailer@10.0.12:
resolution: {integrity: sha512-PQ46oNbNMuH/Sno7B5IWIU9etytwrO0xdTFvWXpIQFRXhBAXtzD48243AS+BCbQQTPD8y3fqUrgLn0tSo77gvg==}
engines: {node: '>=20.0.0'}
obug@2.2.1:
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
engines: {node: '>=12.20.0'}
@ -1445,6 +1548,9 @@ packages:
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
parseley@0.13.1:
resolution: {integrity: sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@ -1452,6 +1558,9 @@ packages:
pdfkit@0.20.2:
resolution: {integrity: sha512-Q/w03ICAQyXfHNfTsg1udp0ADerdBN0s7a6XSPL7J7Ro6ABnafoBOCDBstIO9U02mvzHEV8HrvFIw5iV5ejIRA==}
peberminta@0.10.0:
resolution: {integrity: sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -1490,6 +1599,10 @@ packages:
process-warning@5.1.0:
resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
@ -1561,6 +1674,9 @@ packages:
secure-json-parse@4.1.0:
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
selderee@0.12.0:
resolution: {integrity: sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==}
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
@ -1581,6 +1697,14 @@ packages:
'@types/node':
optional: true
smart-buffer@4.2.0:
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
socks@2.8.9:
resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
sonic-boom@4.2.1:
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
@ -1639,6 +1763,10 @@ packages:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
tlds@1.261.0:
resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==}
hasBin: true
toad-cache@3.7.4:
resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==}
engines: {node: '>=20'}
@ -1659,6 +1787,9 @@ packages:
engines: {node: '>=16.20.0'}
hasBin: true
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
undici-types@8.9.0:
resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
@ -2249,6 +2380,12 @@ snapshots:
'@sapphire/snowflake@3.5.5': {}
'@selderee/plugin-htmlparser2@0.12.0(selderee@0.12.0)':
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
selderee: 0.12.0
'@swc/helpers@0.5.23':
dependencies:
tslib: 2.8.1
@ -2262,6 +2399,11 @@ snapshots:
'@types/estree@1.0.9': {}
'@types/mailparser@3.4.6':
dependencies:
'@types/node': 26.6.3
iconv-lite: 0.6.3
'@types/node@26.6.3':
dependencies:
undici-types: 8.9.0
@ -2363,6 +2505,18 @@ snapshots:
'@vladfrangu/async_event_emitter@2.4.7': {}
'@zone-eu/mailsplit@5.4.16':
dependencies:
libbase64: 1.3.0
libmime: 5.4.3
libqp: 2.1.1
'@zone-eu/mailsplit@5.4.18':
dependencies:
libbase64: 1.3.0
libmime: 5.4.5
libqp: 2.1.1
abstract-logging@2.0.1: {}
ajv-formats@3.0.1(ajv@8.20.0):
@ -2433,6 +2587,8 @@ snapshots:
decamelize@1.2.0: {}
deepmerge-ts@8.0.2: {}
dequal@2.0.3: {}
detect-libc@2.1.2: {}
@ -2462,8 +2618,34 @@ snapshots:
- bufferutil
- utf-8-validate
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.5.0
domelementtype@2.3.0: {}
domhandler@5.0.3:
dependencies:
domelementtype: 2.3.0
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
emoji-regex@8.0.0: {}
encoding-japanese@2.3.0: {}
encoding-japanese@2.4.0: {}
entities@4.5.0: {}
entities@7.0.1: {}
es-module-lexer@2.3.2: {}
esbuild@0.28.2:
@ -2584,12 +2766,44 @@ snapshots:
get-caller-file@2.0.5: {}
he@1.2.0: {}
helmet@8.3.0: {}
html-to-text@10.0.1:
dependencies:
'@selderee/plugin-htmlparser2': 0.12.0(selderee@0.12.0)
deepmerge-ts: 8.0.2
dom-serializer: 2.0.0
htmlparser2: 10.1.0
selderee: 0.12.0
htmlparser2@10.1.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 7.0.1
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
iconv-lite@0.7.3:
dependencies:
safer-buffer: 2.1.2
imapflow@1.7.8:
dependencies:
'@zone-eu/mailsplit': 5.4.16
encoding-japanese: 2.3.0
iconv-lite: 0.7.3
libbase64: 1.3.0
libmime: 5.4.3
libqp: 2.1.1
pino: 10.3.1
socks: 2.8.9
ip-address@10.7.2: {}
ipaddr.js@2.5.0: {}
@ -2604,6 +2818,26 @@ snapshots:
json-schema-traverse@1.0.0: {}
leac@0.7.0: {}
libbase64@1.3.0: {}
libmime@5.4.3:
dependencies:
encoding-japanese: 2.3.0
iconv-lite: 0.7.3
libbase64: 1.3.0
libqp: 2.1.1
libmime@5.4.5:
dependencies:
encoding-japanese: 2.4.0
iconv-lite: 0.7.3
libbase64: 1.3.0
libqp: 2.1.1
libqp@2.1.1: {}
light-my-request@6.6.0:
dependencies:
cookie: 1.1.1
@ -2664,6 +2898,10 @@ snapshots:
base64-js: 0.0.8
unicode-trie: 2.0.0
linkify-it@5.0.2:
dependencies:
uc.micro: 2.1.0
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
@ -2682,6 +2920,19 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
mailparser@3.9.30:
dependencies:
'@zone-eu/mailsplit': 5.4.18
encoding-japanese: 2.4.0
he: 1.2.0
html-to-text: 10.0.1
iconv-lite: 0.7.3
libmime: 5.4.5
linkify-it: 5.0.2
nodemailer: 10.0.12
punycode.js: 2.3.1
tlds: 1.261.0
mysql2@3.24.4(@types/node@26.6.3):
dependencies:
'@types/node': 26.6.3
@ -2726,6 +2977,8 @@ snapshots:
nodemailer@10.0.10: {}
nodemailer@10.0.12: {}
obug@2.2.1: {}
on-exit-leak-free@2.1.2: {}
@ -2746,6 +2999,11 @@ snapshots:
pako@0.2.9: {}
parseley@0.13.1:
dependencies:
leac: 0.7.0
peberminta: 0.10.0
path-exists@4.0.0: {}
pdfkit@0.20.2:
@ -2757,6 +3015,8 @@ snapshots:
linebreak: 1.1.0
png-js: 2.0.0
peberminta@0.10.0: {}
picocolors@1.1.1: {}
picomatch@4.0.7: {}
@ -2803,6 +3063,8 @@ snapshots:
process-warning@5.1.0: {}
punycode.js@2.3.1: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
@ -2869,6 +3131,10 @@ snapshots:
secure-json-parse@4.1.0: {}
selderee@0.12.0:
dependencies:
parseley: 0.13.1
semver@7.8.5: {}
set-blocking@2.0.0: {}
@ -2909,6 +3175,13 @@ snapshots:
'@types/node': 26.6.3
optional: true
smart-buffer@4.2.0: {}
socks@2.8.9:
dependencies:
ip-address: 10.7.2
smart-buffer: 4.2.0
sonic-boom@4.2.1:
dependencies:
atomic-sleep: 1.0.0
@ -2951,6 +3224,8 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.7)
picomatch: 4.0.7
tlds@1.261.0: {}
toad-cache@3.7.4: {}
ts-mixer@6.0.4: {}
@ -2986,6 +3261,8 @@ snapshots:
'@typescript/typescript-win32-arm64': 7.0.2
'@typescript/typescript-win32-x64': 7.0.2
uc.micro@2.1.0: {}
undici-types@8.9.0: {}
undici@6.29.0: {}