// 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/ 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 { 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 { 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); }