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>
206 lines
7.6 KiB
TypeScript
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);
|
|
});
|