@odla-ai/chapter 0.60.0 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -122,7 +122,7 @@ interface ChapterCopy {
122
122
  availability: TextFields<"loading" | "loadFailed" | "saved" | "title" | "days" | "startHour" | "endHour" | "slotMinutes" | "minNoticeHours" | "windowDays" | "timezone" | "summaryTemplate" | "save"> & {
123
123
  dayLabels: readonly string[];
124
124
  };
125
- email: TextFields<"loading" | "loadFailed" | "saved" | "sent" | "testFailed" | "delivery" | "notificationAddress" | "replyTo" | "fromName" | "fromNameHint" | "spamRisk" | "debugInbox" | "debugInboxHint" | "sendTest" | "enabled" | "subject" | "body" | "save" | "sendLog" | "sentColumn" | "templateColumn" | "statusColumn" | "toColumn" | "failed" | "redirected" | "delivered">;
125
+ email: TextFields<"loading" | "loadFailed" | "saved" | "sent" | "testFailed" | "delivery" | "notificationAddress" | "replyTo" | "fromName" | "fromNameHint" | "spamRisk" | "debugInbox" | "debugInboxHint" | "sendTest" | "enabled" | "subject" | "body" | "htmlBody" | "htmlBodyHint" | "save" | "sendLog" | "sentColumn" | "templateColumn" | "statusColumn" | "toColumn" | "failed" | "redirected" | "delivered">;
126
126
  meetings: TextFields<"loading" | "loadFailed" | "cancelConfirm" | "newStartPrompt" | "parseFailed" | "agenda" | "empty" | "unknown" | "drift" | "meet" | "reschedule" | "cancel">;
127
127
  admission: TextFields<"title" | "description" | "email" | "decision" | "expires" | "note" | "waive" | "require" | "add" | "ledger" | "loading" | "consumed" | "revoked" | "expired" | "active" | "local" | "parent" | "revoke">;
128
128
  network: AdminNetworkCopy;
@@ -122,7 +122,7 @@ interface ChapterCopy {
122
122
  availability: TextFields<"loading" | "loadFailed" | "saved" | "title" | "days" | "startHour" | "endHour" | "slotMinutes" | "minNoticeHours" | "windowDays" | "timezone" | "summaryTemplate" | "save"> & {
123
123
  dayLabels: readonly string[];
124
124
  };
125
- email: TextFields<"loading" | "loadFailed" | "saved" | "sent" | "testFailed" | "delivery" | "notificationAddress" | "replyTo" | "fromName" | "fromNameHint" | "spamRisk" | "debugInbox" | "debugInboxHint" | "sendTest" | "enabled" | "subject" | "body" | "save" | "sendLog" | "sentColumn" | "templateColumn" | "statusColumn" | "toColumn" | "failed" | "redirected" | "delivered">;
125
+ email: TextFields<"loading" | "loadFailed" | "saved" | "sent" | "testFailed" | "delivery" | "notificationAddress" | "replyTo" | "fromName" | "fromNameHint" | "spamRisk" | "debugInbox" | "debugInboxHint" | "sendTest" | "enabled" | "subject" | "body" | "htmlBody" | "htmlBodyHint" | "save" | "sendLog" | "sentColumn" | "templateColumn" | "statusColumn" | "toColumn" | "failed" | "redirected" | "delivered">;
126
126
  meetings: TextFields<"loading" | "loadFailed" | "cancelConfirm" | "newStartPrompt" | "parseFailed" | "agenda" | "empty" | "unknown" | "drift" | "meet" | "reschedule" | "cancel">;
127
127
  admission: TextFields<"title" | "description" | "email" | "decision" | "expires" | "note" | "waive" | "require" | "add" | "ledger" | "loading" | "consumed" | "revoked" | "expired" | "active" | "local" | "parent" | "revoke">;
128
128
  network: AdminNetworkCopy;
@@ -1642,18 +1642,72 @@ function isRetiredEmailCopy(subject, text10) {
1642
1642
  // src/email.ts
1643
1643
  import { validateEmailCopy } from "@odla-ai/crm";
1644
1644
 
1645
+ // src/email-render.ts
1646
+ import { renderTemplate, templateLiveVars, templateSectionIssues, templateSectionNames, templateVars } from "@odla-ai/crm";
1647
+
1648
+ // src/email-html.ts
1649
+ var URL_RE = /https?:\/\/[^\s<>"')\]]+[^\s<>"').,;:!?\]]/g;
1650
+ function escapeHtml(s) {
1651
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1652
+ }
1653
+ function lineHtml(line) {
1654
+ let out = "", last = 0;
1655
+ for (const match of line.matchAll(URL_RE)) {
1656
+ out += escapeHtml(line.slice(last, match.index));
1657
+ const url = escapeHtml(match[0]);
1658
+ out += `<a href="${url}">${url}</a>`;
1659
+ last = match.index + match[0].length;
1660
+ }
1661
+ return out + escapeHtml(line.slice(last));
1662
+ }
1663
+ function textToHtml(text10) {
1664
+ const paragraphs = text10.replace(/\r\n/g, "\n").trim().split(/\n{2,}/).filter((paragraph) => paragraph.trim());
1665
+ const body = paragraphs.map((paragraph) => `<p>${paragraph.split("\n").map(lineHtml).join("<br>\n")}</p>`).join("\n");
1666
+ return `<!doctype html>
1667
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
1668
+ <body style="margin:0;padding:24px 16px;background:#ffffff;color:#1a1a1a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;font-size:16px;line-height:1.5">
1669
+ ${body}
1670
+ </body></html>`;
1671
+ }
1672
+
1673
+ // src/email-render.ts
1674
+ function render(template, vars) {
1675
+ return renderTemplate(template, vars);
1676
+ }
1677
+ var HTML_BODY_LIMIT = 2e5;
1678
+ function renderHtmlBody(template, vars) {
1679
+ const escaped = Object.fromEntries(Object.entries(vars).map(([name, value]) => [name, escapeHtml(value).replace(/\r?\n/g, "<br>")]));
1680
+ return renderTemplate(template, escaped);
1681
+ }
1682
+ function htmlBodyIssues(html, options) {
1683
+ const issues = [];
1684
+ if (html.length > HTML_BODY_LIMIT) issues.push({ code: "html-invalid", message: `Keep the HTML body within ${HTML_BODY_LIMIT.toLocaleString("en-US")} characters.` });
1685
+ for (const message of templateSectionIssues(html)) issues.push({ code: "section-invalid", message: `${message} (HTML body)` });
1686
+ const unknown = new Set(templateVars(html).filter((variable) => options.allowedVariables && !options.allowedVariables.includes(variable)));
1687
+ for (const variable of unknown) issues.push({ code: "variable-unknown", variable, message: `This event does not supply {{${variable}}} (HTML body).` });
1688
+ if (options.values) {
1689
+ for (const name of templateSectionNames(html)) if (typeof options.values[name] !== "string") issues.push({ code: "value-missing", variable: name, message: `Missing event value: ${name} (HTML body).` });
1690
+ for (const variable of templateLiveVars(html, options.values)) {
1691
+ if (unknown.has(variable)) continue;
1692
+ const value = options.values[variable];
1693
+ if (typeof value !== "string" || !value.trim() && !options.optionalVariables?.includes(variable)) issues.push({ code: "value-missing", variable, message: `Missing event value: ${variable} (HTML body).` });
1694
+ }
1695
+ }
1696
+ return issues;
1697
+ }
1698
+
1645
1699
  // src/email-events.ts
1646
1700
  var identity = ["firstName", "lastName", "email", "phone", "state"];
1647
1701
  var policy = ["refundPolicyText", "commitmentText", "normsText"];
1648
- var checkout = ["tier", "tierName", "paymentAmount", "paymentAmountSummary"];
1649
- var optionalVariables = ["phone", "state", "paymentAmount", "paymentAmountSummary"];
1702
+ var checkout = ["tier", "tierName", "paymentAmount", "paymentAmountSummary", "namedSeat", "namedSeatRecipientName"];
1703
+ var optionalVariables = ["phone", "state", "paymentAmount", "paymentAmountSummary", "namedSeat", "namedSeatRecipientName"];
1650
1704
  var event = (label, trigger, audience, variables, requiresTier = true) => ({ label, trigger, audience, variables: [...variables, ...policy], optionalVariables, requiresTier });
1651
1705
  var CHAPTER_EMAIL_EVENTS = {
1652
1706
  adminNotification: event("Application notification", "Application submitted or payment received, according to notification settings", "Administrator", [...identity, ...checkout, "adminUrl", "membersUrl"]),
1653
1707
  submitConfirmation: event("Application receipt", "Application submitted", "Applicant", [...identity, ...checkout, "submitNextStep", "membersUrl"]),
1654
1708
  paymentConfirmation: event("Payment confirmation", "Membership payment received", "Applicant", ["firstName", ...checkout, "paymentNextStep"]),
1655
1709
  prepEmail: event("Call confirmation", "Introduction call booked", "Applicant", [...identity, ...checkout, "startAt", "endAt", "timezone", "meetUrl", "htmlLink", "meetingTime", "meetingLink"]),
1656
- onboardingInvite: event("Membership welcome", "Membership approved", "Member", ["firstName", "membersUrl", "tierName"]),
1710
+ onboardingInvite: event("Membership welcome", "Membership approved", "Member", ["firstName", "membersUrl", "tierName", "namedSeat", "namedSeatRecipientName"]),
1657
1711
  namedSeatInvitation: event("Gift membership invitation", "A gift membership is ready to be accepted", "Gift recipient", ["firstName", "purchaserName", "chapterName", "acceptBy", "joinUrl", "tierName"]),
1658
1712
  seatAccepted: event("Gift accepted", "Gift recipient accepts their membership", "Purchaser", ["firstName", "purchaserName", "recipientName", "tierName", "chapterName", "membersUrl"])
1659
1713
  };
@@ -1668,9 +1722,6 @@ function chapterEmailEvents(chapter) {
1668
1722
  }
1669
1723
 
1670
1724
  // src/email.ts
1671
- function render(template, vars) {
1672
- return template.replace(/\{\{(\w+)\}\}/g, (_, key3) => vars[key3] ?? "");
1673
- }
1674
1725
  function groupVars(group, vars) {
1675
1726
  return {
1676
1727
  ...vars,
@@ -1703,7 +1754,9 @@ function planDelivery(input) {
1703
1754
  values: vars,
1704
1755
  tierId: vars.tierId
1705
1756
  });
1706
- if (issues.length) return { deliver: false, reason: "template-invalid", issues: issues.map((issue) => issue.message) };
1757
+ const html = typeof tpl.html === "string" && tpl.html.trim() ? tpl.html : void 0;
1758
+ const htmlIssues = html ? htmlBodyIssues(html, { allowedVariables: event2?.variables, optionalVariables: event2?.optionalVariables, values: vars }) : [];
1759
+ if (issues.length || htmlIssues.length) return { deliver: false, reason: "template-invalid", issues: [...issues, ...htmlIssues].map((issue) => issue.message) };
1707
1760
  const isProd = input.envName === "prod";
1708
1761
  const redirect = !isProd && !!input.group.debugEmail;
1709
1762
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
@@ -1716,32 +1769,9 @@ function planDelivery(input) {
1716
1769
  const text10 = redirect ? `(dev redirect; original recipient: ${input.to})
1717
1770
 
1718
1771
  ` + renderedText : renderedText;
1719
- return { deliver: true, transport, to, subject, text: text10, redirected: redirect };
1720
- }
1721
-
1722
- // src/email-html.ts
1723
- var URL_RE = /https?:\/\/[^\s<>"')\]]+[^\s<>"').,;:!?\]]/g;
1724
- function escapeHtml(s) {
1725
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1726
- }
1727
- function lineHtml(line) {
1728
- let out = "", last = 0;
1729
- for (const match of line.matchAll(URL_RE)) {
1730
- out += escapeHtml(line.slice(last, match.index));
1731
- const url = escapeHtml(match[0]);
1732
- out += `<a href="${url}">${url}</a>`;
1733
- last = match.index + match[0].length;
1734
- }
1735
- return out + escapeHtml(line.slice(last));
1736
- }
1737
- function textToHtml(text10) {
1738
- const paragraphs = text10.replace(/\r\n/g, "\n").trim().split(/\n{2,}/).filter((paragraph) => paragraph.trim());
1739
- const body = paragraphs.map((paragraph) => `<p>${paragraph.split("\n").map(lineHtml).join("<br>\n")}</p>`).join("\n");
1740
- return `<!doctype html>
1741
- <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
1742
- <body style="margin:0;padding:24px 16px;background:#ffffff;color:#1a1a1a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;font-size:16px;line-height:1.5">
1743
- ${body}
1744
- </body></html>`;
1772
+ const renderedHtml = html ? renderHtmlBody(html, vars) : void 0;
1773
+ const htmlOut = renderedHtml && redirect ? `<p style="font:13px monospace;color:#555;margin:0 0 12px">(dev redirect; original recipient: ${escapeHtml(input.to)})</p>` + renderedHtml : renderedHtml;
1774
+ return { deliver: true, transport, to, subject, text: text10, ...htmlOut ? { html: htmlOut } : {}, redirected: redirect };
1745
1775
  }
1746
1776
 
1747
1777
  // src/email-deliverability.ts
@@ -1873,10 +1903,14 @@ async function frozenCheckoutVariables(db, app) {
1873
1903
  if (Number.isSafeInteger(amount) && Number(amount) > 0 && typeof currency === "string" && /^[a-z]{3}$/.test(currency)) {
1874
1904
  formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(Number(amount) / 100);
1875
1905
  }
1906
+ const seat = quote?.namedSeat, seatCents = quote?.lineItems?.namedSeatCents;
1907
+ const namedSeatRecipientName = typeof seat?.recipientName === "string" ? seat.recipientName.trim() : "";
1876
1908
  return {
1877
1909
  paymentAmount: formatted,
1878
1910
  paymentAmountSummary: formatted ? ` of ${formatted}` : "",
1879
- tierName: typeof quote?.tierName === "string" ? quote.tierName : ""
1911
+ tierName: typeof quote?.tierName === "string" ? quote.tierName : "",
1912
+ namedSeat: namedSeatRecipientName ? Number.isSafeInteger(seatCents) && Number(seatCents) > 0 ? "paid" : "included" : "",
1913
+ namedSeatRecipientName
1880
1914
  };
1881
1915
  }
1882
1916
  async function catalogTierName(db, group, app) {
@@ -1934,6 +1968,8 @@ function sampleVariables(origin, timeZone, now = Date.now()) {
1934
1968
  purchaserName: "Sample Purchaser",
1935
1969
  recipientName: "Sample Recipient",
1936
1970
  chapterName: "Sample Chapter",
1971
+ namedSeat: "paid",
1972
+ namedSeatRecipientName: "Sample Recipient",
1937
1973
  acceptBy: new Date(now + 7 * 864e5).toISOString().slice(0, 10),
1938
1974
  joinUrl: `${origin}/join?seat=sample`
1939
1975
  };
@@ -2032,7 +2068,7 @@ async function sendTemplated(deps, input) {
2032
2068
  let messageId;
2033
2069
  try {
2034
2070
  const text10 = String(row2.body);
2035
- const html = deps.renderHtml === false ? void 0 : (deps.renderHtml ?? textToHtml)(text10);
2071
+ const html = deps.renderHtml === false ? void 0 : decision.html ?? (deps.renderHtml ?? textToHtml)(text10);
2036
2072
  const headers = deliverabilityHeaders({ event: event2, replyTo: input.group.replyTo });
2037
2073
  const response = await deps.sender.send({
2038
2074
  from: fromAddress(deps.from, input.group.fromName),
@@ -12508,7 +12544,7 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
12508
12544
  import { getConfig, saveConfig, validateEmailCopy as validateEmailCopy4 } from "@odla-ai/crm";
12509
12545
 
12510
12546
  // src/email-readiness.ts
12511
- import { isEmailTierName as isEmailTierName2, validateEmailCopy as validateEmailCopy3 } from "@odla-ai/crm";
12547
+ import { isEmailTierName as isEmailTierName2, templateSectionNames as templateSectionNames2, templateVars as templateVars2, validateEmailCopy as validateEmailCopy3 } from "@odla-ai/crm";
12512
12548
 
12513
12549
  // src/email-spam-signals.ts
12514
12550
  var TRIGGER_PHRASES = [
@@ -12545,6 +12581,7 @@ var phraseIn = (phrases, haystack) => phrases.find((phrase) => new RegExp(`(?:^|
12545
12581
  function emailSpamSignals(copy) {
12546
12582
  const subject = typeof copy.subject === "string" ? copy.subject : "";
12547
12583
  const text10 = typeof copy.text === "string" ? copy.text : "";
12584
+ const html = typeof copy.html === "string" ? copy.html : "";
12548
12585
  const out = [];
12549
12586
  const letters = strip(subject).replace(/[^A-Za-z]/g, "");
12550
12587
  const shouting = letters.length >= 6 && letters.replace(/[^A-Z]/g, "").length / letters.length > 0.5 || words(subject).some((word) => /^[A-Z]{4,}$/.test(word.replace(/[^A-Za-z]/g, "")));
@@ -12557,7 +12594,8 @@ function emailSpamSignals(copy) {
12557
12594
  if (bodyHit) out.push({ code: "body-trigger-words", message: `The body uses "${bodyHit}", a phrase filters weight against the sender.` });
12558
12595
  if (/(?:^|\s)[A-Z]{3,}(?:\s+[A-Z]{3,}){2,}(?=\s|$)/.test(strip(text10))) out.push({ code: "body-caps", message: "The body shouts a run of capitalised words; use sentence case." });
12559
12596
  if (/!{2,}/.test(text10)) out.push({ code: "body-punctuation", message: "Repeated exclamation marks in the body are a classic spam signal." });
12560
- const urls = [...text10.matchAll(URL_RE2)].map((match) => match[0]);
12597
+ const urls = [...`${text10}
12598
+ ${html}`.matchAll(URL_RE2)].map((match) => match[0].replace(/&quot;.*$/, ""));
12561
12599
  const hosts = urls.map((url) => {
12562
12600
  try {
12563
12601
  return new URL(url).hostname;
@@ -12602,10 +12640,14 @@ function validateEmailReadiness(input) {
12602
12640
  const template = input.templates[key3];
12603
12641
  const problems = !template ? [{ code: "template-missing", message: "No email is configured for this event." }] : validateEmailCopy3(template, { allowedVariables: event2.variables });
12604
12642
  if (template && typeof template.enabled !== "boolean") problems.push({ code: "enabled-missing", message: "Choose explicitly whether this email is enabled. Sending is blocked until then." });
12643
+ const html = typeof template?.html === "string" && template.html.trim() ? template.html : "";
12644
+ if (html) problems.push(...htmlBodyIssues(html, { allowedVariables: event2.variables }));
12605
12645
  if (template) {
12606
- const used = [...`${template.subject ?? ""}
12607
- ${template.text ?? ""}`.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
12608
- for (const name of used) if (name in input.groupValues && !input.groupValues[name]?.trim()) {
12646
+ const copy = `${template.subject ?? ""}
12647
+ ${template.text ?? ""}
12648
+ ${html}`;
12649
+ const sectionOnly = new Set(templateSectionNames2(copy));
12650
+ for (const name of templateVars2(copy)) if (name in input.groupValues && !input.groupValues[name]?.trim() && !sectionOnly.has(name)) {
12609
12651
  problems.push({ code: "value-missing", variable: name, message: `Add the ${name} used by this email.` });
12610
12652
  }
12611
12653
  }
@@ -12634,6 +12676,7 @@ async function emailSettings(db, ctx, env) {
12634
12676
  const emailTemplates = Object.fromEntries(Object.keys(events).filter((key3) => key3 in stored).map((key3) => [key3, {
12635
12677
  subject: str2(stored[key3]?.subject),
12636
12678
  text: str2(stored[key3]?.text),
12679
+ html: str2(stored[key3]?.html),
12637
12680
  enabled: stored[key3]?.enabled
12638
12681
  }]));
12639
12682
  let tiers = [], tiersUnavailable = false;
@@ -12680,12 +12723,14 @@ async function updateEmailSettings(db, ctx, env, body) {
12680
12723
  if (!value || typeof value !== "object" || Array.isArray(value)) return json({ error: `Invalid template: ${key3}` }, 400);
12681
12724
  const t = value;
12682
12725
  if (typeof t.enabled !== "boolean") return json({ error: `Choose whether ${key3} is enabled.` }, 400);
12683
- const subject = str2(t.subject).trim(), text10 = str2(t.text);
12726
+ const subject = str2(t.subject).trim(), text10 = str2(t.text), html = str2(t.html);
12727
+ if (t.html !== void 0 && typeof t.html !== "string") return json({ error: `Invalid HTML body: ${key3}` }, 400);
12684
12728
  const issues = validateEmailCopy4({ subject, text: text10 }, { allowedVariables: events[key3].variables });
12685
- if (t.enabled && issues.length || issues.some((issue) => ["subject-invalid", "body-invalid"].includes(issue.code))) {
12686
- return json({ error: `${events[key3].label} is incomplete.`, errors: issues.map((issue) => issue.message) }, 400);
12729
+ const htmlIssues = html.trim() ? htmlBodyIssues(html, { allowedVariables: events[key3].variables }) : [];
12730
+ if (t.enabled && (issues.length || htmlIssues.length) || issues.some((issue) => ["subject-invalid", "body-invalid"].includes(issue.code)) || html.length > HTML_BODY_LIMIT) {
12731
+ return json({ error: `${events[key3].label} is incomplete.`, errors: [...issues, ...htmlIssues].map((issue) => issue.message) }, 400);
12687
12732
  }
12688
- clean[key3] = { subject, text: text10, enabled: t.enabled };
12733
+ clean[key3] = { subject, text: text10, ...html.trim() ? { html } : {}, enabled: t.enabled };
12689
12734
  }
12690
12735
  const notificationEmail = str2(body.notificationEmail).trim(), replyTo = str2(body.replyTo).trim(), debugEmail = str2(body.debugEmail).trim();
12691
12736
  const emailish = /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/;