Workflow-App/Workflow/lib/email.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

241 lines
7.7 KiB
TypeScript

// lib/email.ts
// SERVER-ONLY email sender for resolution letters.
//
// Phase 6 — wires himalaya CLI into the resolution workflow.
//
// Two public functions:
//
// sendSignerEmail({to, signer_name, resolution_title, sign_url})
// → used by:
// - app/api/resolutions/[id]/attendees/route.ts (send_for_signature)
// - app/api/resolutions/[id]/resend/route.ts (resend to pending)
//
// sendFinalLetterEmail({to, signer_name, resolution_title, pdf_path, pdf_filename})
// → used by:
// - app/api/resolutions/sign/[token]/route.ts (last signer triggers
// finalizeAndDistribute which emails the final signed PDF to every
// signer + the company's primary contact)
//
// Both functions shell out to `himalaya message send` with a raw RFC 5322
// message body (via stdin). Attachments are base64-inlined per the spec
// (Content-Type: application/pdf; name="..." + Content-Transfer-Encoding:
// base64 + base64 body).
//
// Failure mode: if himalaya exits non-zero, we LOG the error + return a
// {ok:false, error} object — we do NOT throw. Email send failures must
// not block the sign flow or the resolution from being marked "signed".
// The caller stores the failure reason on the signer row for later retry.
import "server-only";
import { spawn } from "node:child_process";
import { promises as fs } from "node:fs";
import { HIMALAYA_BIN } from "@/lib/himalaya";
const HIMALAYA_ACCOUNT = process.env.HIMALAYA_ACCOUNT ?? "dyfins";
const FROM_ADDRESS =
process.env.RESOLUTION_FROM_EMAIL ?? "resolutions@dyfins.co.za";
export interface SendSignerEmailInput {
to: string;
signer_name: string;
resolution_title: string;
/** Absolute URL where the signer can review + sign. */
sign_url: string;
/** Company name shown in body copy. */
company_name: string;
}
export interface SendFinalLetterInput {
to: string;
signer_name: string;
resolution_title: string;
company_name: string;
/** Absolute path to the PDF on disk. */
pdf_path: string;
pdf_filename: string;
}
export interface EmailResult {
ok: boolean;
/** himalaya's exit code (0 = sent) */
exit_code: number;
/** Truncated stderr from himalaya, if any */
error?: string;
}
// ─── Public API ──────────────────────────────────────────────────────────
export async function sendSignerEmail(
input: SendSignerEmailInput,
/** Optional PDF to attach — the resolution letter itself. */
attachment?: { filename: string; base64: string }
): Promise<EmailResult> {
const subject = `Action required — sign resolution: ${input.resolution_title}`;
const body = buildSignerEmailBody(input);
const raw = buildRawMessage({
from: FROM_ADDRESS,
to: input.to,
subject,
body,
attachments: attachment
? [{ filename: attachment.filename, contentType: "application/pdf", base64: attachment.base64 }]
: [],
});
return runHimalaya(raw);
}
export async function sendFinalLetterEmail(
input: SendFinalLetterInput
): Promise<EmailResult> {
const subject = `Signed resolution: ${input.resolution_title}`;
const body = buildFinalLetterBody(input);
const pdfBase64 = await fs.readFile(input.pdf_path, "base64");
const raw = buildRawMessage({
from: FROM_ADDRESS,
to: input.to,
subject,
body,
attachments: [
{
filename: input.pdf_filename,
contentType: "application/pdf",
base64: pdfBase64,
},
],
});
return runHimalaya(raw);
}
// ─── Email bodies ────────────────────────────────────────────────────────
function buildSignerEmailBody(input: SendSignerEmailInput): string {
return [
`Dear ${input.signer_name},`,
"",
`You are listed as an attendee on the following shareholder resolution for ${input.company_name}:`,
"",
` ${input.resolution_title}`,
"",
"Please review and sign the resolution by opening the secure link below:",
"",
` ${input.sign_url}`,
"",
"The link is unique to you. Do not forward it — anyone with the link can sign on your behalf.",
"",
"If you have any questions about the resolution, contact the company secretary.",
"",
"Kind regards,",
"Ysterbaard Resolutions",
].join("\r\n");
}
function buildFinalLetterBody(input: SendFinalLetterInput): string {
return [
`Dear ${input.signer_name},`,
"",
`The resolution you signed for ${input.company_name} is now complete.`,
"",
` ${input.resolution_title}`,
"",
"A copy of the fully-signed resolution is attached as a PDF. Please keep it on file for your records.",
"",
"If any details are incorrect, reply to this email within 7 days.",
"",
"Kind regards,",
"Ysterbaard Resolutions",
].join("\r\n");
}
// ─── RFC 5322 message builder ────────────────────────────────────────────
interface RawAttachment {
filename: string;
contentType: string;
base64: string;
}
interface BuildRawOpts {
from: string;
to: string;
subject: string;
body: string;
attachments: RawAttachment[];
}
function buildRawMessage(opts: BuildRawOpts): string {
const boundary = `mixed_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const headers = [
`From: ${opts.from}`,
`To: ${opts.to}`,
`Subject: ${opts.subject}`,
`Date: ${new Date().toUTCString()}`,
`MIME-Version: 1.0`,
`Content-Type: multipart/mixed; boundary="${boundary}"`,
"",
`--${boundary}`,
`Content-Type: text/plain; charset=utf-8`,
`Content-Transfer-Encoding: 7bit`,
"",
opts.body,
];
const parts: string[] = [headers.join("\r\n")];
for (const att of opts.attachments) {
parts.push(
[
`--${boundary}`,
`Content-Type: ${att.contentType}; name="${att.filename}"`,
`Content-Disposition: attachment; filename="${att.filename}"`,
`Content-Transfer-Encoding: base64`,
"",
// base64-wrap at 76 cols per RFC 2045
att.base64.match(/.{1,76}/g)!.join("\r\n"),
].join("\r\n")
);
}
parts.push(`--${boundary}--`);
parts.push("");
return parts.join("\r\n");
}
// ─── Himalaya runner ─────────────────────────────────────────────────────
function runHimalaya(rawMessage: string): Promise<EmailResult> {
return new Promise((resolve) => {
// v1.x: `-a` is a per-subcommand flag, so it must FOLLOW `message send`.
// `himalaya -a acct message send` fails with "unexpected argument '-a'".
//
// Also: a bare "himalaya" relies on process PATH, which a Next.js server
// captures at STARTUP. HIMALAYA_BIN resolves an absolute path at call
// time, so this works without restarting the dev server.
const args = [
"message",
"send",
"-a",
HIMALAYA_ACCOUNT,
];
const child = spawn(HIMALAYA_BIN, args, { stdio: ["pipe", "pipe", "pipe"] });
let stderr = "";
child.stdout.on("data", () => {});
child.stderr.on("data", (b) => (stderr += b.toString()));
child.on("error", (err) => {
resolve({ ok: false, exit_code: -1, error: err.message });
});
child.on("close", (code) => {
if (code === 0) {
resolve({ ok: true, exit_code: 0 });
} else {
resolve({
ok: false,
exit_code: code ?? -1,
error: stderr.slice(0, 500),
});
}
});
// Write the raw RFC 5322 message to stdin
child.stdin.write(rawMessage);
child.stdin.end();
});
}