@odla-ai/chapter 0.59.1 → 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.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var src_exports = {};
22
22
  __export(src_exports, {
23
+ ADMINISTRATOR_AUDIENCE: () => ADMINISTRATOR_AUDIENCE,
23
24
  ADMISSION_GRANTS_PATH: () => ADMISSION_GRANTS_PATH,
24
25
  ADMISSION_GRANTS_SCHEMA_VERSION: () => ADMISSION_GRANTS_SCHEMA_VERSION,
25
26
  BUILDER_LIBRARY_ADMIN_PATH: () => BUILDER_LIBRARY_ADMIN_PATH,
@@ -34,6 +35,7 @@ __export(src_exports, {
34
35
  DEFAULT_SHARE_FIELDS: () => DEFAULT_SHARE_FIELDS,
35
36
  EDGE_UNPAIRED: () => EDGE_UNPAIRED,
36
37
  GIFT_OFFER_CATALOG_VERSION: () => GIFT_OFFER_CATALOG_VERSION,
38
+ HTML_BODY_LIMIT: () => HTML_BODY_LIMIT,
37
39
  JOURNEY_ENDINGS: () => JOURNEY_ENDINGS,
38
40
  JOURNEY_STAGES: () => JOURNEY_STAGES,
39
41
  MAX_GIFT_OFFERS: () => MAX_GIFT_OFFERS,
@@ -86,6 +88,7 @@ __export(src_exports, {
86
88
  chapterDb: () => chapterDb,
87
89
  chapterEmailEvents: () => chapterEmailEvents,
88
90
  chapterFlowFieldContract: () => chapterFlowFieldContract,
91
+ checkoutVariables: () => checkoutVariables,
89
92
  clampArray: () => clampArray,
90
93
  classifyMemberStripeEvent: () => classifyMemberStripeEvent,
91
94
  classifyMemberSystemWrite: () => classifyMemberSystemWrite,
@@ -113,9 +116,12 @@ __export(src_exports, {
113
116
  dashboardMetricData: () => dashboardMetricData,
114
117
  defaultCrm: () => defaultCrm,
115
118
  defineChapter: () => defineChapter,
119
+ deliverabilityHeaders: () => deliverabilityHeaders,
116
120
  drainMembershipEffects: () => drainMembershipEffects,
117
121
  edgeUnpaired: () => edgeUnpaired,
118
122
  emailGroupFrom: () => emailGroupFrom,
123
+ emailSenderWarnings: () => emailSenderWarnings,
124
+ emailSpamSignals: () => emailSpamSignals,
119
125
  endForSlot: () => endForSlot,
120
126
  exportChapterPromotion: () => exportChapterPromotion,
121
127
  findAdmissionGrant: () => findAdmissionGrant,
@@ -126,14 +132,17 @@ __export(src_exports, {
126
132
  formatChapterCopy: () => formatChapterCopy,
127
133
  formationFields: () => formationFields,
128
134
  formationFlowFields: () => formationFlowFields,
135
+ frozenCheckoutVariables: () => frozenCheckoutVariables,
129
136
  getVaultSecret: () => getVaultSecret,
130
137
  giftOfferActive: () => giftOfferActive,
131
138
  hasDisclaimerAck: () => hasDisclaimerAck,
139
+ htmlBodyIssues: () => htmlBodyIssues,
132
140
  hydrateSignupControlTheme: () => hydrateSignupControlTheme,
133
141
  injectSignupControlThemeHead: () => injectSignupControlThemeHead,
134
142
  introIdempotencyKey: () => introIdempotencyKey,
135
143
  isAdminRole: () => isAdminRole,
136
144
  isAlreadySent: () => isAlreadySent,
145
+ isRecipientFacing: () => isRecipientFacing,
137
146
  isReconcilable: () => isReconcilable,
138
147
  isSlotAvailable: () => isSlotAvailable,
139
148
  isValidEmail: () => isValidEmail,
@@ -142,6 +151,7 @@ __export(src_exports, {
142
151
  joinConfigTiers: () => joinConfigTiers,
143
152
  leaderCrmConfig: () => leaderCrmConfig,
144
153
  listStripeObjects: () => listStripeObjects,
154
+ listUnsubscribeHeader: () => listUnsubscribeHeader,
145
155
  loadLatestAdmissionRevision: () => loadLatestAdmissionRevision,
146
156
  loadOfferableTiers: () => loadOfferableTiers,
147
157
  loadTiers: () => loadTiers,
@@ -205,6 +215,7 @@ __export(src_exports, {
205
215
  refundNetworkMembership: () => refundNetworkMembership,
206
216
  refundedPatch: () => refundedPatch,
207
217
  render: () => render,
218
+ renderHtmlBody: () => renderHtmlBody,
208
219
  renderSignupControlThemeHead: () => renderSignupControlThemeHead,
209
220
  renderSummary: () => renderSummary,
210
221
  renderTemplateBody: () => renderTemplateBody,
@@ -259,6 +270,7 @@ __export(src_exports, {
259
270
  submitApplication: () => submitApplication,
260
271
  subscriptionIdempotencyKey: () => subscriptionIdempotencyKey,
261
272
  syncApplicationToCrm: () => syncApplicationToCrm,
273
+ textToHtml: () => textToHtml,
262
274
  tierIsFree: () => tierIsFree,
263
275
  tierPayable: () => tierPayable,
264
276
  updateClerkUserMetadata: () => updateClerkUserMetadata,
@@ -1680,12 +1692,15 @@ var DEFAULT_ADMIN_COPY = {
1680
1692
  replyTo: "Reply-to",
1681
1693
  fromName: "From name",
1682
1694
  fromNameHint: "Shown as the sender; the address stays the Worker's configured one.",
1695
+ spamRisk: "Spam-filter risk",
1683
1696
  debugInbox: "Debug inbox",
1684
1697
  debugInboxHint: "Non-production sends redirect here.",
1685
1698
  sendTest: "Send test",
1686
1699
  enabled: "Enabled",
1687
1700
  subject: "Subject",
1688
1701
  body: "Body",
1702
+ htmlBody: "HTML body (optional)",
1703
+ 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.",
1689
1704
  save: "Save changes",
1690
1705
  sendLog: "Send log",
1691
1706
  sentColumn: "Sent",
@@ -7617,20 +7632,74 @@ function isRetiredEmailCopy(subject, text8) {
7617
7632
  }
7618
7633
 
7619
7634
  // src/email.ts
7635
+ var import_crm6 = require("@odla-ai/crm");
7636
+
7637
+ // src/email-render.ts
7620
7638
  var import_crm5 = require("@odla-ai/crm");
7621
7639
 
7640
+ // src/email-html.ts
7641
+ var URL_RE = /https?:\/\/[^\s<>"')\]]+[^\s<>"').,;:!?\]]/g;
7642
+ function escapeHtml(s) {
7643
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
7644
+ }
7645
+ function lineHtml(line) {
7646
+ let out = "", last = 0;
7647
+ for (const match of line.matchAll(URL_RE)) {
7648
+ out += escapeHtml(line.slice(last, match.index));
7649
+ const url = escapeHtml(match[0]);
7650
+ out += `<a href="${url}">${url}</a>`;
7651
+ last = match.index + match[0].length;
7652
+ }
7653
+ return out + escapeHtml(line.slice(last));
7654
+ }
7655
+ function textToHtml(text8) {
7656
+ const paragraphs = text8.replace(/\r\n/g, "\n").trim().split(/\n{2,}/).filter((paragraph) => paragraph.trim());
7657
+ const body = paragraphs.map((paragraph) => `<p>${paragraph.split("\n").map(lineHtml).join("<br>\n")}</p>`).join("\n");
7658
+ return `<!doctype html>
7659
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
7660
+ <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">
7661
+ ${body}
7662
+ </body></html>`;
7663
+ }
7664
+
7665
+ // src/email-render.ts
7666
+ function render(template, vars) {
7667
+ return (0, import_crm5.renderTemplate)(template, vars);
7668
+ }
7669
+ var HTML_BODY_LIMIT = 2e5;
7670
+ function renderHtmlBody(template, vars) {
7671
+ const escaped = Object.fromEntries(Object.entries(vars).map(([name, value]) => [name, escapeHtml(value).replace(/\r?\n/g, "<br>")]));
7672
+ return (0, import_crm5.renderTemplate)(template, escaped);
7673
+ }
7674
+ function htmlBodyIssues(html, options) {
7675
+ const issues = [];
7676
+ if (html.length > HTML_BODY_LIMIT) issues.push({ code: "html-invalid", message: `Keep the HTML body within ${HTML_BODY_LIMIT.toLocaleString("en-US")} characters.` });
7677
+ for (const message of (0, import_crm5.templateSectionIssues)(html)) issues.push({ code: "section-invalid", message: `${message} (HTML body)` });
7678
+ const unknown = new Set((0, import_crm5.templateVars)(html).filter((variable) => options.allowedVariables && !options.allowedVariables.includes(variable)));
7679
+ for (const variable of unknown) issues.push({ code: "variable-unknown", variable, message: `This event does not supply {{${variable}}} (HTML body).` });
7680
+ if (options.values) {
7681
+ for (const name of (0, import_crm5.templateSectionNames)(html)) if (typeof options.values[name] !== "string") issues.push({ code: "value-missing", variable: name, message: `Missing event value: ${name} (HTML body).` });
7682
+ for (const variable of (0, import_crm5.templateLiveVars)(html, options.values)) {
7683
+ if (unknown.has(variable)) continue;
7684
+ const value = options.values[variable];
7685
+ if (typeof value !== "string" || !value.trim() && !options.optionalVariables?.includes(variable)) issues.push({ code: "value-missing", variable, message: `Missing event value: ${variable} (HTML body).` });
7686
+ }
7687
+ }
7688
+ return issues;
7689
+ }
7690
+
7622
7691
  // src/email-events.ts
7623
7692
  var identity = ["firstName", "lastName", "email", "phone", "state"];
7624
7693
  var policy = ["refundPolicyText", "commitmentText", "normsText"];
7625
- var checkout = ["tier", "tierName", "paymentAmount", "paymentAmountSummary"];
7626
- var optionalVariables = ["phone", "state", "paymentAmount", "paymentAmountSummary"];
7694
+ var checkout = ["tier", "tierName", "paymentAmount", "paymentAmountSummary", "namedSeat", "namedSeatRecipientName"];
7695
+ var optionalVariables = ["phone", "state", "paymentAmount", "paymentAmountSummary", "namedSeat", "namedSeatRecipientName"];
7627
7696
  var event = (label, trigger, audience, variables, requiresTier = true) => ({ label, trigger, audience, variables: [...variables, ...policy], optionalVariables, requiresTier });
7628
7697
  var CHAPTER_EMAIL_EVENTS = {
7629
7698
  adminNotification: event("Application notification", "Application submitted or payment received, according to notification settings", "Administrator", [...identity, ...checkout, "adminUrl", "membersUrl"]),
7630
7699
  submitConfirmation: event("Application receipt", "Application submitted", "Applicant", [...identity, ...checkout, "submitNextStep", "membersUrl"]),
7631
7700
  paymentConfirmation: event("Payment confirmation", "Membership payment received", "Applicant", ["firstName", ...checkout, "paymentNextStep"]),
7632
7701
  prepEmail: event("Call confirmation", "Introduction call booked", "Applicant", [...identity, ...checkout, "startAt", "endAt", "timezone", "meetUrl", "htmlLink", "meetingTime", "meetingLink"]),
7633
- onboardingInvite: event("Membership welcome", "Membership approved", "Member", ["firstName", "membersUrl", "tierName"]),
7702
+ onboardingInvite: event("Membership welcome", "Membership approved", "Member", ["firstName", "membersUrl", "tierName", "namedSeat", "namedSeatRecipientName"]),
7634
7703
  namedSeatInvitation: event("Gift membership invitation", "A gift membership is ready to be accepted", "Gift recipient", ["firstName", "purchaserName", "chapterName", "acceptBy", "joinUrl", "tierName"]),
7635
7704
  seatAccepted: event("Gift accepted", "Gift recipient accepts their membership", "Purchaser", ["firstName", "purchaserName", "recipientName", "tierName", "chapterName", "membersUrl"])
7636
7705
  };
@@ -7645,9 +7714,6 @@ function chapterEmailEvents(chapter) {
7645
7714
  }
7646
7715
 
7647
7716
  // src/email.ts
7648
- function render(template, vars) {
7649
- return template.replace(/\{\{(\w+)\}\}/g, (_, key3) => vars[key3] ?? "");
7650
- }
7651
7717
  function groupVars(group, vars) {
7652
7718
  return {
7653
7719
  ...vars,
@@ -7673,14 +7739,16 @@ function planDelivery(input) {
7673
7739
  const vars = groupVars(input.group, input.vars);
7674
7740
  if (isRetiredEmailCopy(String(tpl.subject ?? ""), String(tpl.text ?? ""))) return { deliver: false, reason: "template-retired" };
7675
7741
  const event2 = input.event ?? CHAPTER_EMAIL_EVENTS[input.template];
7676
- const issues = (0, import_crm5.validateEmailCopy)(tpl, {
7742
+ const issues = (0, import_crm6.validateEmailCopy)(tpl, {
7677
7743
  allowedVariables: event2?.variables,
7678
7744
  optionalVariables: event2?.optionalVariables,
7679
7745
  requiredVariables: event2?.requiresTier ? ["tierName"] : [],
7680
7746
  values: vars,
7681
7747
  tierId: vars.tierId
7682
7748
  });
7683
- if (issues.length) return { deliver: false, reason: "template-invalid", issues: issues.map((issue) => issue.message) };
7749
+ const html = typeof tpl.html === "string" && tpl.html.trim() ? tpl.html : void 0;
7750
+ const htmlIssues = html ? htmlBodyIssues(html, { allowedVariables: event2?.variables, optionalVariables: event2?.optionalVariables, values: vars }) : [];
7751
+ if (issues.length || htmlIssues.length) return { deliver: false, reason: "template-invalid", issues: [...issues, ...htmlIssues].map((issue) => issue.message) };
7684
7752
  const isProd = input.envName === "prod";
7685
7753
  const redirect = !isProd && !!input.group.debugEmail;
7686
7754
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
@@ -7688,16 +7756,98 @@ function planDelivery(input) {
7688
7756
  const renderedSubject = render(tpl.subject, vars);
7689
7757
  const renderedText = render(tpl.text, vars);
7690
7758
  if (isRetiredEmailCopy(renderedSubject, renderedText)) return { deliver: false, reason: "template-retired" };
7691
- if ((0, import_crm5.validateEmailCopy)({ subject: renderedSubject, text: renderedText }, { allowedVariables: [] }).length) return { deliver: false, reason: "template-invalid" };
7759
+ if ((0, import_crm6.validateEmailCopy)({ subject: renderedSubject, text: renderedText }, { allowedVariables: [] }).length) return { deliver: false, reason: "template-invalid" };
7692
7760
  const subject = (redirect ? "[dev] " : "") + renderedSubject;
7693
7761
  const text8 = redirect ? `(dev redirect; original recipient: ${input.to})
7694
7762
 
7695
7763
  ` + renderedText : renderedText;
7696
- return { deliver: true, transport, to, subject, text: text8, redirected: redirect };
7764
+ const renderedHtml = html ? renderHtmlBody(html, vars) : void 0;
7765
+ 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;
7766
+ return { deliver: true, transport, to, subject, text: text8, ...htmlOut ? { html: htmlOut } : {}, redirected: redirect };
7767
+ }
7768
+
7769
+ // src/email-readiness.ts
7770
+ var import_crm7 = require("@odla-ai/crm");
7771
+
7772
+ // src/email-spam-signals.ts
7773
+ var TRIGGER_PHRASES = [
7774
+ "act now",
7775
+ "click here",
7776
+ "limited time",
7777
+ "guaranteed",
7778
+ "risk-free",
7779
+ "risk free",
7780
+ "no obligation",
7781
+ "100% free",
7782
+ "winner",
7783
+ "congratulations",
7784
+ "urgent",
7785
+ "double your",
7786
+ "earn money",
7787
+ "make money",
7788
+ "cash bonus",
7789
+ "once in a lifetime",
7790
+ "exclusive deal",
7791
+ "order now",
7792
+ "buy now",
7793
+ "this is not spam",
7794
+ "dear friend",
7795
+ "dear customer"
7796
+ ];
7797
+ var SUBJECT_TRIGGERS = ["free", ...TRIGGER_PHRASES];
7798
+ var DEV_HOSTS = /(?:^|\.)(?:workers\.dev|pages\.dev|localhost|ngrok(?:-free)?\.app|trycloudflare\.com)$/i;
7799
+ 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;
7800
+ var URL_RE2 = /https?:\/\/[^\s<>"')\]]+/g;
7801
+ var strip = (s) => s.replace(/\{\{\w+\}\}/g, " ");
7802
+ var words = (s) => strip(s).split(/\s+/).filter(Boolean);
7803
+ var phraseIn = (phrases, haystack) => phrases.find((phrase) => new RegExp(`(?:^|[^a-z0-9])${phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![a-z0-9])`, "i").test(haystack));
7804
+ function emailSpamSignals(copy) {
7805
+ const subject = typeof copy.subject === "string" ? copy.subject : "";
7806
+ const text8 = typeof copy.text === "string" ? copy.text : "";
7807
+ const html = typeof copy.html === "string" ? copy.html : "";
7808
+ const out = [];
7809
+ const letters = strip(subject).replace(/[^A-Za-z]/g, "");
7810
+ 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, "")));
7811
+ if (shouting) out.push({ code: "subject-caps", message: "The subject shouts in capital letters. Filters read that as a spam signal; use sentence case." });
7812
+ if (/[!?]{2,}|!\s*$/.test(subject)) out.push({ code: "subject-punctuation", message: "Repeated or trailing exclamation marks in the subject are a classic spam signal." });
7813
+ if (/^\s*(?:re|fw|fwd)\s*:/i.test(subject)) out.push({ code: "subject-fake-reply", message: "A subject that starts like a reply or forward reads as a spoof when the recipient never wrote first." });
7814
+ const subjectHit = phraseIn(SUBJECT_TRIGGERS, strip(subject));
7815
+ if (subjectHit) out.push({ code: "subject-trigger-words", message: `The subject uses "${subjectHit}", a word filters weight against the sender.` });
7816
+ const bodyHit = phraseIn(TRIGGER_PHRASES, strip(text8));
7817
+ if (bodyHit) out.push({ code: "body-trigger-words", message: `The body uses "${bodyHit}", a phrase filters weight against the sender.` });
7818
+ 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." });
7819
+ if (/!{2,}/.test(text8)) out.push({ code: "body-punctuation", message: "Repeated exclamation marks in the body are a classic spam signal." });
7820
+ const urls = [...`${text8}
7821
+ ${html}`.matchAll(URL_RE2)].map((match) => match[0].replace(/&quot;.*$/, ""));
7822
+ const hosts = urls.map((url) => {
7823
+ try {
7824
+ return new URL(url).hostname;
7825
+ } catch {
7826
+ return "";
7827
+ }
7828
+ });
7829
+ if (urls.some((url) => /^http:\/\//i.test(url))) out.push({ code: "link-insecure", message: "A link uses http:// instead of https://. Filters flag insecure links." });
7830
+ if (hosts.some((host) => DEV_HOSTS.test(host) || /^\d+\.\d+\.\d+\.\d+$/.test(host))) out.push({ code: "link-origin", message: "A link points at a development host (workers.dev, pages.dev, localhost or a raw IP). Link to the site's own domain." });
7831
+ if (hosts.some((host) => SHORTENERS.test(host))) out.push({ code: "link-shortener", message: "A link uses a URL shortener, which hides the destination and is heavily abused. Link to the real address." });
7832
+ if (new Set(urls).size > 5) out.push({ code: "link-many", message: "The body has more than five links. Keep lifecycle mail to the one or two the reader needs." });
7833
+ if (urls.length && words(text8).length < 20) out.push({ code: "body-link-only", message: "The body is little more than a link. Say what the reader is being sent and why before the address." });
7834
+ return out;
7835
+ }
7836
+ function emailSenderWarnings(input) {
7837
+ const out = [];
7838
+ const from = (input.fromEmail ?? "").trim().toLowerCase().replace(/^.*<([^>]+)>.*$/, "$1");
7839
+ const reply = (input.replyTo ?? "").trim().toLowerCase();
7840
+ const domain = (address) => address.split("@")[1] ?? "";
7841
+ if (/^(?:no-?reply|do-?not-?reply|donotreply)(?:[+.-][^@]*)?@/.test(from)) {
7842
+ out.push({ code: "sender-no-reply", message: "Mail is sent from a no-reply address. Gmail's sender guidelines advise against it; send from a monitored address so replies and unsubscribe requests reach someone." });
7843
+ }
7844
+ if (domain(from) && domain(reply) && domain(from) !== domain(reply)) {
7845
+ out.push({ code: "reply-to-domain", message: `Replies go to ${domain(reply)} while mail is sent from ${domain(from)}. A Reply-To on another domain is a mild spam signal; prefer an address on the sending domain.` });
7846
+ }
7847
+ return out;
7697
7848
  }
7698
7849
 
7699
7850
  // src/email-readiness.ts
7700
- var import_crm6 = require("@odla-ai/crm");
7701
7851
  function validateEmailReadiness(input) {
7702
7852
  const issues = [];
7703
7853
  for (const key3 of Object.keys(input.templates)) if (!input.events[key3]) {
@@ -7705,33 +7855,59 @@ function validateEmailReadiness(input) {
7705
7855
  }
7706
7856
  if (input.tiersUnavailable) issues.push({ code: "tiers-unavailable", message: "Tier names could not be checked. Membership emails cannot send until their tier is resolved." });
7707
7857
  for (const tier of input.tiers ?? []) {
7708
- if (!(0, import_crm6.isEmailTierName)(tier.name, tier.id)) issues.push({ code: "tier-name-invalid", message: `Tier ${tier.id} needs a recipient-facing display name.` });
7858
+ if (!(0, import_crm7.isEmailTierName)(tier.name, tier.id)) issues.push({ code: "tier-name-invalid", message: `Tier ${tier.id} needs a recipient-facing display name.` });
7709
7859
  }
7710
- const noTierNames = input.tiers !== void 0 && !input.tiers.some((tier) => (0, import_crm6.isEmailTierName)(tier.name, tier.id));
7860
+ const noTierNames = input.tiers !== void 0 && !input.tiers.some((tier) => (0, import_crm7.isEmailTierName)(tier.name, tier.id));
7711
7861
  if (noTierNames && !input.tiersUnavailable) issues.push({ code: "tiers-missing", message: "No membership tiers have a recipient-facing display name." });
7712
7862
  const events2 = Object.entries(input.events).map(([key3, event2]) => {
7713
7863
  const template = input.templates[key3];
7714
- const problems = !template ? [{ code: "template-missing", message: "No email is configured for this event." }] : (0, import_crm6.validateEmailCopy)(template, { allowedVariables: event2.variables });
7864
+ const problems = !template ? [{ code: "template-missing", message: "No email is configured for this event." }] : (0, import_crm7.validateEmailCopy)(template, { allowedVariables: event2.variables });
7715
7865
  if (template && typeof template.enabled !== "boolean") problems.push({ code: "enabled-missing", message: "Choose explicitly whether this email is enabled. Sending is blocked until then." });
7866
+ const html = typeof template?.html === "string" && template.html.trim() ? template.html : "";
7867
+ if (html) problems.push(...htmlBodyIssues(html, { allowedVariables: event2.variables }));
7716
7868
  if (template) {
7717
- const used = [...`${template.subject ?? ""}
7718
- ${template.text ?? ""}`.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
7719
- for (const name of used) if (name in input.groupValues && !input.groupValues[name]?.trim()) {
7869
+ const copy = `${template.subject ?? ""}
7870
+ ${template.text ?? ""}
7871
+ ${html}`;
7872
+ const sectionOnly = new Set((0, import_crm7.templateSectionNames)(copy));
7873
+ for (const name of (0, import_crm7.templateVars)(copy)) if (name in input.groupValues && !input.groupValues[name]?.trim() && !sectionOnly.has(name)) {
7720
7874
  problems.push({ code: "value-missing", variable: name, message: `Add the ${name} used by this email.` });
7721
7875
  }
7722
7876
  }
7723
7877
  if (event2.requiresTier && input.tiersUnavailable) problems.push({ code: "tiers-unavailable", message: "Tier names are unavailable; delivery checks will block unresolved memberships." });
7724
7878
  else if (event2.requiresTier && noTierNames) problems.push({ code: "tiers-missing", message: "Configure the membership tier names needed by this event." });
7725
- return { ...event2, key: key3, status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready", issues: problems };
7879
+ return {
7880
+ ...event2,
7881
+ key: key3,
7882
+ status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready",
7883
+ issues: problems,
7884
+ warnings: template ? emailSpamSignals(template) : []
7885
+ };
7726
7886
  });
7727
- return { checkedAt: input.now ?? Date.now(), events: events2, issues };
7887
+ return { checkedAt: input.now ?? Date.now(), events: events2, issues, warnings: emailSenderWarnings(input.sender ?? {}) };
7888
+ }
7889
+
7890
+ // src/email-deliverability.ts
7891
+ var ADMINISTRATOR_AUDIENCE = /^(?:administrator|admin|team|operator|owner|staff)s?$/i;
7892
+ function isRecipientFacing(event2) {
7893
+ return !event2 || !ADMINISTRATOR_AUDIENCE.test(event2.audience.trim());
7894
+ }
7895
+ function listUnsubscribeHeader(replyTo) {
7896
+ const address = replyTo.trim();
7897
+ if (!/^[^\s@,;<>?]+@[^\s@,;<>?]+\.[^\s@,;<>?]+$/.test(address)) return void 0;
7898
+ return `<mailto:${address}?subject=${encodeURIComponent("unsubscribe")}>`;
7899
+ }
7900
+ function deliverabilityHeaders(input) {
7901
+ if (!isRecipientFacing(input.event)) return void 0;
7902
+ const value = listUnsubscribeHeader(input.replyTo);
7903
+ return value ? { "List-Unsubscribe": value } : void 0;
7728
7904
  }
7729
7905
 
7730
7906
  // src/notify.ts
7731
- var import_crm8 = require("@odla-ai/crm");
7907
+ var import_crm9 = require("@odla-ai/crm");
7732
7908
 
7733
7909
  // src/email-event-values.ts
7734
- var import_crm7 = require("@odla-ai/crm");
7910
+ var import_crm8 = require("@odla-ai/crm");
7735
7911
 
7736
7912
  // src/tiers-store.ts
7737
7913
  function rowToTier(row2, groupId) {
@@ -7782,10 +7958,14 @@ async function frozenCheckoutVariables(db, app) {
7782
7958
  if (Number.isSafeInteger(amount) && Number(amount) > 0 && typeof currency === "string" && /^[a-z]{3}$/.test(currency)) {
7783
7959
  formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(Number(amount) / 100);
7784
7960
  }
7961
+ const seat = quote?.namedSeat, seatCents = quote?.lineItems?.namedSeatCents;
7962
+ const namedSeatRecipientName = typeof seat?.recipientName === "string" ? seat.recipientName.trim() : "";
7785
7963
  return {
7786
7964
  paymentAmount: formatted,
7787
7965
  paymentAmountSummary: formatted ? ` of ${formatted}` : "",
7788
- tierName: typeof quote?.tierName === "string" ? quote.tierName : ""
7966
+ tierName: typeof quote?.tierName === "string" ? quote.tierName : "",
7967
+ namedSeat: namedSeatRecipientName ? Number.isSafeInteger(seatCents) && Number(seatCents) > 0 ? "paid" : "included" : "",
7968
+ namedSeatRecipientName
7789
7969
  };
7790
7970
  }
7791
7971
  async function catalogTierName(db, group, app) {
@@ -7812,7 +7992,7 @@ async function emailTierVariables(db, input) {
7812
7992
  const tierId = input.tierId ?? (typeof app.tierId === "string" ? app.tierId : "");
7813
7993
  if (!tierId) return { tierName: "", tier: "" };
7814
7994
  const name = input.tierId ? await catalogTierName(db, group, { ...app, tierId }) : (await checkoutVariables(db, group, app)).tierName;
7815
- const tierName = (0, import_crm7.isEmailTierName)(name, tierId) ? name.trim() : "";
7995
+ const tierName = (0, import_crm8.isEmailTierName)(name, tierId) ? name.trim() : "";
7816
7996
  return { tierName, tier: tierName, tierId };
7817
7997
  } catch {
7818
7998
  return { tierName: "", tier: "" };
@@ -7872,7 +8052,7 @@ async function sendTemplated(deps, input) {
7872
8052
  ...input.applicationId ? { applicationId: input.applicationId } : {}
7873
8053
  };
7874
8054
  if (isRetiredEmailCopy(String(row2.subject), String(row2.body))) return { sent: false, reason: "template-retired" };
7875
- if ((0, import_crm8.validateEmailCopy)({ subject: row2.subject, text: row2.body }, { allowedVariables: [] }).length) return { sent: false, reason: "template-invalid" };
8055
+ if ((0, import_crm9.validateEmailCopy)({ subject: row2.subject, text: row2.body }, { allowedVariables: [] }).length) return { sent: false, reason: "template-invalid" };
7876
8056
  try {
7877
8057
  const claim = await deps.db.transact([{ t: "update", ns: "emailLog", id: id11, attrs: row2 }], {
7878
8058
  mutationId: `email-claim:${id11}:${attempt}`,
@@ -7892,11 +8072,16 @@ async function sendTemplated(deps, input) {
7892
8072
  if (decision.transport === "log-only") return { sent: true };
7893
8073
  let messageId;
7894
8074
  try {
8075
+ const text8 = String(row2.body);
8076
+ const html = deps.renderHtml === false ? void 0 : decision.html ?? (deps.renderHtml ?? textToHtml)(text8);
8077
+ const headers = deliverabilityHeaders({ event: event2, replyTo: input.group.replyTo });
7895
8078
  const response = await deps.sender.send({
7896
8079
  from: fromAddress(deps.from, input.group.fromName),
7897
8080
  to: [decision.to],
7898
8081
  subject: String(row2.subject),
7899
- text: String(row2.body),
8082
+ text: text8,
8083
+ ...html ? { html } : {},
8084
+ ...headers ? { headers } : {},
7900
8085
  replyTo: input.group.replyTo
7901
8086
  });
7902
8087
  if (!response || typeof response.messageId !== "string" || !response.messageId) throw new Error("missing delivery receipt");
@@ -8431,10 +8616,10 @@ function findGiftOffer(catalog, offerId, now) {
8431
8616
  }
8432
8617
 
8433
8618
  // src/network.ts
8434
- var import_crm11 = require("@odla-ai/crm");
8619
+ var import_crm12 = require("@odla-ai/crm");
8435
8620
 
8436
8621
  // src/crm-person-projection.ts
8437
- var import_crm9 = require("@odla-ai/crm");
8622
+ var import_crm10 = require("@odla-ai/crm");
8438
8623
  async function ensureProjectedPerson(deps, input, options = {}) {
8439
8624
  const email = String(input.email ?? "").trim().toLowerCase();
8440
8625
  if (!email) throw new Error("crm_projection_email_required");
@@ -8452,7 +8637,7 @@ async function ensureProjectedPerson(deps, input, options = {}) {
8452
8637
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`chapter:person:v1:${email}`)));
8453
8638
  const id11 = `person-${Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
8454
8639
  try {
8455
- const created = await (0, import_crm9.createRecord)({ ...deps, db: deps.db }, {
8640
+ const created = await (0, import_crm10.createRecord)({ ...deps, db: deps.db }, {
8456
8641
  id: id11,
8457
8642
  type: "person",
8458
8643
  input: { ...input, email },
@@ -8468,7 +8653,7 @@ async function ensureProjectedPerson(deps, input, options = {}) {
8468
8653
  }
8469
8654
 
8470
8655
  // src/applicant-projection.ts
8471
- var import_crm10 = require("@odla-ai/crm");
8656
+ var import_crm11 = require("@odla-ai/crm");
8472
8657
 
8473
8658
  // src/crm-application-fields.ts
8474
8659
  function applicationCrmFields(crm, app) {
@@ -8487,10 +8672,10 @@ async function upsertPerson(deps, opts) {
8487
8672
  const email = opts.email.toLowerCase();
8488
8673
  const crmDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
8489
8674
  const { record: record4, created } = await ensureProjectedPerson(deps, { ...opts.input, email });
8490
- if (!created) await (0, import_crm10.updateRecord)(crmDeps, { id: String(record4.id), input: opts.input, mutationId: opts.mutationId });
8675
+ if (!created) await (0, import_crm11.updateRecord)(crmDeps, { id: String(record4.id), input: opts.input, mutationId: opts.mutationId });
8491
8676
  const initial = deps.crm.initialStage("person");
8492
8677
  if (!created && record4.stage == null && initial) {
8493
- await (0, import_crm10.setStage)(crmDeps, {
8678
+ await (0, import_crm11.setStage)(crmDeps, {
8494
8679
  id: String(record4.id),
8495
8680
  to: initial,
8496
8681
  authorId: "system",
@@ -8613,7 +8798,7 @@ function sharedRecordFromCrm(crm, record4, target, sourceId) {
8613
8798
  return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record4.id }, type: record4.type, input } : { version: 1, type: record4.type, hubRecordId: record4.id, input };
8614
8799
  }
8615
8800
  async function findSharedRecord(deps, record4, tag) {
8616
- const structured = await (0, import_crm11.getRecordByOrigin)(
8801
+ const structured = await (0, import_crm12.getRecordByOrigin)(
8617
8802
  { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
8618
8803
  record4.sourceId,
8619
8804
  record4.sourceRecordId
@@ -8665,15 +8850,15 @@ async function projectSharedRecord(deps, shared, options = {}) {
8665
8850
  const existing = await findSharedRecord(deps, record4, tag);
8666
8851
  let recordId;
8667
8852
  if (existing && typeof existing.id === "string") {
8668
- await (0, import_crm11.updateRecord)(crmDeps, { id: existing.id, input: record4.input });
8853
+ await (0, import_crm12.updateRecord)(crmDeps, { id: existing.id, input: record4.input });
8669
8854
  recordId = existing.id;
8670
8855
  } else if (record4.type === "person" && typeof record4.input.email === "string") {
8671
8856
  const projected = await ensureProjectedPerson(deps, record4.input);
8672
8857
  recordId = String(projected.record.id);
8673
- if (!projected.created) await (0, import_crm11.updateRecord)(crmDeps, { id: recordId, input: record4.input });
8858
+ if (!projected.created) await (0, import_crm12.updateRecord)(crmDeps, { id: recordId, input: record4.input });
8674
8859
  } else {
8675
8860
  recordId = `network_${shortHash(`${record4.sourceId}\0${record4.type}\0${record4.sourceRecordId}`)}`;
8676
- await (0, import_crm11.createRecord)({ ...crmDeps, newId: () => recordId }, {
8861
+ await (0, import_crm12.createRecord)({ ...crmDeps, newId: () => recordId }, {
8677
8862
  type: record4.type,
8678
8863
  input: record4.input,
8679
8864
  mutationId: `share-create:${tag}`
@@ -8683,7 +8868,7 @@ async function projectSharedRecord(deps, shared, options = {}) {
8683
8868
  [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
8684
8869
  { mutationId: `share-map:${tag}:${recordId}` }
8685
8870
  );
8686
- await (0, import_crm11.upsertRecordOrigin)(crmDeps, {
8871
+ await (0, import_crm12.upsertRecordOrigin)(crmDeps, {
8687
8872
  recordId,
8688
8873
  sourceId: record4.sourceId,
8689
8874
  sourceRecordId: record4.sourceRecordId,
@@ -8694,7 +8879,7 @@ async function projectSharedRecord(deps, shared, options = {}) {
8694
8879
  }
8695
8880
 
8696
8881
  // src/crm-sync.ts
8697
- var import_crm12 = require("@odla-ai/crm");
8882
+ var import_crm13 = require("@odla-ai/crm");
8698
8883
 
8699
8884
  // src/crm-account-projection.ts
8700
8885
  async function syncAccountToCrm(deps, userId) {
@@ -8779,9 +8964,9 @@ async function syncApplicationToCrm(deps, opts) {
8779
8964
  const stage = opts.stage || void 0;
8780
8965
  const { record: existing, created } = await ensureProjectedPerson(deps, input, { stage });
8781
8966
  const recordId = String(existing.id);
8782
- if (!created) await (0, import_crm12.updateRecord)(recordDeps, { id: recordId, input });
8967
+ if (!created) await (0, import_crm13.updateRecord)(recordDeps, { id: recordId, input });
8783
8968
  if (!created && stage && existing.stage !== stage) {
8784
- await (0, import_crm12.setStage)(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${existing.updatedAt}:${stage}` });
8969
+ await (0, import_crm13.setStage)(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${existing.updatedAt}:${stage}` });
8785
8970
  }
8786
8971
  const billing = billingColumns(opts.app);
8787
8972
  await deps.db.transact([
@@ -8789,7 +8974,7 @@ async function syncApplicationToCrm(deps, opts) {
8789
8974
  // Optional typed dates are removed with a retract, never an invalid null.
8790
8975
  ...!("renewalAt" in billing) ? [{ t: "retract", ns: "crm_record", id: recordId, attrs: ["renewalAt"] }] : []
8791
8976
  ]);
8792
- await (0, import_crm12.linkIdentity)(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
8977
+ await (0, import_crm13.linkIdentity)(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
8793
8978
  return recordId;
8794
8979
  }
8795
8980
  async function backfillCrm(deps) {