/* Every email ThreadCount can send to a customer, rendered with sample data for review. * * npx tsx scripts/preview-all-mail.ts → .scratch/mail-preview/all/{index.html, *.html, all.json} * node scripts/send-mail-previews.cjs
→ sends all.json through the box's SMTP (run on the box) * * Covers the coordinator emails (welcome, password reset, the plans notice), the billing emails * (lib/billing-mail.cjs) and the staff notices. Every one carries plain text and HTML; the text is * the original wording, the HTML the shared layout (lib/mail-html.cjs). Sample names are the fictional Riverside General * Hospital; addresses are example.com. Internal alerts (contact form, ops) are not customer mail. */ import fs from "node:fs"; import path from "node:path"; import { approvalEmail, decisionEmail, readyEmail, roundEmail, waitlistEmail } from "../lib/approvallink"; import { resetEmail } from "../lib/reset"; import { welcomeEmail } from "../lib/accountmail"; import { PRICES, TRIAL_DAYS } from "../lib/plan"; // eslint-disable-next-line @typescript-eslint/no-require-imports const billing = { templates: require("../lib/billing-mail.cjs") }; type Mail = { key: string; group: string; subject: string; text: string; html?: string; when: string }; const out: Mail[] = []; const add = (key: string, group: string, when: string, m: { subject: string; text: string; html?: string }) => out.push({ key, group, when, ...m }); const site = "https://threadcount.tech"; const fac = "Riverside General Hospital"; const ctx = { facility: fac, contact: "billing@example.com" }; const lines = [{ qty: 2, item: "Scrub top, navy", size: "M", status: "approved" }, { qty: 1, item: "Fleece jacket", size: "L", status: "declined", reason: "Already holds two" }]; // ---- coordinator add("welcome", "Coordinator", "Right after sign-up, to the coordinator's address", welcomeEmail("Dana", fac)); add("password-reset", "Coordinator", "When someone asks for a reset", resetEmail("Dana", `${site}/reset?token=example`)); { // The notice script owns its words; render its html the same way it does when sending. // eslint-disable-next-line @typescript-eslint/no-require-imports const { layout } = require("../lib/mail-html.cjs") as { layout: (o: Record) => { html: string } }; const text = [`Hi Dana,`, "", `From 12 November 2026 ThreadCount will charge new facilities for hosting. ${fac} was set up before that, so it stays exactly as it is: hosted, every feature, free, for as long as it exists. Nothing changes for you and there is nothing to do.`, "", "The pricing page explains what new facilities will pay and why. Reply to this email if you have a question.", "", "— ThreadCount"].join("\n"); add("plans-notice", "Coordinator", "One-off notice sent 13 Sep 2026 to the facilities that existed before plans", { subject: "ThreadCount is introducing plans on 12 November 2026. Yours stays free.", text, html: layout({ eyebrow: "Plans", title: "Yours stays free", preheader: `${fac} stays hosted, with everything, free.`, intro: [`Hi Dana,`, `From 12 November 2026 ThreadCount will charge new facilities for hosting. ${fac} was set up before that, so it stays exactly as it is: hosted, every feature, free, for as long as it exists. Nothing changes for you and there is nothing to do.`], closing: ["The pricing page explains what new facilities will pay and why. Reply to this email if you have a question."], cta: { label: "Read the Fees section", href: `${site}/terms` }, footer: { facility: fac, links: [["Pricing", `${site}/pricing`], ["Support", `${site}/support`]] } }).html, }); } // ---- billing (HTML) — the same sample calls as scripts/preview-billing-mail.cjs const T = billing.templates as unknown as Record) => { subject: string; text: string; html: string }>; const DAY = 86_400_000; const now = new Date("2026-09-13T00:00:00+10:00"); const trialEnd = new Date(now.getTime() + TRIAL_DAYS * DAY); const paidUntil = new Date("2026-10-13T00:00:00+10:00"); const card = { brand: "Visa", last4: "4242" }; const money = { monthlyCents: PRICES.hostedMonthly * 100, annualCents: PRICES.hostedAnnual * 100 }; const tries: [string, string, () => { subject: string; text: string; html: string }][] = [ ["trial-started", "Right after a trial sign-up", () => T.trialStarted(ctx, { first: "Dana", endsAt: trialEnd })], ["trial-day-1", "Day 1 of the trial: get the register in (daily job)", () => T.trialTip(ctx, { day: 1, endsAt: trialEnd, ...money })], ["trial-day-3", "Day 3: the counter app (daily job)", () => T.trialTip(ctx, { day: 3, endsAt: trialEnd, ...money })], ["trial-day-7", "Day 7: reports and the month-end pack (daily job)", () => T.trialTip(ctx, { day: 7, endsAt: trialEnd, ...money })], ["trial-day-21", "Day 21: what a plan includes (daily job)", () => T.trialTip(ctx, { day: 21, endsAt: trialEnd, ...money })], ["trial-day-28", "Day 28: two days left (daily job)", () => T.trialTip(ctx, { day: 28, endsAt: trialEnd, ...money })], ["trial-7-days", "7 days before the trial ends (daily job)", () => T.trialEndingSoon(ctx, { daysLeft: 7, endsAt: trialEnd, graceEndsAt: new Date(trialEnd.getTime() + 14 * DAY), ...money })], ["trial-1-day", "The day before the trial ends (daily job)", () => T.trialEndingSoon(ctx, { daysLeft: 1, endsAt: trialEnd, graceEndsAt: new Date(trialEnd.getTime() + 14 * DAY), ...money })], ["trial-ended", "The day the trial ends: 14 days of grace begin (daily job)", () => T.trialEnded(ctx, { endsAt: trialEnd, graceEndsAt: new Date(trialEnd.getTime() + 14 * DAY), ...money })], ["payment-received-monthly", "After each successful monthly card payment (webhook)", () => T.paymentReceived(ctx, { cadence: "month", periodStart: now, periodEnd: paidUntil, subtotalCents: 12900, taxCents: 1290, totalCents: 14190, currency: "AUD", card, invoiceNumber: "TC-0001-0001", invoiceUrl: `${site}/app/settings?tab=plan`, nextChargeAt: paidUntil, nextChargeCents: 14190 })], ["payment-received-yearly", "After a yearly card payment (webhook)", () => T.paymentReceived(ctx, { cadence: "year", periodStart: now, periodEnd: new Date("2027-09-13T00:00:00+10:00"), subtotalCents: 129000, taxCents: 12900, totalCents: 141900, currency: "AUD", card, invoiceNumber: "TC-0001-0002", invoiceUrl: `${site}/app/settings?tab=plan`, nextChargeAt: new Date("2027-09-13T00:00:00+10:00"), nextChargeCents: 141900 })], ["payment-failed", "When a renewal charge fails (webhook)", () => T.paymentFailed(ctx, { cadence: "month", totalCents: 14190, currency: "AUD", card, paidUntil, graceEndsAt: new Date(paidUntil.getTime() + 14 * DAY), reason: "the card was declined" })], ["plan-will-end", "When the facility stops the plan at period end", () => T.subscriptionCancelled(ctx, { scheduled: true, endsAt: paidUntil, graceEndsAt: new Date(paidUntil.getTime() + 14 * DAY) })], ["plan-ended", "When the subscription actually ends", () => T.subscriptionCancelled(ctx, { scheduled: false, endsAt: paidUntil, graceEndsAt: new Date(paidUntil.getTime() + 14 * DAY) })], ["read-only", "The day grace runs out (daily job)", () => T.readOnlyNow(ctx, { wasTrial: true, endsAt: trialEnd, ...money })], ["card-updated", "After the card on file is changed", () => T.cardUpdated(ctx, { card, nextChargeAt: paidUntil })], ]; for (const [key, when, fn] of tries) { try { add(key, "Billing", when, fn()); } catch (e) { add(key, "Billing", when, { subject: `(render failed: ${(e as Error).message})`, text: "" }); } } // ---- staff notices (plain text plus the shared HTML layout) add("staff-approval-needed", "Staff", "To a manager when a wearer asks for uniform", approvalEmail({ managerFirst: "Priya", subjectName: "Ali Hassan", lines, reason: "Starting on the ward", note: "", url: `${site}/a/example`, facility: fac })); add("staff-approval-needed-on-behalf", "Staff", "To a manager when the counter raises a request for someone", approvalEmail({ managerFirst: "Priya", subjectName: "Ali Hassan", raisedByName: "Dana (linen room)", lines, reason: "", note: "Replacement after damage", url: `${site}/a/example`, facility: fac })); add("staff-approved", "Staff", "To the wearer when the manager approves", decisionEmail({ staffFirst: "Ali", managerName: "Priya Nair", approved: true, lines: lines.filter((l) => l.status === "approved"), facility: fac })); add("staff-partly-approved", "Staff", "To the wearer when part of the request is declined", decisionEmail({ staffFirst: "Ali", managerName: "Priya Nair", approved: true, lines, facility: fac })); add("staff-declined", "Staff", "To the wearer when the request is declined", decisionEmail({ staffFirst: "Ali", managerName: "Priya Nair", approved: false, reason: "Over the entitlement for this year", lines, facility: fac })); const noteLines = [{ qty: 2, item: "Scrub top, navy", size: "M" }]; add("staff-ready", "Staff", "To the wearer when the bag is ready at the linen room", readyEmail({ first: "Ali", code: "R-1042", lines: noteLines, collectCode: "7391", holdUntil: "Fri 20 Sep", foot: `${fac} · ThreadCount` })); add("staff-round", "Staff", "To the wearer when the bag goes out on the ward round", roundEmail({ first: "Ali", code: "R-1042", lines: noteLines, dept: "Ward 4B", foot: `${fac} · ThreadCount` })); add("staff-waitlist", "Staff", "To the wearer when a size they waited for comes in", waitlistEmail({ item: "Scrub top, navy", size: "M", until: "Thu 19 Sep, 4:00 pm", foot: `${fac} · ThreadCount` })); // ---- write const dir = path.join(process.cwd(), ".scratch", "mail-preview", "all"); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, "all.json"), JSON.stringify(out, null, 1)); const esc = (s: string) => s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c] as string)); for (const m of out) fs.writeFileSync(path.join(dir, m.key + ".html"), m.html || `
${esc(m.text)}
`); console.log(`${out.length} emails rendered → ${dir} (${out.filter((m) => m.html).length} with html)`); for (const m of out) console.log(` ${m.group.padEnd(11)} ${m.key.padEnd(34)} ${m.subject}`);