import "dotenv/config"; import { parseInbound, type RawHeaders } from "../src/server/incident-inbound/parse"; import { processInbound } from "../src/server/incident-inbound/process"; /** * IM-D — Einstiegspunkt des Inbound-Mail-Workers (`npm run worker:incident-inbound`). * * Eigener Prozess/Container neben der App (analog Mail-/Backup-Worker). Holt Mails * vom Catch-all-Postfach (`vorfall-@in.certvia.de`, KONZEPT §2/§14.3) per * IMAP ab, parst sie (rein, parse.ts) und verarbeitet sie (process.ts): Vorfall im * richtigen Mandanten anlegen ODER in die Betreiber-Review geben. * * IMAP-Bibliotheken (imapflow/mailparser) werden BEWUSST dynamisch importiert: * - der Web-/Build-Pfad zieht sie nie mit ein (reiner Ops-Prozess), * - `tsc --noEmit` bleibt unabhängig von den Paket-Typdefinitionen grün. * * Ohne IMAP-Env (INCIDENT_IMAP_HOST/USER/PASSWORD) gibt es keinen Betrieb — der * Worker meldet „nicht konfiguriert" und geht in den LEERLAUF (Prozess bleibt am * Leben), statt sich zu beenden. Grund: mit `restart: unless-stopped` würde ein * Exit einen Crash-Loop erzeugen, den Coolify als unhealthy wertet und deshalb den * GESAMTEN Stack (inkl. gesunder App) wieder abreißt. Idle → Container „running", * Deploy bleibt grün; bei nachträglicher IMAP-Konfig aktiviert ein Neustart den Betrieb. */ interface ImapEnv { host: string; port: number; user: string; password: string; tls: boolean; mailbox: string; pollMs: number; } function readEnv(): ImapEnv | null { const host = process.env.INCIDENT_IMAP_HOST?.trim(); const user = process.env.INCIDENT_IMAP_USER?.trim(); const password = process.env.INCIDENT_IMAP_PASSWORD; if (!host || !user || !password) return null; return { host, port: Number(process.env.INCIDENT_IMAP_PORT ?? "993"), user, password, // Default TLS an; nur bei explizit "false"/"0" abschalten (STARTTLS/Plain). tls: !["false", "0", "no"].includes((process.env.INCIDENT_IMAP_TLS ?? "true").toLowerCase()), mailbox: process.env.INCIDENT_IMAP_MAILBOX?.trim() || "INBOX", pollMs: Math.max(15_000, Number(process.env.INCIDENT_IMAP_POLL_MS ?? "60000")), }; } /** mailparser-Header (Map) → flache RawHeaders für parse.ts. */ function toRawHeaders(headerLines: Array<{ key: string; line: string }> | undefined): RawHeaders { const out: RawHeaders = {}; for (const h of headerLines ?? []) { // `line` ist die vollständige "Key: Value"-Zeile; Wert hinter dem ersten ":". const idx = h.line.indexOf(":"); const value = idx >= 0 ? h.line.slice(idx + 1).trim() : h.line.trim(); const key = h.key; const existing = out[key]; if (existing === undefined) out[key] = value; else if (Array.isArray(existing)) existing.push(value); else out[key] = [existing, value]; } return out; } /** * Hält den Prozess am Leben, bis SIGTERM/SIGINT kommt — statt Crash-Loop bei fehlender * IMAP-Konfig. Der Heartbeat-Timer hält den Event-Loop offen (Signal-Listener allein * genügen dafür nicht); bei Signal wird er gestoppt und der Prozess endet sauber (Exit 0). */ function idleUntilSignal(): Promise { return new Promise((resolve) => { const beat = setInterval(() => {}, 60_000); const done = (signal: string) => { clearInterval(beat); console.info(`[incident-inbound] ${signal} — Leerlauf beendet.`); resolve(); }; process.on("SIGTERM", () => done("SIGTERM")); process.on("SIGINT", () => done("SIGINT")); }); } async function main() { const env = readEnv(); if (!env) { console.warn( "[incident-inbound] IMAP ist nicht konfiguriert (INCIDENT_IMAP_HOST/USER/PASSWORD fehlen). " + "Ohne Postfach kein E-Mail-to-Ticket-Betrieb — Worker läuft im Leerlauf (kein Crash-Loop). " + "Setze die INCIDENT_IMAP_*-Variablen und starte den Container neu, um den Betrieb zu aktivieren.", ); await idleUntilSignal(); return; } // Dynamischer Import: hält Web-/Build-Pfad + tsc frei von IMAP-Typen. let ImapFlow: unknown; let simpleParser: unknown; try { ({ ImapFlow } = (await import("imapflow")) as { ImapFlow: unknown }); ({ simpleParser } = (await import("mailparser")) as { simpleParser: unknown }); } catch (err) { console.error( "[incident-inbound] Pakete 'imapflow'/'mailparser' nicht installiert. " + "`npm install imapflow mailparser` im Worker-Image sicherstellen.", err, ); process.exit(1); } /* eslint-disable @typescript-eslint/no-explicit-any */ const ClientCtor = ImapFlow as any; const parse = simpleParser as any; const client = new ClientCtor({ host: env.host, port: env.port, secure: env.tls, auth: { user: env.user, pass: env.password }, logger: false, }); let shuttingDown = false; const stop = async (signal: string) => { if (shuttingDown) return; shuttingDown = true; console.info(`[incident-inbound] ${signal} — fahre herunter…`); try { await client.logout(); } catch { /* egal beim Herunterfahren */ } process.exit(0); }; process.on("SIGTERM", () => void stop("SIGTERM")); process.on("SIGINT", () => void stop("SIGINT")); await client.connect(); console.info( `[incident-inbound] verbunden mit ${env.host} (${env.mailbox}) — Intake-Domain ` + `${process.env.INCIDENT_INTAKE_DOMAIN ?? "in.certvia.de"}, Poll ${env.pollMs}ms.`, ); async function drainOnce(): Promise { const lock = await client.getMailboxLock(env!.mailbox); try { // Nur ungelesene Mails; nach erfolgreicher Verarbeitung als \Seen markieren // (Idempotenz zusätzlich über Message-ID in process.ts). for await (const message of client.fetch({ seen: false }, { source: true, uid: true })) { let handled = false; try { const mail = await parse(message.source); const parsed = parseInbound({ headers: toRawHeaders(mail.headerLines), from: mail.from?.text, subject: mail.subject, text: mail.text ?? "", messageId: mail.messageId, }); const result = await processInbound(parsed); const decision = result.decision; const detail = decision.action === "incident" ? `Vorfall ${result.refNo}` : decision.action === "review" ? `Review (${decision.review.reason})` : `ignoriert (${decision.reason})`; console.info(`[incident-inbound] UID ${message.uid}: ${detail}`); handled = true; } catch (err) { // Verarbeitungsfehler: NICHT als gelesen markieren → nächster Lauf erneut. console.error(`[incident-inbound] Fehler bei UID ${message.uid}:`, err); } if (handled) { try { await client.messageFlagsAdd({ uid: message.uid }, ["\\Seen"], { uid: true }); } catch (err) { console.error(`[incident-inbound] Konnte UID ${message.uid} nicht als gelesen markieren:`, err); } } } } finally { lock.release(); } } /* eslint-enable @typescript-eslint/no-explicit-any */ // Poll-Schleife (robust gegen einzelne Fehlläufe). IMAP-IDLE wäre latenzärmer, das // Polling ist aber einfacher, ausreichend und übersteht Verbindungsabbrüche. while (!shuttingDown) { try { await drainOnce(); } catch (err) { console.error("[incident-inbound] Abholung fehlgeschlagen (nächster Versuch folgt):", err); } await new Promise((r) => setTimeout(r, env.pollMs)); } } main().catch((err) => { console.error("[incident-inbound] Start fehlgeschlagen:", err); process.exit(1); });