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.
241 lines
7.7 KiB
TypeScript
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();
|
|
});
|
|
}
|