Fix signed distro: send HTML email with inline signature image (no PDF required)
This commit is contained in:
parent
ca697a3b19
commit
8ae44e42e4
2 changed files with 53 additions and 150 deletions
|
|
@ -36,6 +36,7 @@ export async function PATCH(
|
|||
|
||||
// Signature upload — multipart
|
||||
if (body.signature_base64) {
|
||||
// Save signature to PB first (no letter required)
|
||||
const buf = Buffer.from(body.signature_base64, "base64");
|
||||
const sigFile = new File([buf], "signature.png", { type: "image/png" });
|
||||
const record = await updateRecordMultipart<YsterbaardDirectorAppointment>(
|
||||
|
|
@ -45,8 +46,14 @@ export async function PATCH(
|
|||
[{ field: "signature", file: sigFile }]
|
||||
);
|
||||
|
||||
// Build signed letter and send to all directors + company
|
||||
await sendSignedLetterToAll(id);
|
||||
// Distro email to all directors + company (signature image embedded inline)
|
||||
try {
|
||||
await sendSignedDistro({ id, signatureBase64: body.signature_base64 });
|
||||
await updateRecord("ysterbaard_director_appointments", id, { status: "mail_sent_to_all" });
|
||||
} catch (distroErr) {
|
||||
console.error("Distro failed (signature saved):", distroErr);
|
||||
// Don't fail the request — signature is saved, distro is a follow-up
|
||||
}
|
||||
|
||||
return NextResponse.json(record);
|
||||
}
|
||||
|
|
@ -70,193 +77,89 @@ export async function PATCH(
|
|||
|
||||
// ─── Send signed letter to all directors + company ─────────────────────────
|
||||
|
||||
async function sendSignedLetterToAll(appointmentId: string) {
|
||||
// Load appointment with director + company expanded
|
||||
// ─── Send signed acceptance email (HTML with inline signature) ────────────
|
||||
|
||||
async function sendSignedDistro(opts: { id: string; signatureBase64: string }) {
|
||||
const appt = await fetchRecord<YsterbaardDirectorAppointment>(
|
||||
"ysterbaard_director_appointments",
|
||||
appointmentId,
|
||||
opts.id,
|
||||
"director,company"
|
||||
);
|
||||
|
||||
const director = appt.expand?.director;
|
||||
const company = appt.expand?.company;
|
||||
const directorName = [director?.first_names, director?.surname].filter(Boolean).join(" ");
|
||||
const directorName = [director?.first_names, director?.surname].filter(Boolean).join(" ") || "The Director";
|
||||
const companyName = company?.enterprise_name || "the Company";
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
|
||||
// Load the original letter PDF from PB
|
||||
const { PB_URL, PB_TOKEN } = await import("@/lib/pb");
|
||||
const letterUrl = appt.letter;
|
||||
const sigUrl = appt.signature;
|
||||
|
||||
if (!letterUrl || !sigUrl) throw new Error("Letter or signature missing");
|
||||
|
||||
// Download both files
|
||||
const letterPath = `/tmp/signed-letter-${appointmentId}.pdf`;
|
||||
const sigPath = `/tmp/sig-img-${appointmentId}.png`;
|
||||
|
||||
await downloadFile(`${PB_URL}/api/files/ysterbaard_director_appointments/${appointmentId}/${letterUrl}`, letterPath, PB_TOKEN);
|
||||
await downloadFile(`${PB_URL}/api/files/ysterbaard_director_appointments/${appointmentId}/${sigUrl}`, sigPath, PB_TOKEN);
|
||||
|
||||
// Build signed HTML and convert to PDF
|
||||
const signedHtml = buildSignedLetterHtml({
|
||||
directorName,
|
||||
companyName,
|
||||
sigImagePath: sigPath,
|
||||
date: new Date().toISOString().split("T")[0],
|
||||
});
|
||||
const signedHtmlPath = `/tmp/signed-letter-${appointmentId}.html`;
|
||||
writeFileSync(signedHtmlPath, signedHtml, "utf8");
|
||||
const signedPdfPath = await htmlToPdf(signedHtmlPath);
|
||||
|
||||
// Get all current directors of this company from the junction
|
||||
// Build recipient list: all directors of this company + company email
|
||||
const recipients: string[] = [];
|
||||
if (director?.email) recipients.push(director.email);
|
||||
const { fetchDirectorsForCompany } = await import("@/lib/pb");
|
||||
const junctions = await fetchDirectorsForCompany(company!.id) as Array<{
|
||||
expand?: { director?: { email?: string; first_names?: string; surname?: string } };
|
||||
expand?: { director?: { email?: string } };
|
||||
}>;
|
||||
const allDirectorEmails: string[] = [];
|
||||
for (const j of junctions) {
|
||||
const d = j.expand?.director as { email?: string; telephone?: string; first_names?: string; surname?: string } | undefined;
|
||||
if (d?.email) allDirectorEmails.push(d.email);
|
||||
const em = j.expand?.director?.email;
|
||||
if (em) recipients.push(em);
|
||||
}
|
||||
|
||||
// Add company email if available
|
||||
const companyEmail = (company as { company_email?: string } | undefined)?.company_email;
|
||||
const uniqueEmails = [...new Set(companyEmail ? [...allDirectorEmails, companyEmail] : allDirectorEmails)];
|
||||
if (companyEmail) recipients.push(companyEmail);
|
||||
|
||||
// Send to each director email
|
||||
for (const email of uniqueEmails) {
|
||||
await sendSignedEmail({
|
||||
to: email,
|
||||
directorName,
|
||||
companyName,
|
||||
signedPdfPath,
|
||||
});
|
||||
const uniqueEmails = [...new Set(recipients.filter(Boolean))];
|
||||
|
||||
if (uniqueEmails.length === 0) {
|
||||
console.error("sendSignedDistro: no recipients");
|
||||
return;
|
||||
}
|
||||
|
||||
// Update status to mail_sent_to_all
|
||||
await updateRecord("ysterbaard_director_appointments", appointmentId, {
|
||||
status: "mail_sent_to_all",
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
try { unlinkSync(letterPath); unlinkSync(sigPath); unlinkSync(signedHtmlPath); unlinkSync(signedPdfPath); } catch { /* ok */ }
|
||||
for (const to of uniqueEmails) {
|
||||
await sendSignedEmail({ to, directorName, companyName, date, signatureBase64: opts.signatureBase64 });
|
||||
}
|
||||
}
|
||||
|
||||
function buildSignedLetterHtml(opts: {
|
||||
directorName: string;
|
||||
companyName: string;
|
||||
sigImagePath: string;
|
||||
date: string;
|
||||
}): string {
|
||||
const { directorName, companyName, sigImagePath, date } = opts;
|
||||
const { readFileSync } = require("fs");
|
||||
const sigB64 = readFileSync(sigImagePath).toString("base64");
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><style>
|
||||
body { font-family: Arial, sans-serif; font-size: 12pt; margin: 40px 60px; line-height: 1.6; color: #222; }
|
||||
.right { text-align: right; }
|
||||
h1 { font-size: 14pt; text-align: center; margin-bottom: 4px; font-weight: bold; }
|
||||
hr { border: none; border-top: 1px solid #333; margin: 8px 0 16px; }
|
||||
p { margin: 8px 0; }
|
||||
.sig-block { margin-top: 32px; }
|
||||
.sig-line { border-top: 1px solid #333; width: 260px; margin-top: 48px; }
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="right">
|
||||
<p><strong>${directorName}</strong></p>
|
||||
<p>${date}</p>
|
||||
</div>
|
||||
<h1>LETTER OF APPOINTMENT AS DIRECTOR</h1>
|
||||
<hr />
|
||||
<p>Dear ${directorName},</p>
|
||||
<p>We are pleased to confirm that you have been appointed as a Director of <strong>${companyName}</strong>.</p>
|
||||
<p>Your appointment was accepted on ${date} and is hereby recorded in terms of section 70 of the Companies Act 71 of 2008.</p>
|
||||
<div class="sig-block">
|
||||
<p>Yours sincerely,<br/><strong>The Board of ${companyName}</strong></p>
|
||||
<div class="sig-line"></div>
|
||||
<p><strong>${directorName}</strong><br/>Director</p>
|
||||
<p style="margin-top:8px;"><img src="cid:signature@apphub.co.za" alt="Signature" style="height:60px;" /></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function sendSignedEmail(opts: { to: string; directorName: string; companyName: string; signedPdfPath: string }) {
|
||||
const { to, directorName, companyName, signedPdfPath } = opts;
|
||||
async function sendSignedEmail(opts: { to: string; directorName: string; companyName: string; date: string; signatureBase64: string }) {
|
||||
const { to, directorName, companyName, date, signatureBase64 } = opts;
|
||||
const subject = `${directorName} accepted the appointment as director of ${companyName}`;
|
||||
const body = `I am glad to inform you that ${directorName} accepted the appointment of ${companyName}.
|
||||
|
||||
Find attached the signed acceptance letter.
|
||||
|
||||
Regards`;
|
||||
|
||||
const boundary = "----YSTERBAARD" + Date.now();
|
||||
const pdfBuf = readFileSync(signedPdfPath);
|
||||
const pdfB64 = pdfBuf.toString("base64");
|
||||
|
||||
const eml = `To: ${to}\r
|
||||
From: SJ@Dyfins.com\r
|
||||
Subject: ${subject}\r
|
||||
MIME-Version: 1.0\r
|
||||
Content-Type: multipart/mixed; boundary="${boundary}"\r
|
||||
Content-Type: text/html; charset="utf-8"\r
|
||||
\r
|
||||
--${boundary}\r
|
||||
Content-Type: text/plain; charset="utf-8"\r
|
||||
\r
|
||||
${body}\r
|
||||
--${boundary}\r
|
||||
Content-Type: application/pdf; name="signed-acceptance-letter.pdf"\r
|
||||
Content-Transfer-Encoding: base64\r
|
||||
Content-Disposition: attachment; filename="signed-acceptance-letter.pdf"\r
|
||||
\r
|
||||
${pdfB64}\r
|
||||
--${boundary}--\r
|
||||
<!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>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>
|
||||
<hr style="border:none;border-top:1px solid #333;margin:8px 0 16px;" />
|
||||
<p>We are pleased to confirm that <strong>${directorName}</strong> has been appointed as a Director of <strong>${companyName}</strong>.</p>
|
||||
<p>The appointment was accepted on ${date} and is hereby recorded in terms of section 70 of the Companies Act 71 of 2008.</p>
|
||||
<div style="margin-top:24px;border-top:1px solid #333;padding-top:8px;width:260px;">
|
||||
<p style="margin:0;"><strong>${directorName}</strong></p>
|
||||
<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()}.eml`;
|
||||
const emlPath = `/tmp/signed-email-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.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(); }); });
|
||||
await new Promise<void>((res) => { himalaya.on("close", () => { res(); }); });
|
||||
if (err.trim()) console.error("himalaya stderr:", err.trim());
|
||||
} finally {
|
||||
unlinkSync(emlPath);
|
||||
}
|
||||
}
|
||||
|
||||
async function htmlToPdf(htmlPath: string): Promise<string> {
|
||||
const pdfPath = htmlPath.replace(".html", ".pdf");
|
||||
try {
|
||||
execSync(
|
||||
`"/Applications/LibreOffice.app/Contents/MacOS/soffice" --headless --convert-to pdf --outdir /tmp/ "${htmlPath}"`,
|
||||
{ timeout: 30 }
|
||||
);
|
||||
} catch {
|
||||
execSync(`soffice --headless --convert-to pdf --outdir /tmp/ "${htmlPath}"`, { timeout: 30 });
|
||||
}
|
||||
return pdfPath;
|
||||
}
|
||||
|
||||
async function downloadFile(url: string, destPath: string, token?: string) {
|
||||
const { PB_TOKEN } = await import("@/lib/pb");
|
||||
const authToken = token || PB_TOKEN;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: authToken as string },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to download ${url}: ${res.status}`);
|
||||
const buf = await res.arrayBuffer();
|
||||
require("fs").writeFileSync(destPath, Buffer.from(buf));
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue