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.
166 lines
No EOL
5 KiB
TypeScript
166 lines
No EOL
5 KiB
TypeScript
// lib/shareholder-mail.ts
|
|
//
|
|
// SERVER-ONLY email sender for section 5 ("Change in shareholders").
|
|
//
|
|
// WHY THIS IS NOT lib/email.ts
|
|
// ----------------------------
|
|
// lib/email.ts is built around the token-based signing flow: every message it
|
|
// sends carries a secure /resolve-sign/<token> link for the recipient to sign
|
|
// in the browser. Section 5 does not use that flow — the parties print the PDF,
|
|
// sign it by hand and return it. Sending them a "sign here" link would be
|
|
// actively misleading, so this module composes its own message.
|
|
//
|
|
// It reuses HIMALAYA_BIN (the only genuinely reusable piece) so the known
|
|
// PATH-capture bug on Windows stays fixed in exactly one place.
|
|
//
|
|
// Failure mode matches the rest of the app: never throw. An email that fails to
|
|
// send must not lose the letter or block the operator; the caller reports it
|
|
// per-recipient so the operator can chase it by another means.
|
|
|
|
import "server-only";
|
|
import { spawn } from "node:child_process";
|
|
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 ShareholderMailInput {
|
|
to: string;
|
|
party_name: string;
|
|
company_name: string;
|
|
/** Role wording shown in the body, e.g. "Transferor (seller)". */
|
|
role_label: string;
|
|
/** The generated special resolution PDF. */
|
|
attachment: { filename: string; base64: string };
|
|
}
|
|
|
|
export interface ShareholderMailResult {
|
|
ok: boolean;
|
|
exit_code: number;
|
|
error?: string;
|
|
}
|
|
|
|
function buildBody(input: ShareholderMailInput): string {
|
|
return [
|
|
`Dear ${input.party_name},`,
|
|
"",
|
|
`Please find attached the special resolution of shareholders of ${input.company_name},`,
|
|
"recording a transfer of shares. You are named in this resolution as:",
|
|
"",
|
|
` ${input.role_label}`,
|
|
"",
|
|
"Please print the attached resolution, sign it in the signature block provided,",
|
|
"and return the signed copy to us. We will then file it and arrange for the",
|
|
"company's securities register to be updated.",
|
|
"",
|
|
"If any detail in the resolution is incorrect, please tell us before signing",
|
|
"so we can issue a corrected version.",
|
|
"",
|
|
"Kind regards,",
|
|
"Ysterbaard Resolutions",
|
|
].join("\r\n");
|
|
}
|
|
|
|
interface RawAttachment {
|
|
filename: string;
|
|
contentType: string;
|
|
base64: string;
|
|
}
|
|
|
|
function buildRawMessage(opts: {
|
|
from: string;
|
|
to: string;
|
|
subject: string;
|
|
body: string;
|
|
attachments: RawAttachment[];
|
|
}): 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=utf8",
|
|
"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 wrapped at 76 columns 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");
|
|
}
|
|
|
|
function runHimalaya(rawMessage: string): Promise<ShareholderMailResult> {
|
|
return new Promise((resolve) => {
|
|
// `-a` is a per-subcommand flag in himalaya v1.x, so it must FOLLOW
|
|
// `message send`.
|
|
const child = spawn(HIMALAYA_BIN, ["message", "send", "-a", HIMALAYA_ACCOUNT], {
|
|
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 || `himalaya exited ${code}`).slice(0, 300),
|
|
});
|
|
}
|
|
});
|
|
|
|
child.stdin.on("error", () => {
|
|
/* himalaya may close stdin early; the close handler reports the outcome */
|
|
});
|
|
child.stdin.end(rawMessage);
|
|
});
|
|
}
|
|
|
|
export async function sendShareholderResolutionEmail(
|
|
input: ShareholderMailInput
|
|
): Promise<ShareholderMailResult> {
|
|
const subject = `Action required - sign resolution: Special resolution of shareholders - ${input.company_name}`;
|
|
const raw = buildRawMessage({
|
|
from: FROM_ADDRESS,
|
|
to: input.to,
|
|
subject,
|
|
body: buildBody(input),
|
|
attachments: [
|
|
{
|
|
filename: input.attachment.filename,
|
|
contentType: "application/pdf",
|
|
base64: input.attachment.base64,
|
|
},
|
|
],
|
|
});
|
|
return runHimalaya(raw);
|
|
} |