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>
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user