Workflow-App/Workflow/lib/mail-health.ts
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
Adds the full Next.js app (app/, components/, lib/, scripts/, assets/,
docs/, Dockerfile, compose, configs) to version control.

Security:
- CIPC BizPortal customer-code logins (Login 1 + Login 3) now read
  CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE /
  CIPC_LOGIN3_PASSWORD from .env.local instead of source.
- BizPortal personal login in apply-bizportal route reads
  BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD.
- Added .env.example as the committed template.

.gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright
storage_state.json (live CIPC cookies), scratch dirs, client PDFs and
debug screenshots.
2026-10-18 15:21:45 +02:00

168 lines
No EOL
5.9 KiB
TypeScript

// lib/mail-health.ts
// SERVER-ONLY. Diagnostics for the outbound mail + PDF pipeline.
//
// The failure this exists to prevent: a resignation letter is generated,
// the record is saved, the UI reports success — and nothing was ever
// delivered, because himalaya wasn't on PATH or the PDF service was down.
// Nothing failed loudly at the moment it mattered.
//
// `checkMailHealth()` is called by GET /api/mail-health and by
// scripts/check-mail.ps1, so you can verify the pipeline BEFORE relying
// on it, instead of after losing a letter.
import "server-only";
import { spawn } from "child_process";
import { existsSync } from "fs";
import { homedir, platform } from "os";
import { join } from "path";
import { HIMALAYA_BIN } from "@/lib/himalaya";
export type CheckStatus = "ok" | "warn" | "fail";
export interface CheckResult {
name: string;
status: CheckStatus;
detail: string;
/** Present only for status === "fail" — a concrete next action. */
fix?: string;
}
function run(
bin: string,
args: string[],
timeoutMs = 30000
): Promise<{ code: number; stdout: string; stderr: string; spawnError?: string }> {
return new Promise((resolve) => {
let settled = false;
let stdout = "";
let stderr = "";
const finish = (r: Parameters<typeof resolve>[0]) => {
if (!settled) { settled = true; resolve(r); }
};
let child;
try {
child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
} catch (e) {
finish({ code: -1, stdout, stderr, spawnError: e instanceof Error ? e.message : String(e) });
return;
}
const timer = setTimeout(() => {
try { child.kill(); } catch { /* already gone */ }
finish({ code: -1, stdout, stderr, spawnError: `timed out after ${timeoutMs}ms` });
}, timeoutMs);
child.stdout?.on("data", (d) => { stdout += d.toString(); });
child.stderr?.on("data", (d) => { stderr += d.toString(); });
child.on("error", (err) => {
clearTimeout(timer);
finish({ code: -1, stdout, stderr, spawnError: err.message });
});
child.on("close", (code) => {
clearTimeout(timer);
finish({ code: code ?? -1, stdout, stderr });
});
});
}
/** Absolute path to himalaya's config file for the current platform. */
export function himalayaConfigPath(): string {
const appData = process.env.APPDATA;
if (platform() === "win32" && appData) return join(appData, "himalaya", "config.toml");
return join(homedir(), ".config", "himalaya", "config.toml");
}
/** True when the resolved binary is an existing absolute file.
* Bare "himalaya" means it relies on PATH — acceptable but worth flagging. */
function binaryLooksResolvable(): { ok: boolean; onPath: boolean } {
if (HIMALAYA_BIN === "himalaya") return { ok: true, onPath: true };
return { ok: existsSync(HIMALAYA_BIN), onPath: false };
}
async function checkPdfService(): Promise<CheckResult> {
const url = process.env.PDFD_URL || "http://127.0.0.1:3070";
try {
const res = await fetch(`${url.replace(/\/$/, "")}/health`, {
signal: AbortSignal.timeout(5000),
}).catch(async () => {
// Some builds don't expose /health — fall back to probing /pdf.
const r = await fetch(`${url.replace(/\/$/, "")}/pdf`, {
method: "OPTIONS",
signal: AbortSignal.timeout(5000),
});
return r;
});
if (res.ok) {
return { name: "PDF service", status: "ok", detail: `reachable at ${url}` };
}
return {
name: "PDF service",
status: "warn",
detail: `${url} responded ${res.status}`,
fix: "Letters will still send, but without a PDF attachment.",
};
} catch (e) {
return {
name: "PDF service",
status: "warn",
detail: `not reachable at ${url} (${e instanceof Error ? e.message : "unknown"})`,
fix: "Letters will still send (HTML only, no attachment). Start the pdfd service to restore attachments.",
};
}
}
export async function checkMailHealth(): Promise<{
ok: boolean;
checks: CheckResult[];
}> {
const checks: CheckResult[] = [];
const { ok: binOk, onPath } = binaryLooksResolvable();
checks.push(
binOk
? onPath
? {
name: "himalaya binary",
status: "warn",
detail: "resolving via PATH as 'himalaya'",
fix: "Set HIMALAYA_BIN to the absolute path — a running dev server may not see a PATH change until restart.",
}
: { name: "himalaya binary", status: "ok", detail: HIMALAYA_BIN }
: {
name: "himalaya binary",
status: "fail",
detail: `NOT FOUND at ${HIMALAYA_BIN}`,
fix: "Install himalaya v1.2.0 to C:\\Users\\SJ\\.local\\bin, or set HIMALAYA_BIN in .env.local and restart the dev server.",
}
);
const cfg = himalayaConfigPath();
if (!existsSync(cfg)) {
checks.push({
name: "himalaya config",
status: "fail",
detail: `missing: ${cfg}`,
fix: "Create the config with accounts `dyfins` (SMTP send) and `deltadet` (IMAP read).",
});
} else {
checks.push({ name: "himalaya config", status: "ok", detail: cfg });
if (binOk) {
// v1.x uses a POSITIONAL account argument for `doctor`.
for (const acct of ["dyfins", "deltadet"]) {
const r = await run(HIMALAYA_BIN, ["account", "doctor", acct], 45000);
const combined = `${r.stdout}${r.stderr}`;
if (r.code === 0) {
checks.push({ name: `account "${acct}"`, status: "ok", detail: "IMAP + SMTP verified" });
} else {
checks.push({
name: `account "${acct}"`,
status: "fail",
detail: r.spawnError || combined.split("\n").filter(Boolean).slice(0, 2).join(" | ") || "doctor failed",
fix: "App passwords are likely wrong or revoked. Regenerate at myaccount.google.com/apppasswords and update config.toml.",
});
}
}
}
}
checks.push(await checkPdfService());
return { ok: !checks.some((c) => c.status === "fail"), checks };
}