Files
craftvia/scripts/incident-inbound-worker.ts
T
msolarczekandClaude Opus 5 c8e6f30a27
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s
Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:05:39 +02:00

206 lines
7.6 KiB
TypeScript

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-<token>@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<void> {
return new Promise<void>((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<void> {
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);
});