@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.
@@ -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" | "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" | "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,7 +1769,25 @@ 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 };
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 };
1775
+ }
1776
+
1777
+ // src/email-deliverability.ts
1778
+ var ADMINISTRATOR_AUDIENCE = /^(?:administrator|admin|team|operator|owner|staff)s?$/i;
1779
+ function isRecipientFacing(event2) {
1780
+ return !event2 || !ADMINISTRATOR_AUDIENCE.test(event2.audience.trim());
1781
+ }
1782
+ function listUnsubscribeHeader(replyTo) {
1783
+ const address = replyTo.trim();
1784
+ if (!/^[^\s@,;<>?]+@[^\s@,;<>?]+\.[^\s@,;<>?]+$/.test(address)) return void 0;
1785
+ return `<mailto:${address}?subject=${encodeURIComponent("unsubscribe")}>`;
1786
+ }
1787
+ function deliverabilityHeaders(input) {
1788
+ if (!isRecipientFacing(input.event)) return void 0;
1789
+ const value = listUnsubscribeHeader(input.replyTo);
1790
+ return value ? { "List-Unsubscribe": value } : void 0;
1720
1791
  }
1721
1792
 
1722
1793
  // src/notify.ts
@@ -1832,10 +1903,14 @@ async function frozenCheckoutVariables(db, app) {
1832
1903
  if (Number.isSafeInteger(amount) && Number(amount) > 0 && typeof currency === "string" && /^[a-z]{3}$/.test(currency)) {
1833
1904
  formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(Number(amount) / 100);
1834
1905
  }
1906
+ const seat = quote?.namedSeat, seatCents = quote?.lineItems?.namedSeatCents;
1907
+ const namedSeatRecipientName = typeof seat?.recipientName === "string" ? seat.recipientName.trim() : "";
1835
1908
  return {
1836
1909
  paymentAmount: formatted,
1837
1910
  paymentAmountSummary: formatted ? ` of ${formatted}` : "",
1838
- 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
1839
1914
  };
1840
1915
  }
1841
1916
  async function catalogTierName(db, group, app) {
@@ -1893,6 +1968,8 @@ function sampleVariables(origin, timeZone, now = Date.now()) {
1893
1968
  purchaserName: "Sample Purchaser",
1894
1969
  recipientName: "Sample Recipient",
1895
1970
  chapterName: "Sample Chapter",
1971
+ namedSeat: "paid",
1972
+ namedSeatRecipientName: "Sample Recipient",
1896
1973
  acceptBy: new Date(now + 7 * 864e5).toISOString().slice(0, 10),
1897
1974
  joinUrl: `${origin}/join?seat=sample`
1898
1975
  };
@@ -1990,11 +2067,16 @@ async function sendTemplated(deps, input) {
1990
2067
  if (decision.transport === "log-only") return { sent: true };
1991
2068
  let messageId;
1992
2069
  try {
2070
+ const text10 = String(row2.body);
2071
+ const html = deps.renderHtml === false ? void 0 : decision.html ?? (deps.renderHtml ?? textToHtml)(text10);
2072
+ const headers = deliverabilityHeaders({ event: event2, replyTo: input.group.replyTo });
1993
2073
  const response = await deps.sender.send({
1994
2074
  from: fromAddress(deps.from, input.group.fromName),
1995
2075
  to: [decision.to],
1996
2076
  subject: String(row2.subject),
1997
- text: String(row2.body),
2077
+ text: text10,
2078
+ ...html ? { html } : {},
2079
+ ...headers ? { headers } : {},
1998
2080
  replyTo: input.group.replyTo
1999
2081
  });
2000
2082
  if (!response || typeof response.messageId !== "string" || !response.messageId) throw new Error("missing delivery receipt");
@@ -12462,7 +12544,87 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
12462
12544
  import { getConfig, saveConfig, validateEmailCopy as validateEmailCopy4 } from "@odla-ai/crm";
12463
12545
 
12464
12546
  // src/email-readiness.ts
12465
- 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";
12548
+
12549
+ // src/email-spam-signals.ts
12550
+ var TRIGGER_PHRASES = [
12551
+ "act now",
12552
+ "click here",
12553
+ "limited time",
12554
+ "guaranteed",
12555
+ "risk-free",
12556
+ "risk free",
12557
+ "no obligation",
12558
+ "100% free",
12559
+ "winner",
12560
+ "congratulations",
12561
+ "urgent",
12562
+ "double your",
12563
+ "earn money",
12564
+ "make money",
12565
+ "cash bonus",
12566
+ "once in a lifetime",
12567
+ "exclusive deal",
12568
+ "order now",
12569
+ "buy now",
12570
+ "this is not spam",
12571
+ "dear friend",
12572
+ "dear customer"
12573
+ ];
12574
+ var SUBJECT_TRIGGERS = ["free", ...TRIGGER_PHRASES];
12575
+ var DEV_HOSTS = /(?:^|\.)(?:workers\.dev|pages\.dev|localhost|ngrok(?:-free)?\.app|trycloudflare\.com)$/i;
12576
+ 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;
12577
+ var URL_RE2 = /https?:\/\/[^\s<>"')\]]+/g;
12578
+ var strip = (s) => s.replace(/\{\{\w+\}\}/g, " ");
12579
+ var words = (s) => strip(s).split(/\s+/).filter(Boolean);
12580
+ var phraseIn = (phrases, haystack) => phrases.find((phrase) => new RegExp(`(?:^|[^a-z0-9])${phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![a-z0-9])`, "i").test(haystack));
12581
+ function emailSpamSignals(copy) {
12582
+ const subject = typeof copy.subject === "string" ? copy.subject : "";
12583
+ const text10 = typeof copy.text === "string" ? copy.text : "";
12584
+ const html = typeof copy.html === "string" ? copy.html : "";
12585
+ const out = [];
12586
+ const letters = strip(subject).replace(/[^A-Za-z]/g, "");
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, "")));
12588
+ if (shouting) out.push({ code: "subject-caps", message: "The subject shouts in capital letters. Filters read that as a spam signal; use sentence case." });
12589
+ if (/[!?]{2,}|!\s*$/.test(subject)) out.push({ code: "subject-punctuation", message: "Repeated or trailing exclamation marks in the subject are a classic spam signal." });
12590
+ 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." });
12591
+ const subjectHit = phraseIn(SUBJECT_TRIGGERS, strip(subject));
12592
+ if (subjectHit) out.push({ code: "subject-trigger-words", message: `The subject uses "${subjectHit}", a word filters weight against the sender.` });
12593
+ const bodyHit = phraseIn(TRIGGER_PHRASES, strip(text10));
12594
+ if (bodyHit) out.push({ code: "body-trigger-words", message: `The body uses "${bodyHit}", a phrase filters weight against the sender.` });
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." });
12596
+ if (/!{2,}/.test(text10)) out.push({ code: "body-punctuation", message: "Repeated exclamation marks in the body are a classic spam signal." });
12597
+ const urls = [...`${text10}
12598
+ ${html}`.matchAll(URL_RE2)].map((match) => match[0].replace(/&quot;.*$/, ""));
12599
+ const hosts = urls.map((url) => {
12600
+ try {
12601
+ return new URL(url).hostname;
12602
+ } catch {
12603
+ return "";
12604
+ }
12605
+ });
12606
+ 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." });
12607
+ 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." });
12608
+ 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." });
12609
+ 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." });
12610
+ if (urls.length && words(text10).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." });
12611
+ return out;
12612
+ }
12613
+ function emailSenderWarnings(input) {
12614
+ const out = [];
12615
+ const from = (input.fromEmail ?? "").trim().toLowerCase().replace(/^.*<([^>]+)>.*$/, "$1");
12616
+ const reply = (input.replyTo ?? "").trim().toLowerCase();
12617
+ const domain = (address) => address.split("@")[1] ?? "";
12618
+ if (/^(?:no-?reply|do-?not-?reply|donotreply)(?:[+.-][^@]*)?@/.test(from)) {
12619
+ 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." });
12620
+ }
12621
+ if (domain(from) && domain(reply) && domain(from) !== domain(reply)) {
12622
+ 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.` });
12623
+ }
12624
+ return out;
12625
+ }
12626
+
12627
+ // src/email-readiness.ts
12466
12628
  function validateEmailReadiness(input) {
12467
12629
  const issues = [];
12468
12630
  for (const key3 of Object.keys(input.templates)) if (!input.events[key3]) {
@@ -12478,18 +12640,28 @@ function validateEmailReadiness(input) {
12478
12640
  const template = input.templates[key3];
12479
12641
  const problems = !template ? [{ code: "template-missing", message: "No email is configured for this event." }] : validateEmailCopy3(template, { allowedVariables: event2.variables });
12480
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 }));
12481
12645
  if (template) {
12482
- const used = [...`${template.subject ?? ""}
12483
- ${template.text ?? ""}`.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
12484
- 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)) {
12485
12651
  problems.push({ code: "value-missing", variable: name, message: `Add the ${name} used by this email.` });
12486
12652
  }
12487
12653
  }
12488
12654
  if (event2.requiresTier && input.tiersUnavailable) problems.push({ code: "tiers-unavailable", message: "Tier names are unavailable; delivery checks will block unresolved memberships." });
12489
12655
  else if (event2.requiresTier && noTierNames) problems.push({ code: "tiers-missing", message: "Configure the membership tier names needed by this event." });
12490
- return { ...event2, key: key3, status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready", issues: problems };
12656
+ return {
12657
+ ...event2,
12658
+ key: key3,
12659
+ status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready",
12660
+ issues: problems,
12661
+ warnings: template ? emailSpamSignals(template) : []
12662
+ };
12491
12663
  });
12492
- return { checkedAt: input.now ?? Date.now(), events, issues };
12664
+ return { checkedAt: input.now ?? Date.now(), events, issues, warnings: emailSenderWarnings(input.sender ?? {}) };
12493
12665
  }
12494
12666
 
12495
12667
  // src/email-admin-config.ts
@@ -12504,6 +12676,7 @@ async function emailSettings(db, ctx, env) {
12504
12676
  const emailTemplates = Object.fromEntries(Object.keys(events).filter((key3) => key3 in stored).map((key3) => [key3, {
12505
12677
  subject: str2(stored[key3]?.subject),
12506
12678
  text: str2(stored[key3]?.text),
12679
+ html: str2(stored[key3]?.html),
12507
12680
  enabled: stored[key3]?.enabled
12508
12681
  }]));
12509
12682
  let tiers = [], tiersUnavailable = false;
@@ -12515,7 +12688,14 @@ async function emailSettings(db, ctx, env) {
12515
12688
  }
12516
12689
  }
12517
12690
  const groupValues = Object.fromEntries(["refundPolicyText", "commitmentText", "normsText"].map((key3) => [key3, str2(group?.[key3])]));
12518
- const validation = validateEmailReadiness({ events, templates: stored, groupValues, tiers: hub ? void 0 : tiers, tiersUnavailable });
12691
+ const validation = validateEmailReadiness({
12692
+ events,
12693
+ templates: stored,
12694
+ groupValues,
12695
+ tiers: hub ? void 0 : tiers,
12696
+ tiersUnavailable,
12697
+ sender: { fromEmail: env.EMAIL_FROM ?? null, replyTo: str2(group?.replyTo) }
12698
+ });
12519
12699
  return { group, events, data: {
12520
12700
  groupId: ctx.chapter.id,
12521
12701
  name: ctx.chapter.name,
@@ -12543,12 +12723,14 @@ async function updateEmailSettings(db, ctx, env, body) {
12543
12723
  if (!value || typeof value !== "object" || Array.isArray(value)) return json({ error: `Invalid template: ${key3}` }, 400);
12544
12724
  const t = value;
12545
12725
  if (typeof t.enabled !== "boolean") return json({ error: `Choose whether ${key3} is enabled.` }, 400);
12546
- 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);
12547
12728
  const issues = validateEmailCopy4({ subject, text: text10 }, { allowedVariables: events[key3].variables });
12548
- if (t.enabled && issues.length || issues.some((issue) => ["subject-invalid", "body-invalid"].includes(issue.code))) {
12549
- 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);
12550
12732
  }
12551
- clean[key3] = { subject, text: text10, enabled: t.enabled };
12733
+ clean[key3] = { subject, text: text10, ...html.trim() ? { html } : {}, enabled: t.enabled };
12552
12734
  }
12553
12735
  const notificationEmail = str2(body.notificationEmail).trim(), replyTo = str2(body.replyTo).trim(), debugEmail = str2(body.debugEmail).trim();
12554
12736
  const emailish = /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/;
@@ -12569,7 +12751,8 @@ async function updateEmailSettings(db, ctx, env, body) {
12569
12751
  { t: "update", ns: "groups", id: id7, attrs: { commitmentText, normsText, notificationEmail, replyTo, debugEmail, fromName } }
12570
12752
  ]);
12571
12753
  }
12572
- return json({ ok: true });
12754
+ const warnings = Object.fromEntries(Object.entries(clean).map(([key3, template]) => [key3, emailSpamSignals(template)]).filter(([, found]) => found.length));
12755
+ return json({ ok: true, warnings });
12573
12756
  }
12574
12757
 
12575
12758
  // src/worker-routes-admin-comms.ts