PDF daemon + attachments for appointment emails

- New /Volumes/KIOXIA/.hermes/profiles/ysterbaard/scripts/pdfd.js
  standalone HTML->PDF daemon on localhost:3070, calls soffice via
  execSync (proven working pattern from resignation form watcher.js)
- app/api/appointments/route.ts generates acceptance-letter.pdf via
  daemon, attaches via MIME multipart/mixed
- app/api/appointments/[id]/route.ts renders signed-letter.pdf with
  embedded signature, attaches to distro mail
- Idempotency guard: skip distro if status is already mail_sent_to_all
This commit is contained in:
Dave 2026-08-17 22:34:58 +02:00
parent 8ae44e42e4
commit efa4331b99
3 changed files with 311 additions and 90 deletions

View file

@ -5,8 +5,10 @@
import { NextRequest, NextResponse } from "next/server";
import { fetchRecord, updateRecord, updateRecordMultipart } from "@/lib/pb";
import type { YsterbaardDirectorAppointment } from "@/lib/types";
import { readFileSync, writeFileSync, unlinkSync } from "fs";
import { execSync, spawn } from "child_process";
import { writeFileSync, unlinkSync } from "fs";
import { spawn } from "child_process";
const PDFD_URL = process.env.PDFD_URL || "http://127.0.0.1:3070";
export async function GET(
_req: NextRequest,
@ -36,7 +38,16 @@ export async function PATCH(
// Signature upload — multipart
if (body.signature_base64) {
// Save signature to PB first (no letter required)
// Idempotency: if distro already ran, don't resend on retry.
const existing = await fetchRecord<YsterbaardDirectorAppointment>(
"ysterbaard_director_appointments",
id
);
if (existing.status === "mail_sent_to_all") {
return NextResponse.json({ ok: true, already: true });
}
// Save signature to PB first (so we don't lose the user's input on distro fail)
const buf = Buffer.from(body.signature_base64, "base64");
const sigFile = new File([buf], "signature.png", { type: "image/png" });
const record = await updateRecordMultipart<YsterbaardDirectorAppointment>(
@ -46,7 +57,7 @@ export async function PATCH(
[{ field: "signature", file: sigFile }]
);
// Distro email to all directors + company (signature image embedded inline)
// Distro email to all directors + company (PDF with embedded signature attached)
try {
await sendSignedDistro({ id, signatureBase64: body.signature_base64 });
await updateRecord("ysterbaard_director_appointments", id, { status: "mail_sent_to_all" });
@ -75,9 +86,71 @@ export async function PATCH(
}
}
// ─── Send signed letter to all directors + company ─────────────────────────
// ─── Signed-letter PDF via daemon ─────────────────────────────────────────
// ─── Send signed acceptance email (HTML with inline signature) ────────────
function signedLetterHtml(directorName: string, companyName: string, date: string, signatureBase64: string): string {
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Signed Letter of Appointment</title>
<style>
@page { size: A4; margin: 25mm; }
body { font-family: Arial, sans-serif; font-size: 11pt; color: #111; line-height: 1.5; }
h1 { text-align: center; font-size: 14pt; margin: 0 0 6pt; }
h2 { font-size: 11pt; margin: 14pt 0 4pt; border-bottom: 1px solid #333; padding-bottom: 2pt; }
p { margin: 6pt 0; text-align: justify; }
.meta { font-size: 10pt; color: #444; margin-bottom: 18pt; }
.sig-block { margin-top: 28pt; border-top: 1px solid #000; padding-top: 8pt; width: 60%; }
.sig-img { height: 70pt; display: block; margin: 4pt 0; }
.sig-name { font-weight: bold; }
.sig-role { color: #555; font-size: 10pt; }
.footer { margin-top: 32pt; font-size: 9pt; color: #666; text-align: center; }
</style></head>
<body>
<h1>SIGNED LETTER OF APPOINTMENT AS DIRECTOR</h1>
<p class="meta">Date: ${date}</p>
<p><strong>To:</strong> All Directors and the Company Secretary</p>
<p><strong>Re:</strong> Acceptance of Director Appointment — <em>${companyName}</em></p>
<h2>1. Confirmation</h2>
<p>
We are pleased to confirm that <strong>${directorName}</strong> has accepted the
appointment as a Director of <strong>${companyName}</strong>.
</p>
<h2>2. Acceptance</h2>
<p>
The acceptance was received on <strong>${date}</strong> and is hereby recorded in
terms of section 70 of the Companies Act 71 of 2008.
</p>
<h2>3. Signature</h2>
<p>The Director's acceptance is evidenced by the signature below:</p>
<div class="sig-block">
<img class="sig-img" src="data:image/png;base64,${signatureBase64}" alt="Signature"/>
<p class="sig-name">${directorName}</p>
<p class="sig-role">Director — ${companyName}</p>
</div>
<p class="footer">
Filed by the Board of Directors — ${companyName}<br/>
Section 70, Companies Act 71 of 2008
</p>
</body></html>`;
}
async function renderPdf(html: string, filename: string): Promise<Buffer> {
const res = await fetch(`${PDFD_URL}/pdf`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ html, filename }),
});
if (!res.ok) throw new Error(`pdfd failed: ${res.status} ${await res.text()}`);
const data = (await res.json()) as { pdf: string };
return Buffer.from(data.pdf, "base64");
}
// ─── Send signed letter to all directors + company ─────────────────────────
async function sendSignedDistro(opts: { id: string; signatureBase64: string }) {
const appt = await fetchRecord<YsterbaardDirectorAppointment>(
@ -92,7 +165,7 @@ async function sendSignedDistro(opts: { id: string; signatureBase64: string }) {
const companyName = company?.enterprise_name || "the Company";
const date = new Date().toISOString().split("T")[0];
// Build recipient list: all directors of this company + company email
// Build recipient list: signing director + all other directors + company email
const recipients: string[] = [];
if (director?.email) recipients.push(director.email);
const { fetchDirectorsForCompany } = await import("@/lib/pb");
@ -104,35 +177,105 @@ async function sendSignedDistro(opts: { id: string; signatureBase64: string }) {
if (em) recipients.push(em);
}
const companyEmail = (company as { company_email?: string } | undefined)?.company_email;
if (companyEmail) recipients.push(companyEmail);
if (companyEmail && companyEmail.includes("@")) recipients.push(companyEmail);
// Operator fallback — if no other recipients, copy SJ so we know it happened
const otherThanSigning = recipients.filter((e) => e !== director?.email);
if (otherThanSigning.length === 0) recipients.push("SJ@dyfins.com");
const uniqueEmails = [...new Set(recipients.filter(Boolean))];
if (uniqueEmails.length === 0) {
console.error("sendSignedDistro: no recipients");
return;
}
// Build the signed PDF once, reuse for every recipient
let pdfBytes: Buffer | null = null;
let pdfFilename = `signed-acceptance-${Date.now()}.pdf`;
try {
pdfBytes = await renderPdf(
signedLetterHtml(directorName, companyName, date, opts.signatureBase64),
`signed-acceptance-${companyName.replace(/[^A-Za-z0-9]/g, "_")}`
);
} catch (e) {
console.error("PDF generation failed (sending HTML-only):", e instanceof Error ? e.message : e);
}
for (const to of uniqueEmails) {
await sendSignedEmail({ to, directorName, companyName, date, signatureBase64: opts.signatureBase64 });
await sendSignedEmail({
to,
directorName,
companyName,
date,
signatureBase64: opts.signatureBase64,
pdfBytes,
pdfFilename,
});
}
}
async function sendSignedEmail(opts: { to: string; directorName: string; companyName: string; date: string; signatureBase64: string }) {
const { to, directorName, companyName, date, signatureBase64 } = opts;
async function sendSignedEmail(opts: {
to: string;
directorName: string;
companyName: string;
date: string;
signatureBase64: string;
pdfBytes: Buffer | null;
pdfFilename: string;
}) {
const { to, directorName, companyName, date, signatureBase64, pdfBytes, pdfFilename } = opts;
const subject = `${directorName} accepted the appointment as director of ${companyName}`;
const eml = `To: ${to}\r
From: SJ@Dyfins.com\r
Subject: ${subject}\r
MIME-Version: 1.0\r
Content-Type: text/html; charset="utf-8"\r
\r
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
const boundary = "----YSTERBAARD_" + Date.now();
let eml = `To: ${to}\r\n`;
eml += `From: SJ@Dyfins.com\r\n`;
eml += `Subject: ${subject}\r\n`;
eml += `MIME-Version: 1.0\r\n`;
if (pdfBytes) {
eml += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n\r\n`;
eml += `--${boundary}\r\n`;
eml += `Content-Type: text/html; charset="utf-8"\r\n`;
eml += `Content-Transfer-Encoding: 7bit\r\n\r\n`;
} else {
eml += `Content-Type: text/html; charset="utf-8"\r\n\r\n`;
}
eml += signedEmailHtml(directorName, companyName, date, signatureBase64);
eml += `\r\n`;
if (pdfBytes) {
eml += `--${boundary}\r\n`;
eml += `Content-Type: application/pdf; name="${pdfFilename}"\r\n`;
eml += `Content-Transfer-Encoding: base64\r\n`;
eml += `Content-Disposition: attachment; filename="${pdfFilename}"\r\n\r\n`;
eml += pdfBytes.toString("base64").replace(/(.{76})/g, "$1\r\n");
eml += `\r\n`;
eml += `--${boundary}--\r\n`;
}
const emlPath = `/tmp/signed-email-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.eml`;
writeFileSync(emlPath, eml, "utf8");
try {
const himalaya = spawn(
"/opt/homebrew/bin/himalaya",
["-a", "dyfins", "message", "send", "--save", "Sent"],
{ stdio: ["pipe", "pipe", "pipe"] }
);
himalaya.stdin.write(eml);
himalaya.stdin.end();
let err = "";
himalaya.stderr.on("data", (d) => { err += d.toString(); });
await new Promise<void>((res) => { himalaya.on("close", () => res()); });
if (err.trim()) console.error("himalaya stderr:", err.trim());
} finally {
try { unlinkSync(emlPath); } catch {}
}
}
function signedEmailHtml(directorName: string, companyName: string, date: string, signatureBase64: string): string {
return `<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="font-family:Arial,sans-serif;font-size:14px;color:#222;max-width:600px;margin:40px auto;padding:0 20px;">
<div style="background:#f8f9fa;border:1px solid #ddd;border-radius:8px;padding:24px;">
<h2 style="text-align:center;color:#0a0a0a;font-size:18px;margin:0 0 16px;">Director Acceptance Recorded</h2>
<p>I am glad to inform you that <strong>${directorName}</strong> accepted the appointment of <strong>${companyName}</strong>.</p>
<p>I am glad to inform you that <strong>${directorName}</strong> accepted the appointment as director of <strong>${companyName}</strong>.</p>
<p>Date: ${date}</p>
<div style="background:#fff;border:1px solid #eee;border-radius:6px;padding:20px;margin:20px 0;">
<h3 style="font-size:14px;text-align:center;margin:0 0 12px;">LETTER OF APPOINTMENT AS DIRECTOR</h3>
@ -144,22 +287,6 @@ Content-Type: text/html; charset="utf-8"\r
<p style="margin:0;color:#555;">Director</p>
<p style="margin-top:8px;"><img src="data:image/png;base64,${signatureBase64}" alt="Signature" style="height:60px;display:block;" /></p>
</div>
</div></div></body></html>\r
`;
const emlPath = `/tmp/signed-email-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.eml`;
writeFileSync(emlPath, eml, "utf8");
try {
const himalaya = spawn("/opt/homebrew/bin/himalaya", ["-a", "dyfins", "message", "send", "--save", "Sent"], { stdio: ["pipe", "pipe", "pipe"] });
const emlBuf = readFileSync(emlPath);
himalaya.stdin.write(emlBuf);
himalaya.stdin.end();
let err = "";
himalaya.stderr.on("data", (d) => { err += d.toString(); });
await new Promise<void>((res) => { himalaya.on("close", () => { res(); }); });
if (err.trim()) console.error("himalaya stderr:", err.trim());
} finally {
unlinkSync(emlPath);
}
}
<p style="margin-top:16px;font-size:12px;color:#666;">The full signed letter is attached as a PDF.</p>
</div></div></body></html>`;
}

View file

@ -1,14 +1,16 @@
// app/api/appointments/route.ts
// GET — list all director appointments (with expand)
// POST — create a new appointment, generate letter, email director
// POST — create a new appointment, generate letter PDF, email director
import { NextRequest, NextResponse } from "next/server";
import { fetchCollection, createRecord, updateRecordMultipart } from "@/lib/pb";
import { writeFileSync, readFileSync, unlinkSync, existsSync } from "fs";
import { writeFileSync, unlinkSync } from "fs";
import { spawn } from "child_process";
export const dynamic = "force-dynamic";
const PDFD_URL = process.env.PDFD_URL || "http://127.0.0.1:3070";
// GET /api/appointments
export async function GET() {
try {
@ -42,7 +44,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "director_id and company_id are required" }, { status: 400 });
}
// Generate a unique sign token
const signToken = Math.random().toString(36).substring(2) + Date.now().toString(36);
const today = new Date().toISOString().split("T")[0];
const emailTo = director_email || "SJ@Dyfins.com";
@ -60,7 +61,7 @@ export async function POST(req: NextRequest) {
sign_token: signToken,
});
// 2. Send HTML email to director with sign link
// 2. Send HTML email to director with sign link + PDF attachment
await sendAppointmentEmail({
to: emailTo,
directorName: fullName || "Director",
@ -68,7 +69,7 @@ export async function POST(req: NextRequest) {
signUrl,
});
// 6. Update status to mail_sent
// 3. Update status to mail_sent
await updateRecordMultipart("ysterbaard_director_appointments", record.id, { status: "mail_sent" }, []);
return NextResponse.json({ id: record.id, sign_token: signToken }, { status: 201 });
@ -79,8 +80,78 @@ export async function POST(req: NextRequest) {
}
}
// ─── Letter HTML → PDF via daemon ─────────────────────────────────────────
// ─── Email ───────────────────────────────────────────────────────────────
function letterHtml(directorName: string, companyName: string, signUrl: string): string {
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Letter of Appointment as Director</title>
<style>
@page { size: A4; margin: 25mm; }
body { font-family: Arial, sans-serif; font-size: 11pt; color: #111; line-height: 1.5; }
h1 { text-align: center; font-size: 14pt; margin: 0 0 6pt; }
h2 { font-size: 11pt; margin: 14pt 0 4pt; border-bottom: 1px solid #333; padding-bottom: 2pt; }
p { margin: 6pt 0; text-align: justify; }
.meta { font-size: 10pt; color: #444; margin-bottom: 18pt; }
.sign-line { margin-top: 28pt; border-top: 1px solid #000; width: 50%; padding-top: 4pt; font-size: 10pt; color: #555; }
.footer { margin-top: 32pt; font-size: 9pt; color: #666; text-align: center; }
</style></head>
<body>
<h1>LETTER OF APPOINTMENT AS DIRECTOR</h1>
<p class="meta">Date: ${new Date().toISOString().split("T")[0]}</p>
<p><strong>To:</strong> ${directorName}</p>
<h2>1. Appointment</h2>
<p>
We are pleased to inform you that you have been appointed as a Director of
<strong>${companyName}</strong> in terms of the company's Memorandum of Incorporation.
</p>
<h2>2. Acceptance Required</h2>
<p>
Your appointment is subject to your formal acceptance in terms of section 70 of the
Companies Act 71 of 2008. Until your acceptance is received, your appointment is not
complete.
</p>
<h2>3. How to Accept</h2>
<p>
Please accept your appointment and provide your signature by visiting the following
secure link:
</p>
<p style="text-align:center; font-family: monospace; font-size: 10pt; padding: 8pt; background:#f4f4f4; border:1px solid #ddd;">
${signUrl}
</p>
<p>
Alternatively, you may print and sign this letter and return the signed copy to the
company secretary.
</p>
<div class="sign-line">Director (to sign on acceptance)</div>
<p class="footer">
Issued by the Board of Directors — ${companyName}<br/>
Section 70, Companies Act 71 of 2008
</p>
</body></html>`;
}
async function renderPdf(html: string, filename: string): Promise<{ bytes: Buffer; filename: string }> {
const res = await fetch(`${PDFD_URL}/pdf`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ html, filename }),
});
if (!res.ok) {
const t = await res.text();
throw new Error(`pdfd failed: ${res.status} ${t}`);
}
const data = (await res.json()) as { ok: boolean; pdf: string; filename: string; bytes: number };
return { bytes: Buffer.from(data.pdf, "base64"), filename: data.filename };
}
// ─── Email ────────────────────────────────────────────────────────────────
async function sendAppointmentEmail(opts: {
to: string;
@ -91,35 +162,80 @@ async function sendAppointmentEmail(opts: {
const { to, directorName, companyName, signUrl } = opts;
const subject = `[${directorName}] Acceptance letter as director for ${companyName}`;
const body = `Good day ${directorName}
Please find attached the letter to accept your appointment as director for ${companyName}.
// 1. Generate PDF via daemon
const html = letterHtml(directorName, companyName, signUrl);
let pdfBytes: Buffer | null = null;
let pdfFilename = `acceptance-letter-${Date.now()}.pdf`;
try {
const rendered = await renderPdf(html, `acceptance-letter`);
pdfBytes = rendered.bytes;
pdfFilename = rendered.filename;
} catch (e) {
console.error("PDF generation failed (sending HTML-only):", e instanceof Error ? e.message : e);
}
Please click the link below to accept your appointment and sign:
// 2. Build MIME multipart/mixed EML (HTML body + PDF attachment)
const boundary = "----YSTERBAARD_" + Date.now();
let eml = `To: ${to}\r\n`;
eml += `From: SJ@Dyfins.com\r\n`;
eml += `Subject: ${subject}\r\n`;
eml += `MIME-Version: 1.0\r\n`;
if (pdfBytes) {
eml += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`;
eml += `\r\n`;
eml += `--${boundary}\r\n`;
eml += `Content-Type: text/html; charset="utf-8"\r\n`;
eml += `Content-Transfer-Encoding: 7bit\r\n\r\n`;
} else {
eml += `Content-Type: text/html; charset="utf-8"\r\n`;
eml += `\r\n`;
}
eml += emailBodyHtml(directorName, companyName, signUrl);
eml += `\r\n`;
if (pdfBytes) {
eml += `--${boundary}\r\n`;
eml += `Content-Type: application/pdf; name="${pdfFilename}"\r\n`;
eml += `Content-Transfer-Encoding: base64\r\n`;
eml += `Content-Disposition: attachment; filename="${pdfFilename}"\r\n\r\n`;
// Wrap base64 at 76 chars per RFC 2045
eml += pdfBytes.toString("base64").replace(/(.{76})/g, "$1\r\n");
eml += `\r\n`;
eml += `--${boundary}--\r\n`;
}
${signUrl}
const emlPath = `/tmp/appt-email-${Date.now()}.eml`;
writeFileSync(emlPath, eml, "utf8");
Regards`;
try {
const himalaya = spawn(
"/opt/homebrew/bin/himalaya",
["-a", "dyfins", "message", "send", "--save", "Sent"],
{ stdio: ["pipe", "pipe", "pipe"] }
);
himalaya.stdin.on("error", () => {});
himalaya.stdin.write(eml);
himalaya.stdin.end();
let err = "";
let out = "";
himalaya.stderr.on("data", (d) => { err += d.toString(); });
himalaya.stdout.on("data", (d) => { out += d.toString(); });
himalaya.on("error", (e) => { console.error("himalaya spawn error:", e.message); });
await new Promise<void>((res) => { himalaya.on("close", () => res()); });
if (err.trim()) console.error("himalaya stderr:", err.trim());
if (out.trim()) console.log("himalaya stdout:", out.trim());
} finally {
try { unlinkSync(emlPath); } catch {}
}
}
const boundary = "----YSTERBAARD" + Date.now();
// Send as HTML email (PDF generation via LibreOffice is unreliable when spawned from Node.js)
const eml = `To: ${to}\r
From: SJ@Dyfins.com\r
Subject: ${subject}\r
MIME-Version: 1.0\r
Content-Type: text/html; charset="utf-8"\r
\r
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
function emailBodyHtml(directorName: string, companyName: string, signUrl: string): string {
return `<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="font-family:Arial,sans-serif;font-size:14px;color:#222;max-width:600px;margin:40px auto;padding:0 20px;">
<div style="background:#f8f9fa;border:1px solid #ddd;border-radius:8px;padding:24px;">
<p>Good day <strong>${directorName}</strong>,</p>
<p>Please find below the letter to accept your appointment as director for <strong>${companyName}</strong>.</p>
<p>Please find attached the letter to accept your appointment as director for <strong>${companyName}</strong>.</p>
<div style="background:#fff;border:1px solid #eee;border-radius:6px;padding:20px;margin:20px 0;">
<h1 style="font-size:16px;text-align:center;margin:0 0 4px;">LETTER OF APPOINTMENT AS DIRECTOR</h1>
<hr style="border:none;border-top:1px solid #333;margin:8px 0 16px;">
<p>We are pleased to inform you that you have been appointed as a Director of <strong>${companyName}</strong>.</p>
<p>Your appointment is subject to your formal acceptance in terms of section 70 of the Companies Act 71 of 2008.</p>
<p><strong>To accept your appointment, please click the button below and draw your signature:</strong></p>
<p style="text-align:center;margin:24px 0;">
<a href="${signUrl}" style="display:inline-block;background:#0066cc;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;font-size:14px;font-weight:bold;">
@ -129,27 +245,5 @@ Content-Type: text/html; charset="utf-8"\r
<p>Or copy this link into your browser:<br/><span style="color:#0066cc;font-size:12px;word-break:break-all;">${signUrl}</span></p>
<p>Please note that until your acceptance is received, your appointment is not complete.</p>
<p>Yours sincerely,<br/><strong>The Board of ${companyName}</strong></p>
</div></div></body></html>\r
`;
const emlPath = `/tmp/appt-email-${Date.now()}.eml`;
writeFileSync(emlPath, eml, "utf8");
try {
// Extract recipients from EML for himalaya smtp send
const emlContent = readFileSync(emlPath, "utf8");
const toMatch = emlContent.match(/^To:\s*(.+)$/m);
const toAddresses = toMatch ? toMatch[1].trim() : "";
// Use Node.js stdin to avoid shell /bin/sh ETIMEDOUT
const himalaya = spawn("/opt/homebrew/bin/himalaya", ["-a", "dyfins", "message", "send", "--save", "Sent"], { stdio: ["pipe", "pipe", "pipe"] });
const emlBuf = readFileSync(emlPath);
himalaya.stdin.write(emlBuf);
himalaya.stdin.end();
let err = "";
himalaya.stderr.on("data", (d) => { err += d.toString(); });
await new Promise<void>((res) => { himalaya.on("close", (code) => { res(); }); });
if (err.trim()) console.error("himalaya stderr:", err.trim());
} finally {
unlinkSync(emlPath);
}
}
</div></div></body></html>`;
}

File diff suppressed because one or more lines are too long