diff --git a/apps/api/package.json b/apps/api/package.json index 1f3ea95..aca9bab 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/modules/mail/index.ts b/apps/api/src/modules/mail/index.ts index 1bc006c..d44a4cf 100644 --- a/apps/api/src/modules/mail/index.ts +++ b/apps/api/src/modules/mail/index.ts @@ -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 }; + }); }, }; diff --git a/apps/api/src/modules/tickets/index.ts b/apps/api/src/modules/tickets/index.ts index efa10d8..e843c55 100644 --- a/apps/api/src/modules/tickets/index.ts +++ b/apps/api/src/modules/tickets/index.ts @@ -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 }; + }); }, }; diff --git a/apps/web/src/app/(app)/admin/posteingang/page.tsx b/apps/web/src/app/(app)/admin/posteingang/page.tsx new file mode 100644 index 0000000..0345e12 --- /dev/null +++ b/apps/web/src/app/(app)/admin/posteingang/page.tsx @@ -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 = { 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 (
+
{m.subject || '(kein Betreff)'}

Von: {m.fromName ? `${m.fromName} <${m.fromEmail}>` : m.fromEmail} · {fmt(m.receivedAt)}

+
+ {open &&
{m.bodyText || '(kein Text)'}
} +
+
+ + +
+
+ setTicketNumber(e.target.value)} style={{ width: 100 }} /> + +
+ +
+
); +} + +export default function Posteingang() { + const [status, setStatus] = useState<'unmatched' | 'matched' | 'created' | 'ignored' | ''>('unmatched'); + const [list, setList] = useState(null); const [custs, setCusts] = useState([]); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); + const load = useCallback(() => api('GET', `/admin/inbox${status ? `?status=${status}` : ''}`).then(setList).catch((e) => setErr(errMsg(e))), [status]); + useEffect(() => { void load(); }, [load]); + useEffect(() => { api('GET', '/admin/customers').then(setCusts).catch(() => undefined); }, []); + return (<> +

Posteingang

+

Mails aus dem konfigurierten Ticket-Postfach, die nicht automatisch zugeordnet werden konnten (siehe Einstellungen > E-Mail > IMAP-Posteingang).

+ {err && {err}} + {ok && {ok}} +
+ {(['unmatched', 'matched', 'created', 'ignored', ''] as const).map((s) => )} +
+ {list === null ?

Wird geladen …

: list.length === 0 ?
{status === 'unmatched' ? 'Aktuell liegen keine unzugeordneten Mails vor.' : 'Für diesen Filter gibt es keine Einträge.'}
: + list.map((m) => m.status === 'unmatched' + ? { setOk(t); setErr(''); void load(); }} onError={setErr} /> + :
{m.subject || '(kein Betreff)'}

Von: {m.fromName ? `${m.fromName} <${m.fromEmail}>` : m.fromEmail} · {fmt(m.receivedAt)}

+
{STATUS_LABEL[m.status]}{m.ticketNumber && {m.ticketNumber} ansehen}
)} + ); +} diff --git a/apps/web/src/app/(app)/einstellungen/email/page.tsx b/apps/web/src/app/(app)/einstellungen/email/page.tsx index 12f94fd..908a620 100644 --- a/apps/web/src/app/(app)/einstellungen/email/page.tsx +++ b/apps/web/src/app/(app)/einstellungen/email/page.tsx @@ -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 = { none: 'Keine Verschlüsselung', starttls: 'STARTTLS (empfohlen, meist Port 587)', tls: 'TLS/SSL direkt (meist Port 465)' }; +const IMAP_SECURE_LABEL: Record = { starttls: 'STARTTLS (meist Port 143)', tls: 'TLS/SSL direkt (meist Port 993)' }; const CATEGORY_LABEL: Record = { kundencenter: 'Kundencenter', stoerungsticket: 'Störungsticket', rechnungen: 'Rechnungen' }; const STATUS: Record = { 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 ); } +/** 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) { + 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 (
+

IMAP-Posteingang (Ticketsystem)

+

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 Posteingang.

+ {!s.configured && Noch nicht eingerichtet.} + {s.configured && !s.enabled && Eingerichtet, aber deaktiviert – es werden keine Mails abgerufen.} + {s.lastError && Letzter Abruf fehlgeschlagen: {s.lastError}} + {msg && {msg.t}} +
+
+ + + + + + +
+ + {w &&
{s.configured && }
} +
+

Letzter Abruf: {s.lastCheckedAt ? fmt(s.lastCheckedAt) : 'noch nie'}

+
); +} + /** 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(null); const [senders, setSenders] = useState(null); const [tpls, setTpls] = useState(null); const [log, setLog] = useState(null); const [err, setErr] = useState(''); + const [s, setS] = useState(null); const [senders, setSenders] = useState(null); const [tpls, setTpls] = useState(null); const [log, setLog] = useState(null); const [imap, setImap] = useState(null); const [err, setErr] = useState(''); const load = useCallback(() => { api('GET', '/admin/email/settings').then(setS).catch((e) => setErr(errMsg(e))); api('GET', '/admin/email/senders').then(setSenders).catch((e) => setErr(errMsg(e))); api('GET', '/admin/email/templates').then(setTpls).catch((e) => setErr(errMsg(e))); api('GET', '/admin/email/log').then(setLog).catch(() => undefined); + api('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 ? Wird geladen … : log.length === 0 ? Noch keine Einträge. : log.map((l) => {fmt(l.createdAt)}{l.to}{l.template}{l.error &&
{l.error}
})} +
{imap ? :

Wird geladen …

}
); } diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index 216ab14..5ba1d19 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -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]) => {link(h, l)}))} {!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') && {link('/admin/posteingang', 'Posteingang')}})} {!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]) => {link(h, l)}))} diff --git a/apps/worker/package.json b/apps/worker/package.json index 7cd489b..32ecd2e 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -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", diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 7343ad6..131a934 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -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) { diff --git a/apps/worker/src/mailbox.ts b/apps/worker/src/mailbox.ts new file mode 100644 index 0000000..dd9af0d --- /dev/null +++ b/apps/worker/src/mailbox.ts @@ -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(//gi, '\n').replace(/<\/p>/gi, '\n\n').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim(); +} +async function nextTicketNumber(): Promise { + 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, 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 { + 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 { + 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.`); +} diff --git a/migrations/024_imap_inbox.sql b/migrations/024_imap_inbox.sql new file mode 100644 index 0000000..8b9bf38 --- /dev/null +++ b/migrations/024_imap_inbox.sql @@ -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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4d1acb..a22af30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}