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