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.
168 lines
No EOL
5.9 KiB
TypeScript
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 };
|
|
} |