@odla-ai/chapter 0.59.1 → 0.60.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" | "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" | "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;
@@ -1719,6 +1719,47 @@ function planDelivery(input) {
1719
1719
  return { deliver: true, transport, to, subject, text: text10, redirected: redirect };
1720
1720
  }
1721
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>`;
1745
+ }
1746
+
1747
+ // src/email-deliverability.ts
1748
+ var ADMINISTRATOR_AUDIENCE = /^(?:administrator|admin|team|operator|owner|staff)s?$/i;
1749
+ function isRecipientFacing(event2) {
1750
+ return !event2 || !ADMINISTRATOR_AUDIENCE.test(event2.audience.trim());
1751
+ }
1752
+ function listUnsubscribeHeader(replyTo) {
1753
+ const address = replyTo.trim();
1754
+ if (!/^[^\s@,;<>?]+@[^\s@,;<>?]+\.[^\s@,;<>?]+$/.test(address)) return void 0;
1755
+ return `<mailto:${address}?subject=${encodeURIComponent("unsubscribe")}>`;
1756
+ }
1757
+ function deliverabilityHeaders(input) {
1758
+ if (!isRecipientFacing(input.event)) return void 0;
1759
+ const value = listUnsubscribeHeader(input.replyTo);
1760
+ return value ? { "List-Unsubscribe": value } : void 0;
1761
+ }
1762
+
1722
1763
  // src/notify.ts
1723
1764
  import { validateEmailCopy as validateEmailCopy2 } from "@odla-ai/crm";
1724
1765
 
@@ -1990,11 +2031,16 @@ async function sendTemplated(deps, input) {
1990
2031
  if (decision.transport === "log-only") return { sent: true };
1991
2032
  let messageId;
1992
2033
  try {
2034
+ const text10 = String(row2.body);
2035
+ const html = deps.renderHtml === false ? void 0 : (deps.renderHtml ?? textToHtml)(text10);
2036
+ const headers = deliverabilityHeaders({ event: event2, replyTo: input.group.replyTo });
1993
2037
  const response = await deps.sender.send({
1994
2038
  from: fromAddress(deps.from, input.group.fromName),
1995
2039
  to: [decision.to],
1996
2040
  subject: String(row2.subject),
1997
- text: String(row2.body),
2041
+ text: text10,
2042
+ ...html ? { html } : {},
2043
+ ...headers ? { headers } : {},
1998
2044
  replyTo: input.group.replyTo
1999
2045
  });
2000
2046
  if (!response || typeof response.messageId !== "string" || !response.messageId) throw new Error("missing delivery receipt");
@@ -12463,6 +12509,84 @@ import { getConfig, saveConfig, validateEmailCopy as validateEmailCopy4 } from "
12463
12509
 
12464
12510
  // src/email-readiness.ts
12465
12511
  import { isEmailTierName as isEmailTierName2, validateEmailCopy as validateEmailCopy3 } from "@odla-ai/crm";
12512
+
12513
+ // src/email-spam-signals.ts
12514
+ var TRIGGER_PHRASES = [
12515
+ "act now",
12516
+ "click here",
12517
+ "limited time",
12518
+ "guaranteed",
12519
+ "risk-free",
12520
+ "risk free",
12521
+ "no obligation",
12522
+ "100% free",
12523
+ "winner",
12524
+ "congratulations",
12525
+ "urgent",
12526
+ "double your",
12527
+ "earn money",
12528
+ "make money",
12529
+ "cash bonus",
12530
+ "once in a lifetime",
12531
+ "exclusive deal",
12532
+ "order now",
12533
+ "buy now",
12534
+ "this is not spam",
12535
+ "dear friend",
12536
+ "dear customer"
12537
+ ];
12538
+ var SUBJECT_TRIGGERS = ["free", ...TRIGGER_PHRASES];
12539
+ var DEV_HOSTS = /(?:^|\.)(?:workers\.dev|pages\.dev|localhost|ngrok(?:-free)?\.app|trycloudflare\.com)$/i;
12540
+ 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;
12541
+ var URL_RE2 = /https?:\/\/[^\s<>"')\]]+/g;
12542
+ var strip = (s) => s.replace(/\{\{\w+\}\}/g, " ");
12543
+ var words = (s) => strip(s).split(/\s+/).filter(Boolean);
12544
+ var phraseIn = (phrases, haystack) => phrases.find((phrase) => new RegExp(`(?:^|[^a-z0-9])${phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![a-z0-9])`, "i").test(haystack));
12545
+ function emailSpamSignals(copy) {
12546
+ const subject = typeof copy.subject === "string" ? copy.subject : "";
12547
+ const text10 = typeof copy.text === "string" ? copy.text : "";
12548
+ const out = [];
12549
+ const letters = strip(subject).replace(/[^A-Za-z]/g, "");
12550
+ 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, "")));
12551
+ if (shouting) out.push({ code: "subject-caps", message: "The subject shouts in capital letters. Filters read that as a spam signal; use sentence case." });
12552
+ if (/[!?]{2,}|!\s*$/.test(subject)) out.push({ code: "subject-punctuation", message: "Repeated or trailing exclamation marks in the subject are a classic spam signal." });
12553
+ 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." });
12554
+ const subjectHit = phraseIn(SUBJECT_TRIGGERS, strip(subject));
12555
+ if (subjectHit) out.push({ code: "subject-trigger-words", message: `The subject uses "${subjectHit}", a word filters weight against the sender.` });
12556
+ const bodyHit = phraseIn(TRIGGER_PHRASES, strip(text10));
12557
+ if (bodyHit) out.push({ code: "body-trigger-words", message: `The body uses "${bodyHit}", a phrase filters weight against the sender.` });
12558
+ 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
+ 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]);
12561
+ const hosts = urls.map((url) => {
12562
+ try {
12563
+ return new URL(url).hostname;
12564
+ } catch {
12565
+ return "";
12566
+ }
12567
+ });
12568
+ 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." });
12569
+ 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." });
12570
+ 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." });
12571
+ 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." });
12572
+ 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." });
12573
+ return out;
12574
+ }
12575
+ function emailSenderWarnings(input) {
12576
+ const out = [];
12577
+ const from = (input.fromEmail ?? "").trim().toLowerCase().replace(/^.*<([^>]+)>.*$/, "$1");
12578
+ const reply = (input.replyTo ?? "").trim().toLowerCase();
12579
+ const domain = (address) => address.split("@")[1] ?? "";
12580
+ if (/^(?:no-?reply|do-?not-?reply|donotreply)(?:[+.-][^@]*)?@/.test(from)) {
12581
+ 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." });
12582
+ }
12583
+ if (domain(from) && domain(reply) && domain(from) !== domain(reply)) {
12584
+ 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.` });
12585
+ }
12586
+ return out;
12587
+ }
12588
+
12589
+ // src/email-readiness.ts
12466
12590
  function validateEmailReadiness(input) {
12467
12591
  const issues = [];
12468
12592
  for (const key3 of Object.keys(input.templates)) if (!input.events[key3]) {
@@ -12487,9 +12611,15 @@ ${template.text ?? ""}`.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
12487
12611
  }
12488
12612
  if (event2.requiresTier && input.tiersUnavailable) problems.push({ code: "tiers-unavailable", message: "Tier names are unavailable; delivery checks will block unresolved memberships." });
12489
12613
  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 };
12614
+ return {
12615
+ ...event2,
12616
+ key: key3,
12617
+ status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready",
12618
+ issues: problems,
12619
+ warnings: template ? emailSpamSignals(template) : []
12620
+ };
12491
12621
  });
12492
- return { checkedAt: input.now ?? Date.now(), events, issues };
12622
+ return { checkedAt: input.now ?? Date.now(), events, issues, warnings: emailSenderWarnings(input.sender ?? {}) };
12493
12623
  }
12494
12624
 
12495
12625
  // src/email-admin-config.ts
@@ -12515,7 +12645,14 @@ async function emailSettings(db, ctx, env) {
12515
12645
  }
12516
12646
  }
12517
12647
  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 });
12648
+ const validation = validateEmailReadiness({
12649
+ events,
12650
+ templates: stored,
12651
+ groupValues,
12652
+ tiers: hub ? void 0 : tiers,
12653
+ tiersUnavailable,
12654
+ sender: { fromEmail: env.EMAIL_FROM ?? null, replyTo: str2(group?.replyTo) }
12655
+ });
12519
12656
  return { group, events, data: {
12520
12657
  groupId: ctx.chapter.id,
12521
12658
  name: ctx.chapter.name,
@@ -12569,7 +12706,8 @@ async function updateEmailSettings(db, ctx, env, body) {
12569
12706
  { t: "update", ns: "groups", id: id7, attrs: { commitmentText, normsText, notificationEmail, replyTo, debugEmail, fromName } }
12570
12707
  ]);
12571
12708
  }
12572
- return json({ ok: true });
12709
+ const warnings = Object.fromEntries(Object.entries(clean).map(([key3, template]) => [key3, emailSpamSignals(template)]).filter(([, found]) => found.length));
12710
+ return json({ ok: true, warnings });
12573
12711
  }
12574
12712
 
12575
12713
  // src/worker-routes-admin-comms.ts