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

@ -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.`);
}