@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.
@@ -1,8 +1,8 @@
1
1
  import * as preact from 'preact';
2
2
  import { ComponentChildren, JSX } from 'preact';
3
3
  import { CrmClient, Crm, CrmRecord, FieldType } from '@odla-ai/crm';
4
- import { a as Chapter, b as ChapterCopy, M as MemberContentDocument, E as EffectiveMemberContent, c as MemberContentTierAuthority } from '../../copy-context-BuJBQafl.js';
5
- export { C as ChapterCopyProvider, u as useChapterCopy } from '../../copy-context-BuJBQafl.js';
4
+ import { a as Chapter, b as ChapterCopy, M as MemberContentDocument, E as EffectiveMemberContent, c as MemberContentTierAuthority } from '../../copy-context-BP3G1iBd.js';
5
+ export { C as ChapterCopyProvider, u as useChapterCopy } from '../../copy-context-BP3G1iBd.js';
6
6
  import { CrmLifecycleAdapter, CrmWorkspaceMasterContext, CrmWorkspaceRecordContext, RecordPanelTab } from '@odla-ai/crm/ui';
7
7
 
8
8
  /** Normalized public auth configuration consumed by the Clerk gate. */
@@ -44,11 +44,11 @@ import {
44
44
  networkRecordsWorkspace,
45
45
  peopleSection,
46
46
  useAdminResource
47
- } from "../../chunk-P6X3W2XP.js";
47
+ } from "../../chunk-J7ES2OWN.js";
48
48
  import {
49
49
  ChapterCopyProvider,
50
50
  useChapterCopy
51
- } from "../../chunk-GADLTNJF.js";
51
+ } from "../../chunk-QNP3UEIL.js";
52
52
  export {
53
53
  APPLICATION_STAGES,
54
54
  AdminNote,
@@ -1,5 +1,5 @@
1
1
  export { APPLICATION_STAGES, AdminAccountMenuProps, AdminBuilderLibraryData, AdminChrome, AdminMemberContentData, AdminNote, AdminPage, AdminResource, AdminRouteState, AdminRouteTarget, AdminRouting, AdminSection, AdminSectionContext, AdminWorkspace, AdminWorkspaceFrameProps, AdminWorkspaceNavProps, AdmissionGrantsWorkspace, AvailabilitySectionOptions, BillingSectionOptions, BuilderLibraryAdmin, CandidateStagePanel, CandidateStagePanelProps, ChapterAdmin, ChapterAdminAuthAdapter, ChapterAdminAuthConfig, ChapterAdminAuthLoadContext, ChapterAdminProps, ChapterAdminUser, CollectionSectionOptions, DISPUTE_STATUS_WORDS, DashboardSectionOptions, EmailSectionOptions, LeaderAdmissionAction, LeaderAdmissionActionProps, LeaderNetworkOverview, MeetingsSectionOptions, MemberContentAdmin, MembershipDisputeRow, MembershipDisputes, MembershipPolicyVersions, MembershipPolicyVersionsProps, NamedSeatRecordPanel, NamedSeatRecordPanelProps, NetworkRecordEditor, NetworkRecordsWorkspace, NetworkSeatBilling, NetworkShareActions, NetworkShareActionsProps, Panel, PaymentRecoveryQueue, PeopleSectionOptions, RecordActions, RecordActionsProps, RecordAdmissionPanel, RecordAdmissionPanelProps, ThemeWarning, adminFetch, adminRouteFromUrl, adminRouteHref, adminSectionFromUrl, adminSectionHref, admissionGrantsWorkspace, applicationLifecycleAdapter, availabilitySection, billingSection, builderLibrarySection, collectionSection, dashboardSection, defaultAdminSections, defaultAdminWorkspaces, emailSection, loadChapterAdminConfig, loadChapterAdminUser, meetingsSection, memberContentSection, networkRecordsWorkspace, peopleSection, useAdminResource } from './admin/index.js';
2
- export { C as ChapterCopyProvider, u as useChapterCopy } from '../copy-context-BuJBQafl.js';
2
+ export { C as ChapterCopyProvider, u as useChapterCopy } from '../copy-context-BP3G1iBd.js';
3
3
  export { ADDITIONAL_MEMBER_COPY, AdditionalMemberCopy, AdditionalMemberDraft, AdditionalMemberEditor, AdditionalMemberEditorOptions, AdditionalMemberEditorProps, AdditionalMemberFieldNames, AdditionalMemberFields, AdditionalMemberFieldsProps, AdditionalMemberOffer, AdditionalMemberOptions, ApiFn, BrandStyle, BrandStyleProps, CheckoutSeatEditor, CheckoutSeatEditorContext, GiftCodeRedeemResult, GiftCodeRedeemer, GiftCodeRedeemerCopy, GiftCodeRedeemerProps, GiftOfferCards, GiftOfferCardsMode, GiftOfferCardsProps, GiftPurchases, GiftPurchasesProps, JoinBookingPreload, JoinBookingSimulation, JoinConfig, JoinFlowState, JoinIsland, JoinIslandProps, JoinSimulation, JoinStepRenderContext, JoinSubmitButton, JoinSubmitRenderContext, JoinTier, JoinTierRenderContext, JourneyFrame, MEMBERSHIP_CARD_TOKEN_DEFAULTS, MemberContentArea, MemberContentAreaProps, MemberGifts, MemberProvisionalRenderContext, MemberRestart, MembersArea, MembersAreaProps, MembershipCardTokenName, MembershipCards, MembershipCardsMode, MembershipCardsProps, NAMED_SEAT_RECIPIENT_WORDS, NamedSeatInvitationLink, NamedSeatInvitationLinkProps, NamedSeatPurchase, NamedSeatRecipientLine, PaymentPriceLines, PaymentStep, PaymentStepProps, PaymentStepSimulation, RescheduleProps, Rescheduler, Slot, SlotPicker, SlotPickerClasses, SlotPickerProps, SubResponse, TierPlaceholder, additionalMemberSelection, dayKey, dayLabel, fmtDate, fmtMoney, fullLabel, groupSlotsByDay, loadJoinResume, membershipApplicationHref, namedSeatInvitationMessage, namedSeatInvitationShareable, namedSeatInvitationUrl, tierNeedsPayment, timeLabel, tzShort, useTierSelection } from './member/index.js';
4
4
  import 'preact';
5
5
  import '@odla-ai/crm';
package/dist/ui/index.js CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  timeLabel,
39
39
  tzShort,
40
40
  useTierSelection
41
- } from "../chunk-SDGVPIXF.js";
41
+ } from "../chunk-D5UR5H6R.js";
42
42
  import {
43
43
  APPLICATION_STAGES,
44
44
  AdminNote,
@@ -85,12 +85,12 @@ import {
85
85
  networkRecordsWorkspace,
86
86
  peopleSection,
87
87
  useAdminResource
88
- } from "../chunk-P6X3W2XP.js";
88
+ } from "../chunk-J7ES2OWN.js";
89
89
  import {
90
90
  BrandStyle,
91
91
  ChapterCopyProvider,
92
92
  useChapterCopy
93
- } from "../chunk-GADLTNJF.js";
93
+ } from "../chunk-QNP3UEIL.js";
94
94
  import "../chunk-IVXVECKR.js";
95
95
  export {
96
96
  ADDITIONAL_MEMBER_COPY,
@@ -1,7 +1,7 @@
1
1
  import * as preact from 'preact';
2
2
  import { ComponentChildren, JSX } from 'preact';
3
- import { d as MemberContentProjection, e as MemberContentPageProjection, b as ChapterCopy, f as ChapterBrand } from '../../copy-context-BuJBQafl.js';
4
- export { C as ChapterCopyProvider, u as useChapterCopy } from '../../copy-context-BuJBQafl.js';
3
+ import { d as MemberContentProjection, e as MemberContentPageProjection, b as ChapterCopy, f as ChapterBrand } from '../../copy-context-BP3G1iBd.js';
4
+ export { C as ChapterCopyProvider, u as useChapterCopy } from '../../copy-context-BP3G1iBd.js';
5
5
  import { FieldType, FieldConditions } from '@odla-ai/crm';
6
6
 
7
7
  /** A bookable slot: a start instant in epoch milliseconds. */
@@ -38,12 +38,12 @@ import {
38
38
  timeLabel,
39
39
  tzShort,
40
40
  useTierSelection
41
- } from "../../chunk-SDGVPIXF.js";
41
+ } from "../../chunk-D5UR5H6R.js";
42
42
  import {
43
43
  BrandStyle,
44
44
  ChapterCopyProvider,
45
45
  useChapterCopy
46
- } from "../../chunk-GADLTNJF.js";
46
+ } from "../../chunk-QNP3UEIL.js";
47
47
  import "../../chunk-IVXVECKR.js";
48
48
  export {
49
49
  ADDITIONAL_MEMBER_COPY,
@@ -1786,6 +1786,47 @@ function planDelivery(input) {
1786
1786
  return { deliver: true, transport, to, subject, text: text10, redirected: redirect };
1787
1787
  }
1788
1788
 
1789
+ // src/email-html.ts
1790
+ var URL_RE = /https?:\/\/[^\s<>"')\]]+[^\s<>"').,;:!?\]]/g;
1791
+ function escapeHtml(s) {
1792
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1793
+ }
1794
+ function lineHtml(line) {
1795
+ let out = "", last = 0;
1796
+ for (const match of line.matchAll(URL_RE)) {
1797
+ out += escapeHtml(line.slice(last, match.index));
1798
+ const url = escapeHtml(match[0]);
1799
+ out += `<a href="${url}">${url}</a>`;
1800
+ last = match.index + match[0].length;
1801
+ }
1802
+ return out + escapeHtml(line.slice(last));
1803
+ }
1804
+ function textToHtml(text10) {
1805
+ const paragraphs = text10.replace(/\r\n/g, "\n").trim().split(/\n{2,}/).filter((paragraph) => paragraph.trim());
1806
+ const body = paragraphs.map((paragraph) => `<p>${paragraph.split("\n").map(lineHtml).join("<br>\n")}</p>`).join("\n");
1807
+ return `<!doctype html>
1808
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
1809
+ <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">
1810
+ ${body}
1811
+ </body></html>`;
1812
+ }
1813
+
1814
+ // src/email-deliverability.ts
1815
+ var ADMINISTRATOR_AUDIENCE = /^(?:administrator|admin|team|operator|owner|staff)s?$/i;
1816
+ function isRecipientFacing(event2) {
1817
+ return !event2 || !ADMINISTRATOR_AUDIENCE.test(event2.audience.trim());
1818
+ }
1819
+ function listUnsubscribeHeader(replyTo) {
1820
+ const address = replyTo.trim();
1821
+ if (!/^[^\s@,;<>?]+@[^\s@,;<>?]+\.[^\s@,;<>?]+$/.test(address)) return void 0;
1822
+ return `<mailto:${address}?subject=${encodeURIComponent("unsubscribe")}>`;
1823
+ }
1824
+ function deliverabilityHeaders(input) {
1825
+ if (!isRecipientFacing(input.event)) return void 0;
1826
+ const value = listUnsubscribeHeader(input.replyTo);
1827
+ return value ? { "List-Unsubscribe": value } : void 0;
1828
+ }
1829
+
1789
1830
  // src/notify.ts
1790
1831
  var import_crm7 = require("@odla-ai/crm");
1791
1832
 
@@ -2057,11 +2098,16 @@ async function sendTemplated(deps, input) {
2057
2098
  if (decision.transport === "log-only") return { sent: true };
2058
2099
  let messageId;
2059
2100
  try {
2101
+ const text10 = String(row2.body);
2102
+ const html = deps.renderHtml === false ? void 0 : (deps.renderHtml ?? textToHtml)(text10);
2103
+ const headers = deliverabilityHeaders({ event: event2, replyTo: input.group.replyTo });
2060
2104
  const response = await deps.sender.send({
2061
2105
  from: fromAddress(deps.from, input.group.fromName),
2062
2106
  to: [decision.to],
2063
2107
  subject: String(row2.subject),
2064
- text: String(row2.body),
2108
+ text: text10,
2109
+ ...html ? { html } : {},
2110
+ ...headers ? { headers } : {},
2065
2111
  replyTo: input.group.replyTo
2066
2112
  });
2067
2113
  if (!response || typeof response.messageId !== "string" || !response.messageId) throw new Error("missing delivery receipt");
@@ -12530,6 +12576,84 @@ var import_crm16 = require("@odla-ai/crm");
12530
12576
 
12531
12577
  // src/email-readiness.ts
12532
12578
  var import_crm15 = require("@odla-ai/crm");
12579
+
12580
+ // src/email-spam-signals.ts
12581
+ var TRIGGER_PHRASES = [
12582
+ "act now",
12583
+ "click here",
12584
+ "limited time",
12585
+ "guaranteed",
12586
+ "risk-free",
12587
+ "risk free",
12588
+ "no obligation",
12589
+ "100% free",
12590
+ "winner",
12591
+ "congratulations",
12592
+ "urgent",
12593
+ "double your",
12594
+ "earn money",
12595
+ "make money",
12596
+ "cash bonus",
12597
+ "once in a lifetime",
12598
+ "exclusive deal",
12599
+ "order now",
12600
+ "buy now",
12601
+ "this is not spam",
12602
+ "dear friend",
12603
+ "dear customer"
12604
+ ];
12605
+ var SUBJECT_TRIGGERS = ["free", ...TRIGGER_PHRASES];
12606
+ var DEV_HOSTS = /(?:^|\.)(?:workers\.dev|pages\.dev|localhost|ngrok(?:-free)?\.app|trycloudflare\.com)$/i;
12607
+ 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;
12608
+ var URL_RE2 = /https?:\/\/[^\s<>"')\]]+/g;
12609
+ var strip = (s) => s.replace(/\{\{\w+\}\}/g, " ");
12610
+ var words = (s) => strip(s).split(/\s+/).filter(Boolean);
12611
+ var phraseIn = (phrases, haystack) => phrases.find((phrase) => new RegExp(`(?:^|[^a-z0-9])${phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![a-z0-9])`, "i").test(haystack));
12612
+ function emailSpamSignals(copy) {
12613
+ const subject = typeof copy.subject === "string" ? copy.subject : "";
12614
+ const text10 = typeof copy.text === "string" ? copy.text : "";
12615
+ const out = [];
12616
+ const letters = strip(subject).replace(/[^A-Za-z]/g, "");
12617
+ 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, "")));
12618
+ if (shouting) out.push({ code: "subject-caps", message: "The subject shouts in capital letters. Filters read that as a spam signal; use sentence case." });
12619
+ if (/[!?]{2,}|!\s*$/.test(subject)) out.push({ code: "subject-punctuation", message: "Repeated or trailing exclamation marks in the subject are a classic spam signal." });
12620
+ 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." });
12621
+ const subjectHit = phraseIn(SUBJECT_TRIGGERS, strip(subject));
12622
+ if (subjectHit) out.push({ code: "subject-trigger-words", message: `The subject uses "${subjectHit}", a word filters weight against the sender.` });
12623
+ const bodyHit = phraseIn(TRIGGER_PHRASES, strip(text10));
12624
+ if (bodyHit) out.push({ code: "body-trigger-words", message: `The body uses "${bodyHit}", a phrase filters weight against the sender.` });
12625
+ 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." });
12626
+ if (/!{2,}/.test(text10)) out.push({ code: "body-punctuation", message: "Repeated exclamation marks in the body are a classic spam signal." });
12627
+ const urls = [...text10.matchAll(URL_RE2)].map((match) => match[0]);
12628
+ const hosts = urls.map((url) => {
12629
+ try {
12630
+ return new URL(url).hostname;
12631
+ } catch {
12632
+ return "";
12633
+ }
12634
+ });
12635
+ 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." });
12636
+ 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." });
12637
+ 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." });
12638
+ 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." });
12639
+ 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." });
12640
+ return out;
12641
+ }
12642
+ function emailSenderWarnings(input) {
12643
+ const out = [];
12644
+ const from = (input.fromEmail ?? "").trim().toLowerCase().replace(/^.*<([^>]+)>.*$/, "$1");
12645
+ const reply = (input.replyTo ?? "").trim().toLowerCase();
12646
+ const domain = (address) => address.split("@")[1] ?? "";
12647
+ if (/^(?:no-?reply|do-?not-?reply|donotreply)(?:[+.-][^@]*)?@/.test(from)) {
12648
+ 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." });
12649
+ }
12650
+ if (domain(from) && domain(reply) && domain(from) !== domain(reply)) {
12651
+ 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.` });
12652
+ }
12653
+ return out;
12654
+ }
12655
+
12656
+ // src/email-readiness.ts
12533
12657
  function validateEmailReadiness(input) {
12534
12658
  const issues = [];
12535
12659
  for (const key3 of Object.keys(input.templates)) if (!input.events[key3]) {
@@ -12554,9 +12678,15 @@ ${template.text ?? ""}`.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
12554
12678
  }
12555
12679
  if (event2.requiresTier && input.tiersUnavailable) problems.push({ code: "tiers-unavailable", message: "Tier names are unavailable; delivery checks will block unresolved memberships." });
12556
12680
  else if (event2.requiresTier && noTierNames) problems.push({ code: "tiers-missing", message: "Configure the membership tier names needed by this event." });
12557
- return { ...event2, key: key3, status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready", issues: problems };
12681
+ return {
12682
+ ...event2,
12683
+ key: key3,
12684
+ status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready",
12685
+ issues: problems,
12686
+ warnings: template ? emailSpamSignals(template) : []
12687
+ };
12558
12688
  });
12559
- return { checkedAt: input.now ?? Date.now(), events, issues };
12689
+ return { checkedAt: input.now ?? Date.now(), events, issues, warnings: emailSenderWarnings(input.sender ?? {}) };
12560
12690
  }
12561
12691
 
12562
12692
  // src/email-admin-config.ts
@@ -12582,7 +12712,14 @@ async function emailSettings(db, ctx, env) {
12582
12712
  }
12583
12713
  }
12584
12714
  const groupValues = Object.fromEntries(["refundPolicyText", "commitmentText", "normsText"].map((key3) => [key3, str2(group?.[key3])]));
12585
- const validation = validateEmailReadiness({ events, templates: stored, groupValues, tiers: hub ? void 0 : tiers, tiersUnavailable });
12715
+ const validation = validateEmailReadiness({
12716
+ events,
12717
+ templates: stored,
12718
+ groupValues,
12719
+ tiers: hub ? void 0 : tiers,
12720
+ tiersUnavailable,
12721
+ sender: { fromEmail: env.EMAIL_FROM ?? null, replyTo: str2(group?.replyTo) }
12722
+ });
12586
12723
  return { group, events, data: {
12587
12724
  groupId: ctx.chapter.id,
12588
12725
  name: ctx.chapter.name,
@@ -12636,7 +12773,8 @@ async function updateEmailSettings(db, ctx, env, body) {
12636
12773
  { t: "update", ns: "groups", id: id7, attrs: { commitmentText, normsText, notificationEmail, replyTo, debugEmail, fromName } }
12637
12774
  ]);
12638
12775
  }
12639
- return json({ ok: true });
12776
+ const warnings = Object.fromEntries(Object.entries(clean).map(([key3, template]) => [key3, emailSpamSignals(template)]).filter(([, found]) => found.length));
12777
+ return json({ ok: true, warnings });
12640
12778
  }
12641
12779
 
12642
12780
  // src/worker-routes-admin-comms.ts