@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.
package/dist/index.js CHANGED
@@ -1404,6 +1404,8 @@ var DEFAULT_ADMIN_COPY = {
1404
1404
  enabled: "Enabled",
1405
1405
  subject: "Subject",
1406
1406
  body: "Body",
1407
+ htmlBody: "HTML body (optional)",
1408
+ htmlBodyHint: "Sent beside the plain text when present, with the same {{placeholders}} and {{#name}} sections; values are escaped. Leave empty to send the plain text rendered as HTML.",
1407
1409
  save: "Save changes",
1408
1410
  sendLog: "Send log",
1409
1411
  sentColumn: "Sent",
@@ -7337,18 +7339,72 @@ function isRetiredEmailCopy(subject, text8) {
7337
7339
  // src/email.ts
7338
7340
  import { validateEmailCopy } from "@odla-ai/crm";
7339
7341
 
7342
+ // src/email-render.ts
7343
+ import { renderTemplate, templateLiveVars, templateSectionIssues, templateSectionNames, templateVars } from "@odla-ai/crm";
7344
+
7345
+ // src/email-html.ts
7346
+ var URL_RE = /https?:\/\/[^\s<>"')\]]+[^\s<>"').,;:!?\]]/g;
7347
+ function escapeHtml(s) {
7348
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
7349
+ }
7350
+ function lineHtml(line) {
7351
+ let out = "", last = 0;
7352
+ for (const match of line.matchAll(URL_RE)) {
7353
+ out += escapeHtml(line.slice(last, match.index));
7354
+ const url = escapeHtml(match[0]);
7355
+ out += `<a href="${url}">${url}</a>`;
7356
+ last = match.index + match[0].length;
7357
+ }
7358
+ return out + escapeHtml(line.slice(last));
7359
+ }
7360
+ function textToHtml(text8) {
7361
+ const paragraphs = text8.replace(/\r\n/g, "\n").trim().split(/\n{2,}/).filter((paragraph) => paragraph.trim());
7362
+ const body = paragraphs.map((paragraph) => `<p>${paragraph.split("\n").map(lineHtml).join("<br>\n")}</p>`).join("\n");
7363
+ return `<!doctype html>
7364
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
7365
+ <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">
7366
+ ${body}
7367
+ </body></html>`;
7368
+ }
7369
+
7370
+ // src/email-render.ts
7371
+ function render(template, vars) {
7372
+ return renderTemplate(template, vars);
7373
+ }
7374
+ var HTML_BODY_LIMIT = 2e5;
7375
+ function renderHtmlBody(template, vars) {
7376
+ const escaped = Object.fromEntries(Object.entries(vars).map(([name, value]) => [name, escapeHtml(value).replace(/\r?\n/g, "<br>")]));
7377
+ return renderTemplate(template, escaped);
7378
+ }
7379
+ function htmlBodyIssues(html, options) {
7380
+ const issues = [];
7381
+ if (html.length > HTML_BODY_LIMIT) issues.push({ code: "html-invalid", message: `Keep the HTML body within ${HTML_BODY_LIMIT.toLocaleString("en-US")} characters.` });
7382
+ for (const message of templateSectionIssues(html)) issues.push({ code: "section-invalid", message: `${message} (HTML body)` });
7383
+ const unknown = new Set(templateVars(html).filter((variable) => options.allowedVariables && !options.allowedVariables.includes(variable)));
7384
+ for (const variable of unknown) issues.push({ code: "variable-unknown", variable, message: `This event does not supply {{${variable}}} (HTML body).` });
7385
+ if (options.values) {
7386
+ 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).` });
7387
+ for (const variable of templateLiveVars(html, options.values)) {
7388
+ if (unknown.has(variable)) continue;
7389
+ const value = options.values[variable];
7390
+ if (typeof value !== "string" || !value.trim() && !options.optionalVariables?.includes(variable)) issues.push({ code: "value-missing", variable, message: `Missing event value: ${variable} (HTML body).` });
7391
+ }
7392
+ }
7393
+ return issues;
7394
+ }
7395
+
7340
7396
  // src/email-events.ts
7341
7397
  var identity = ["firstName", "lastName", "email", "phone", "state"];
7342
7398
  var policy = ["refundPolicyText", "commitmentText", "normsText"];
7343
- var checkout = ["tier", "tierName", "paymentAmount", "paymentAmountSummary"];
7344
- var optionalVariables = ["phone", "state", "paymentAmount", "paymentAmountSummary"];
7399
+ var checkout = ["tier", "tierName", "paymentAmount", "paymentAmountSummary", "namedSeat", "namedSeatRecipientName"];
7400
+ var optionalVariables = ["phone", "state", "paymentAmount", "paymentAmountSummary", "namedSeat", "namedSeatRecipientName"];
7345
7401
  var event = (label, trigger, audience, variables, requiresTier = true) => ({ label, trigger, audience, variables: [...variables, ...policy], optionalVariables, requiresTier });
7346
7402
  var CHAPTER_EMAIL_EVENTS = {
7347
7403
  adminNotification: event("Application notification", "Application submitted or payment received, according to notification settings", "Administrator", [...identity, ...checkout, "adminUrl", "membersUrl"]),
7348
7404
  submitConfirmation: event("Application receipt", "Application submitted", "Applicant", [...identity, ...checkout, "submitNextStep", "membersUrl"]),
7349
7405
  paymentConfirmation: event("Payment confirmation", "Membership payment received", "Applicant", ["firstName", ...checkout, "paymentNextStep"]),
7350
7406
  prepEmail: event("Call confirmation", "Introduction call booked", "Applicant", [...identity, ...checkout, "startAt", "endAt", "timezone", "meetUrl", "htmlLink", "meetingTime", "meetingLink"]),
7351
- onboardingInvite: event("Membership welcome", "Membership approved", "Member", ["firstName", "membersUrl", "tierName"]),
7407
+ onboardingInvite: event("Membership welcome", "Membership approved", "Member", ["firstName", "membersUrl", "tierName", "namedSeat", "namedSeatRecipientName"]),
7352
7408
  namedSeatInvitation: event("Gift membership invitation", "A gift membership is ready to be accepted", "Gift recipient", ["firstName", "purchaserName", "chapterName", "acceptBy", "joinUrl", "tierName"]),
7353
7409
  seatAccepted: event("Gift accepted", "Gift recipient accepts their membership", "Purchaser", ["firstName", "purchaserName", "recipientName", "tierName", "chapterName", "membersUrl"])
7354
7410
  };
@@ -7363,9 +7419,6 @@ function chapterEmailEvents(chapter) {
7363
7419
  }
7364
7420
 
7365
7421
  // src/email.ts
7366
- function render(template, vars) {
7367
- return template.replace(/\{\{(\w+)\}\}/g, (_, key3) => vars[key3] ?? "");
7368
- }
7369
7422
  function groupVars(group, vars) {
7370
7423
  return {
7371
7424
  ...vars,
@@ -7398,7 +7451,9 @@ function planDelivery(input) {
7398
7451
  values: vars,
7399
7452
  tierId: vars.tierId
7400
7453
  });
7401
- if (issues.length) return { deliver: false, reason: "template-invalid", issues: issues.map((issue) => issue.message) };
7454
+ const html = typeof tpl.html === "string" && tpl.html.trim() ? tpl.html : void 0;
7455
+ const htmlIssues = html ? htmlBodyIssues(html, { allowedVariables: event2?.variables, optionalVariables: event2?.optionalVariables, values: vars }) : [];
7456
+ if (issues.length || htmlIssues.length) return { deliver: false, reason: "template-invalid", issues: [...issues, ...htmlIssues].map((issue) => issue.message) };
7402
7457
  const isProd = input.envName === "prod";
7403
7458
  const redirect = !isProd && !!input.group.debugEmail;
7404
7459
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
@@ -7411,11 +7466,13 @@ function planDelivery(input) {
7411
7466
  const text8 = redirect ? `(dev redirect; original recipient: ${input.to})
7412
7467
 
7413
7468
  ` + renderedText : renderedText;
7414
- return { deliver: true, transport, to, subject, text: text8, redirected: redirect };
7469
+ const renderedHtml = html ? renderHtmlBody(html, vars) : void 0;
7470
+ 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;
7471
+ return { deliver: true, transport, to, subject, text: text8, ...htmlOut ? { html: htmlOut } : {}, redirected: redirect };
7415
7472
  }
7416
7473
 
7417
7474
  // src/email-readiness.ts
7418
- import { isEmailTierName, validateEmailCopy as validateEmailCopy2 } from "@odla-ai/crm";
7475
+ import { isEmailTierName, templateSectionNames as templateSectionNames2, templateVars as templateVars2, validateEmailCopy as validateEmailCopy2 } from "@odla-ai/crm";
7419
7476
 
7420
7477
  // src/email-spam-signals.ts
7421
7478
  var TRIGGER_PHRASES = [
@@ -7445,13 +7502,14 @@ var TRIGGER_PHRASES = [
7445
7502
  var SUBJECT_TRIGGERS = ["free", ...TRIGGER_PHRASES];
7446
7503
  var DEV_HOSTS = /(?:^|\.)(?:workers\.dev|pages\.dev|localhost|ngrok(?:-free)?\.app|trycloudflare\.com)$/i;
7447
7504
  var SHORTENERS = /^(?:bit\.ly|tinyurl\.com|t\.co|goo\.gl|ow\.ly|is\.gd|buff\.ly|rebrand\.ly|cutt\.ly|tiny\.cc|shorturl\.at|rb\.gy)$/i;
7448
- var URL_RE = /https?:\/\/[^\s<>"')\]]+/g;
7505
+ var URL_RE2 = /https?:\/\/[^\s<>"')\]]+/g;
7449
7506
  var strip = (s) => s.replace(/\{\{\w+\}\}/g, " ");
7450
7507
  var words = (s) => strip(s).split(/\s+/).filter(Boolean);
7451
7508
  var phraseIn = (phrases, haystack) => phrases.find((phrase) => new RegExp(`(?:^|[^a-z0-9])${phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![a-z0-9])`, "i").test(haystack));
7452
7509
  function emailSpamSignals(copy) {
7453
7510
  const subject = typeof copy.subject === "string" ? copy.subject : "";
7454
7511
  const text8 = typeof copy.text === "string" ? copy.text : "";
7512
+ const html = typeof copy.html === "string" ? copy.html : "";
7455
7513
  const out = [];
7456
7514
  const letters = strip(subject).replace(/[^A-Za-z]/g, "");
7457
7515
  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, "")));
@@ -7464,7 +7522,8 @@ function emailSpamSignals(copy) {
7464
7522
  if (bodyHit) out.push({ code: "body-trigger-words", message: `The body uses "${bodyHit}", a phrase filters weight against the sender.` });
7465
7523
  if (/(?:^|\s)[A-Z]{3,}(?:\s+[A-Z]{3,}){2,}(?=\s|$)/.test(strip(text8))) out.push({ code: "body-caps", message: "The body shouts a run of capitalised words; use sentence case." });
7466
7524
  if (/!{2,}/.test(text8)) out.push({ code: "body-punctuation", message: "Repeated exclamation marks in the body are a classic spam signal." });
7467
- const urls = [...text8.matchAll(URL_RE)].map((match) => match[0]);
7525
+ const urls = [...`${text8}
7526
+ ${html}`.matchAll(URL_RE2)].map((match) => match[0].replace(/&quot;.*$/, ""));
7468
7527
  const hosts = urls.map((url) => {
7469
7528
  try {
7470
7529
  return new URL(url).hostname;
@@ -7509,10 +7568,14 @@ function validateEmailReadiness(input) {
7509
7568
  const template = input.templates[key3];
7510
7569
  const problems = !template ? [{ code: "template-missing", message: "No email is configured for this event." }] : validateEmailCopy2(template, { allowedVariables: event2.variables });
7511
7570
  if (template && typeof template.enabled !== "boolean") problems.push({ code: "enabled-missing", message: "Choose explicitly whether this email is enabled. Sending is blocked until then." });
7571
+ const html = typeof template?.html === "string" && template.html.trim() ? template.html : "";
7572
+ if (html) problems.push(...htmlBodyIssues(html, { allowedVariables: event2.variables }));
7512
7573
  if (template) {
7513
- const used = [...`${template.subject ?? ""}
7514
- ${template.text ?? ""}`.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
7515
- for (const name of used) if (name in input.groupValues && !input.groupValues[name]?.trim()) {
7574
+ const copy = `${template.subject ?? ""}
7575
+ ${template.text ?? ""}
7576
+ ${html}`;
7577
+ const sectionOnly = new Set(templateSectionNames2(copy));
7578
+ for (const name of templateVars2(copy)) if (name in input.groupValues && !input.groupValues[name]?.trim() && !sectionOnly.has(name)) {
7516
7579
  problems.push({ code: "value-missing", variable: name, message: `Add the ${name} used by this email.` });
7517
7580
  }
7518
7581
  }
@@ -7529,31 +7592,6 @@ ${template.text ?? ""}`.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
7529
7592
  return { checkedAt: input.now ?? Date.now(), events: events2, issues, warnings: emailSenderWarnings(input.sender ?? {}) };
7530
7593
  }
7531
7594
 
7532
- // src/email-html.ts
7533
- var URL_RE2 = /https?:\/\/[^\s<>"')\]]+[^\s<>"').,;:!?\]]/g;
7534
- function escapeHtml(s) {
7535
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
7536
- }
7537
- function lineHtml(line) {
7538
- let out = "", last = 0;
7539
- for (const match of line.matchAll(URL_RE2)) {
7540
- out += escapeHtml(line.slice(last, match.index));
7541
- const url = escapeHtml(match[0]);
7542
- out += `<a href="${url}">${url}</a>`;
7543
- last = match.index + match[0].length;
7544
- }
7545
- return out + escapeHtml(line.slice(last));
7546
- }
7547
- function textToHtml(text8) {
7548
- const paragraphs = text8.replace(/\r\n/g, "\n").trim().split(/\n{2,}/).filter((paragraph) => paragraph.trim());
7549
- const body = paragraphs.map((paragraph) => `<p>${paragraph.split("\n").map(lineHtml).join("<br>\n")}</p>`).join("\n");
7550
- return `<!doctype html>
7551
- <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
7552
- <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">
7553
- ${body}
7554
- </body></html>`;
7555
- }
7556
-
7557
7595
  // src/email-deliverability.ts
7558
7596
  var ADMINISTRATOR_AUDIENCE = /^(?:administrator|admin|team|operator|owner|staff)s?$/i;
7559
7597
  function isRecipientFacing(event2) {
@@ -7625,10 +7663,14 @@ async function frozenCheckoutVariables(db, app) {
7625
7663
  if (Number.isSafeInteger(amount) && Number(amount) > 0 && typeof currency === "string" && /^[a-z]{3}$/.test(currency)) {
7626
7664
  formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(Number(amount) / 100);
7627
7665
  }
7666
+ const seat = quote?.namedSeat, seatCents = quote?.lineItems?.namedSeatCents;
7667
+ const namedSeatRecipientName = typeof seat?.recipientName === "string" ? seat.recipientName.trim() : "";
7628
7668
  return {
7629
7669
  paymentAmount: formatted,
7630
7670
  paymentAmountSummary: formatted ? ` of ${formatted}` : "",
7631
- tierName: typeof quote?.tierName === "string" ? quote.tierName : ""
7671
+ tierName: typeof quote?.tierName === "string" ? quote.tierName : "",
7672
+ namedSeat: namedSeatRecipientName ? Number.isSafeInteger(seatCents) && Number(seatCents) > 0 ? "paid" : "included" : "",
7673
+ namedSeatRecipientName
7632
7674
  };
7633
7675
  }
7634
7676
  async function catalogTierName(db, group, app) {
@@ -7736,7 +7778,7 @@ async function sendTemplated(deps, input) {
7736
7778
  let messageId;
7737
7779
  try {
7738
7780
  const text8 = String(row2.body);
7739
- const html = deps.renderHtml === false ? void 0 : (deps.renderHtml ?? textToHtml)(text8);
7781
+ const html = deps.renderHtml === false ? void 0 : decision.html ?? (deps.renderHtml ?? textToHtml)(text8);
7740
7782
  const headers = deliverabilityHeaders({ event: event2, replyTo: input.group.replyTo });
7741
7783
  const response = await deps.sender.send({
7742
7784
  from: fromAddress(deps.from, input.group.fromName),
@@ -10518,6 +10560,7 @@ export {
10518
10560
  DEFAULT_SHARE_FIELDS,
10519
10561
  EDGE_UNPAIRED,
10520
10562
  GIFT_OFFER_CATALOG_VERSION,
10563
+ HTML_BODY_LIMIT,
10521
10564
  JOURNEY_ENDINGS,
10522
10565
  JOURNEY_STAGES,
10523
10566
  MAX_GIFT_OFFERS,
@@ -10570,6 +10613,7 @@ export {
10570
10613
  chapterDb,
10571
10614
  chapterEmailEvents,
10572
10615
  chapterFlowFieldContract,
10616
+ checkoutVariables,
10573
10617
  clampArray,
10574
10618
  classifyMemberStripeEvent,
10575
10619
  classifyMemberSystemWrite,
@@ -10613,9 +10657,11 @@ export {
10613
10657
  formatChapterCopy,
10614
10658
  formationFields,
10615
10659
  formationFlowFields,
10660
+ frozenCheckoutVariables,
10616
10661
  getVaultSecret,
10617
10662
  giftOfferActive,
10618
10663
  hasDisclaimerAck,
10664
+ htmlBodyIssues,
10619
10665
  hydrateSignupControlTheme,
10620
10666
  injectSignupControlThemeHead,
10621
10667
  introIdempotencyKey,
@@ -10694,6 +10740,7 @@ export {
10694
10740
  refundNetworkMembership,
10695
10741
  refundedPatch,
10696
10742
  render,
10743
+ renderHtmlBody,
10697
10744
  renderSignupControlThemeHead,
10698
10745
  renderSummary,
10699
10746
  renderTemplateBody,