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:
parent
7d2b2947f4
commit
fe18bf0bce
11 changed files with 651 additions and 3 deletions
|
|
@ -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 };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue