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.
451 lines
No EOL
21 KiB
TypeScript
451 lines
No EOL
21 KiB
TypeScript
// app/api/appointments/[id]/route.ts
|
||
// GET — single appointment (by id)
|
||
// PATCH — update status / upload signature / mark mail_sent_to_all
|
||
|
||
import { NextRequest, NextResponse } from "next/server";
|
||
import { fetchRecord, updateRecord, updateRecordMultipart } from "@/lib/pb";
|
||
import type { YsterbaardDirectorAppointment } from "@/lib/types";
|
||
import { writeFileSync, unlinkSync } from "fs";
|
||
import { spawn } from "child_process";
|
||
import { HIMALAYA_BIN, himalayaSendArgs } from "@/lib/himalaya";
|
||
|
||
const PDFD_URL = process.env.PDFD_URL || "http://127.0.0.1:3070";
|
||
|
||
export async function GET(
|
||
_req: NextRequest,
|
||
{ params }: { params: Promise<{ id: string }> }
|
||
) {
|
||
try {
|
||
const { id } = await params;
|
||
const record = await fetchRecord<YsterbaardDirectorAppointment>(
|
||
"ysterbaard_director_appointments",
|
||
id,
|
||
"director,company"
|
||
);
|
||
return NextResponse.json(record);
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return NextResponse.json({ error: msg }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
export async function PATCH(
|
||
req: NextRequest,
|
||
{ params }: { params: Promise<{ id: string }> }
|
||
) {
|
||
try {
|
||
const { id } = await params;
|
||
const body = await req.json();
|
||
|
||
// Signature upload — multipart
|
||
if (body.signature_base64) {
|
||
// Idempotency: if the distro has already run (or even started), do
|
||
// not re-send on retry. The status state machine is:
|
||
// pending -> mail_sent -> signature_received -> mail_sent_to_all
|
||
// "signature_received" is set as soon as the signature arrives, BEFORE
|
||
// the email fan-out completes. So we MUST short-circuit on either
|
||
// signature_received or mail_sent_to_all to prevent double-sends.
|
||
const existing = await fetchRecord<YsterbaardDirectorAppointment>(
|
||
"ysterbaard_director_appointments",
|
||
id
|
||
);
|
||
if (existing.status === "mail_sent_to_all" || existing.status === "signature_received") {
|
||
return NextResponse.json({ ok: true, already: true, status: existing.status });
|
||
}
|
||
|
||
// 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>(
|
||
"ysterbaard_director_appointments",
|
||
id,
|
||
{ status: "signature_received" },
|
||
[{ field: "signature", file: sigFile }]
|
||
);
|
||
|
||
// 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" });
|
||
} catch (distroErr) {
|
||
console.error("Distro failed (signature saved):", distroErr);
|
||
// Don't fail the request — signature is saved, distro is a follow-up
|
||
}
|
||
|
||
// Persist the signed PDF letter to PB so we have an on-file copy independent
|
||
// of email attachments. Done AFTER distro so a failed distro doesn't
|
||
// block signature persistence; also after status update so the audit trail
|
||
// reflects what actually happened.
|
||
try {
|
||
const apptForPdf = await fetchRecord<YsterbaardDirectorAppointment>(
|
||
"ysterbaard_director_appointments",
|
||
id,
|
||
"director,company"
|
||
);
|
||
const director = apptForPdf.expand?.director ?? ({} as Record<string, unknown>);
|
||
const company = apptForPdf.expand?.company ?? ({} as Record<string, unknown>);
|
||
const date = new Date().toISOString().split("T")[0];
|
||
const pdfFilename = `signed-acceptance-${((company.enterprise_name as string) || "Company").replace(/[^A-Za-z0-9]/g, "_")}.pdf`;
|
||
const pdf = await renderPdf(
|
||
signedLetterHtml(director, company, date, body.signature_base64),
|
||
pdfFilename
|
||
);
|
||
if (pdf) {
|
||
const pdfFile = new File([pdf], pdfFilename, { type: "application/pdf" });
|
||
await updateRecordMultipart<YsterbaardDirectorAppointment>(
|
||
"ysterbaard_director_appointments",
|
||
id,
|
||
{},
|
||
[{ field: "letter", file: pdfFile }]
|
||
);
|
||
}
|
||
} catch (pdfErr) {
|
||
console.error("Failed to persist signed letter to PB (distro still succeeded):", pdfErr);
|
||
}
|
||
|
||
return NextResponse.json(record);
|
||
}
|
||
|
||
// Simple status update
|
||
const updates: Record<string, unknown> = {};
|
||
if (body.status) updates.status = body.status;
|
||
if (body.letter_url) updates.letter = body.letter_url;
|
||
|
||
const record = await updateRecord<YsterbaardDirectorAppointment>(
|
||
"ysterbaard_director_appointments",
|
||
id,
|
||
updates
|
||
);
|
||
return NextResponse.json(record);
|
||
} catch (err: unknown) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return NextResponse.json({ error: msg }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
// ─── Signed-letter PDF via daemon ─────────────────────────────────────────
|
||
|
||
// Signed copy of the Consent to Act template. Mirrors the renderer in
|
||
// app/api/appointments/route.ts — they share the same body. Kept in sync
|
||
// by hand. The only delta from the initial letter is the embedded signature:
|
||
// the signUrl / "you may print and sign" copy is suppressed.
|
||
function signedLetterHtml(
|
||
director: Record<string, unknown>,
|
||
company: Record<string, unknown>,
|
||
date: string,
|
||
signatureBase64: string
|
||
): string {
|
||
const esc = (s: string) => String(s ?? "")
|
||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||
.replace(/"/g, """).replace(/'/g, "'");
|
||
const dir = {
|
||
name: esc([director.first_names, director.surname].filter(Boolean).join(" ")),
|
||
id: esc(String(director.id_no ?? "")),
|
||
email: esc(String(director.email ?? "")),
|
||
cell: esc(String(director.telephone ?? "")),
|
||
addr1: esc(String(director.residential_address_line_1 ?? "")),
|
||
addr2: esc(String(director.residential_address_line_2 ?? "")),
|
||
town: esc(String(director.residential_town_city ?? "")),
|
||
province: esc(String(director.residential_province ?? "")),
|
||
zip: esc(String(director.residential_postal_code ?? "")),
|
||
};
|
||
const co = {
|
||
name: esc(String(company.enterprise_name ?? "")),
|
||
p1: esc(String(company.physical_address_line_1 ?? "")),
|
||
p2: esc(String(company.physical_address_line_2 ?? "")),
|
||
pt: esc(String(company.physical_town_city ?? "")),
|
||
pp: esc(String(company.physical_province ?? "")),
|
||
pz: esc(String(company.physical_postal_code ?? "")),
|
||
};
|
||
const d = new Date(date);
|
||
const dayOfMonth = d.getUTCDate();
|
||
const monthNames = ["January","February","March","April","May","June","July","August","September","October","November","December"];
|
||
const monthName = monthNames[d.getUTCMonth()];
|
||
const year = d.getUTCFullYear();
|
||
|
||
return `<!DOCTYPE html>
|
||
<html><head><meta charset="utf-8"><title>Consent to Act as Director</title>
|
||
<style>
|
||
@page { size: A4; margin: 25mm; }
|
||
body { font-family: Arial, sans-serif; font-size: 11pt; color: #000; line-height: 1.4; margin: 0; }
|
||
.top-block { margin: 0 0 18pt 0; }
|
||
.top-right { float: right; text-align: left; min-width: 220pt; margin-left: 16pt; margin-bottom: 12pt; }
|
||
.top-left { text-align: left; }
|
||
.addressee-block { margin: 12pt 0 16pt 0; }
|
||
.addressee-name { font-weight: bold; margin-top: 12pt; }
|
||
.hdr { font-weight: bold; margin: 14pt 0 4pt 0; }
|
||
.re { margin: 8pt 0 12pt 0; font-weight: bold; }
|
||
.ol { margin: 4pt 0 4pt 16pt; padding: 0; }
|
||
.ol li { margin: 2pt 0; }
|
||
.sig-img { height: 60pt; display: block; margin: 8pt 0; }
|
||
.footer { margin-top: 24pt; font-size: 9pt; color: #555; text-align: center; font-style: italic; }
|
||
p { margin: 6pt 0; }
|
||
</style></head>
|
||
<body>
|
||
|
||
<div class="top-block">
|
||
<div class="top-right">
|
||
<div>${dir.name}</div>
|
||
<div>${dir.addr1}</div>
|
||
${dir.addr2 ? `<div>${dir.addr2}</div>` : ``}
|
||
<div>${[dir.town, dir.zip].filter(Boolean).join(", ")}</div>
|
||
${dir.province ? `<div>${dir.province}</div>` : ``}
|
||
<div>${dir.email}</div>
|
||
<div>${dir.cell}</div>
|
||
</div>
|
||
<div class="top-left">${date}</div>
|
||
<div style="clear:both;"></div>
|
||
</div>
|
||
|
||
<div class="addressee-block">
|
||
<div class="addressee-name">The Board</div>
|
||
<div>${co.name}</div>
|
||
<div>${co.p1}</div>
|
||
${co.p2 ? `<div>${co.p2}</div>` : ``}
|
||
<div>${[co.pt, co.pz].filter(Boolean).join(", ")}</div>
|
||
${co.pp ? `<div>${co.pp}</div>` : ``}
|
||
</div>
|
||
|
||
<div class="hdr">Dear Members of The Board</div>
|
||
<div class="re">RE: CONSENT TO ACT AS DIRECTOR</div>
|
||
|
||
<p>It is with great pleasure that I accept your offer to serve as Director of the Company in accordance with the provisions of The Companies Act 71 of 2008 (as amended) ("the Act").</p>
|
||
|
||
<p class="hdr">Eligibility to Act as Director</p>
|
||
<p>A person is ineligible to be a director if the person:</p>
|
||
<ol class="ol">
|
||
<li>is a juristic person;</li>
|
||
<li>is an unemancipated minor, or is under a similar legal disability; or</li>
|
||
<li>does not satisfy all of qualifications as set out in the Company’s Memorandum of Incorporation ("MOI").</li>
|
||
</ol>
|
||
<p>Section 69(7) of the Act.</p>
|
||
<p>A person is disqualified to be a director if the person:</p>
|
||
<ol class="ol">
|
||
<li>has been prohibited by a court to be a director or declared to be delinquent;</li>
|
||
<li>is an unrehabilitated insolvent; is prohibited in terms of any public regulation to be a director of the Company; has been removed from office on the grounds of misconduct involving dishonesty; or has been convicted and imprisoned without the option of a fine or fined more than the prescribed amount for theft, forgery, perjury or any offence involving fraud, misrepresentation or dishonesty in connection with the promotion, formation or management of a company.</li>
|
||
</ol>
|
||
<p>Section 69(8) of the Act.</p>
|
||
|
||
<p class="hdr">Disclosure of Personal Financial Interests</p>
|
||
<p>A personal financial interest means a direct material interest of financial, monetary, or economic nature, or to which a monetary value may be attributed, but does not include any interest held in a unit trust or collective investment scheme.</p>
|
||
<p>A director may at any time disclose a personal financial interest to the Board or the shareholders by delivering a written notice, setting out the nature and extent of that interest.</p>
|
||
<p>Section 75(4) of the Act.</p>
|
||
<p>If a director has or knows of a related person with a personal financial interest in respect to a matter being considered by the Board, they are obligated to disclose the general nature of that interest and any material information relating to the matter.</p>
|
||
<p>Section 75(5) of the Act.</p>
|
||
|
||
<p class="hdr">Duties and Responsibilities</p>
|
||
<p>A director must not use their position, or any information obtained while acting in that capacity to:</p>
|
||
<ol class="ol">
|
||
<li>(a) gain an advantage for themselves or any other person other than the Company or a wholly owned subsidiary of the Company; or knowingly cause harm to the Company or a subsidiary of the Company; and</li>
|
||
<li>(b) A director must communicate to the Board any information that comes to their attention, unless they: reasonably believe the information is immaterial, generally available to the public or known to the other directors; or are bound not to disclose that information by a legal or ethical obligation of confidentiality.</li>
|
||
</ol>
|
||
<p>Section 76(2) of the Act.</p>
|
||
<p>A director must also exercise their powers and perform their functions:</p>
|
||
<ol class="ol">
|
||
<li>in good faith and for a proper purpose;</li>
|
||
<li>in the best interests of the Company; and</li>
|
||
<li>with the degree of care, skill and diligence that may reasonably be expected of a director.</li>
|
||
</ol>
|
||
<p>Section 76(3) of the Act.</p>
|
||
|
||
<p class="hdr">Liability of a Director</p>
|
||
<p>A director may be held liable for any loss, damages or costs sustained by the Company in accordance with the principles of Common Law relating to:</p>
|
||
<ol class="ol">
|
||
<li>Fiduciary duty, in the case of a breach of duty as contemplated in Section 75, Section 76(2), Section 76(3)(a) or Section 76(3)(b); or</li>
|
||
<li>delict, in the case of a breach of duty as contemplated in Section 76(3)(c), any provision of the Act not mentioned in Section 77 or any provision of the MOI.</li>
|
||
</ol>
|
||
<p>Section 77(2) of the Act.</p>
|
||
<p>A director is also liable for any loss, damages or costs sustained by the Company as a direct or indirect consequence of them having:</p>
|
||
<ol class="ol">
|
||
<li>acted in the name of the Company, despite knowing they lacked the authority to do so;</li>
|
||
<li>carried on the business of the Company, despite knowing it was being conducted recklessly or with gross negligence as prohibited by Section 22(1);</li>
|
||
<li>been party to an act or omission by the Company, despite knowing it was for fraudulent purposes;</li>
|
||
<li>signed, consented to or authorised the publication of a false or misleading financial statement or prospectus;</li>
|
||
<li>failed to vote against the issuing, granting or provision of any unauthorised shares, securities, options or financial assistance.</li>
|
||
</ol>
|
||
<p>Section 77(3) of the Act.</p>
|
||
|
||
<p class="hdr">Consent to Act as Director</p>
|
||
<p>I, ${dir.name} (ID ${dir.id}), hereby consent to act as Director of the Company in accordance with the provisions of Section 66(7)(b) of the Act. I confirm that I am eligible, that I am not disqualified, and that I have familiarised myself with, understand and consider myself bound by the duties, responsibilities and liabilities of this office as set out by the Act, Common Law and any other legislation which regulates this office from time to time.</p>
|
||
|
||
<p>Signed at ${dir.town || "_____________"} on this ${dayOfMonth} day of ${monthName} ${year}.</p>
|
||
|
||
<img class="sig-img" src="data:image/png;base64,${signatureBase64}" alt="Signature"/>
|
||
|
||
<p>
|
||
<strong>${dir.name}</strong><br/>
|
||
ID ${dir.id}
|
||
</p>
|
||
|
||
<div class="footer">
|
||
Filed in terms of section 66(7)(b) of the Companies Act 71 of 2008
|
||
</div>
|
||
|
||
</body></html>`;
|
||
}
|
||
|
||
/** Render the PDF, or return null if the local renderer is down.
|
||
*
|
||
* The pdfd service on PDFD_URL is often not running. Throwing here would
|
||
* abort the request before himalaya is called and the letter would be lost,
|
||
* so a missing attachment is logged and degraded to an HTML-only email. */
|
||
async function renderPdf(html: string, filename: string): Promise<Buffer | null> {
|
||
try {
|
||
const res = await fetch(`${PDFD_URL}/pdf`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ html, filename }),
|
||
signal: AbortSignal.timeout(20000),
|
||
});
|
||
if (!res.ok) {
|
||
console.error(`[appointments/id] pdfd returned ${res.status} — sending without attachment`);
|
||
return null;
|
||
}
|
||
const data = (await res.json()) as { pdf: string };
|
||
return Buffer.from(data.pdf, "base64");
|
||
} catch (e) {
|
||
console.error("[appointments/id] PDF render failed — sending without attachment:", e instanceof Error ? e.message : e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ─── Send signed letter to all directors + company ─────────────────────────
|
||
|
||
async function sendSignedDistro(opts: { id: string; signatureBase64: string }) {
|
||
const appt = await fetchRecord<YsterbaardDirectorAppointment>(
|
||
"ysterbaard_director_appointments",
|
||
opts.id,
|
||
"director,company"
|
||
);
|
||
|
||
const director = appt.expand?.director;
|
||
const company = appt.expand?.company;
|
||
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];
|
||
|
||
// 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");
|
||
const junctions = await fetchDirectorsForCompany(company!.id) as Array<{
|
||
expand?: { director?: { email?: string } };
|
||
}>;
|
||
for (const j of junctions) {
|
||
const em = j.expand?.director?.email;
|
||
if (em) recipients.push(em);
|
||
}
|
||
const companyEmail = (company as { company_email?: string } | undefined)?.company_email;
|
||
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(director ?? {}, company ?? {}, 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,
|
||
pdfBytes,
|
||
pdfFilename,
|
||
});
|
||
}
|
||
}
|
||
|
||
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} signed consent to act as director of ${companyName}`;
|
||
|
||
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(
|
||
HIMALAYA_BIN,
|
||
himalayaSendArgs("dyfins"),
|
||
{ 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;">Consent to Act as Director — Signed</h2>
|
||
<p>I am glad to inform you that <strong>${directorName}</strong> has signed the Consent to Act 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;">CONSENT TO ACT AS DIRECTOR</h3>
|
||
<hr style="border:none;border-top:1px solid #333;margin:8px 0 16px;" />
|
||
<p><strong>${directorName}</strong> hereby consents to act as Director of <strong>${companyName}</strong> in accordance with the provisions of Section 66(7)(b) of the Companies Act 71 of 2008.</p>
|
||
<p>The consent was signed on ${date} and is filed in terms of the Act.</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;color:#888;font-style:italic;">[Signature on file — see attached signed letter]</p>
|
||
</div>
|
||
<p style="margin-top:16px;font-size:12px;color:#666;">The full signed letter is attached as a PDF.</p>
|
||
</div></div></body></html>`;
|
||
} |