@odla-ai/chapter 0.59.0 → 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.
package/dist/index.js CHANGED
@@ -1397,6 +1397,7 @@ var DEFAULT_ADMIN_COPY = {
1397
1397
  replyTo: "Reply-to",
1398
1398
  fromName: "From name",
1399
1399
  fromNameHint: "Shown as the sender; the address stays the Worker's configured one.",
1400
+ spamRisk: "Spam-filter risk",
1400
1401
  debugInbox: "Debug inbox",
1401
1402
  debugInboxHint: "Non-production sends redirect here.",
1402
1403
  sendTest: "Send test",
@@ -1714,8 +1715,8 @@ var APPLICATION_LABELS = {
1714
1715
  state: "State"
1715
1716
  };
1716
1717
  function humanize(id11) {
1717
- const text7 = id11.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
1718
- return text7 ? `${text7[0].toUpperCase()}${text7.slice(1)}` : id11;
1718
+ const text8 = id11.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
1719
+ return text8 ? `${text8[0].toUpperCase()}${text8.slice(1)}` : id11;
1719
1720
  }
1720
1721
  function applicationDefs(chapter) {
1721
1722
  try {
@@ -4382,6 +4383,9 @@ function namedSeatRecipientState(seat) {
4382
4383
  return seat.refundReason === "declined" ? "declined" : "refunded";
4383
4384
  case "canceled":
4384
4385
  return "canceled";
4386
+ // A card-network dispute on the seat's own payment pauses the recipient until it is won or re-charged.
4387
+ case "payment_disputed":
4388
+ return "disputed";
4385
4389
  default:
4386
4390
  return "payment_pending";
4387
4391
  }
@@ -4577,12 +4581,87 @@ async function reconcileMembershipRenewal(db, input, now) {
4577
4581
 
4578
4582
  // src/membership-dispute-authority.ts
4579
4583
  import { stripeRequest as stripeRequest4 } from "@odla-ai/stripe";
4584
+
4585
+ // src/membership-dispute-transition.ts
4580
4586
  var MEMBERSHIP_DISPUTED_STATUS = "payment_disputed";
4581
4587
  var DISPUTE_WON_STATUSES = ["won", "warning_closed"];
4582
4588
  var CLOSED_STATUSES = [...DISPUTE_WON_STATUSES, "lost"];
4583
- var TERMINAL = ["refunded", "refund_pending", "canceled", "declined", "restarting", "superseded"];
4589
+ function disputeTransition(row2, fact, eventId, now, terminal) {
4590
+ const status2 = String(fact.status ?? "unknown"), closed = CLOSED_STATUSES.includes(status2);
4591
+ const sameDispute = row2.disputeId === fact.objectId;
4592
+ if (sameDispute && Number(row2.disputeClosedAt ?? 0) > 0 && !closed) return null;
4593
+ const won = DISPUTE_WON_STATUSES.includes(status2);
4594
+ const suspended = row2.status === MEMBERSHIP_DISPUTED_STATUS, done = terminal.includes(String(row2.status));
4595
+ const restored = String(row2.disputePriorStatus ?? (row2.journeyApprovedAt ? "active" : "paid_pending_approval"));
4596
+ const next = done ? String(row2.status) : won ? suspended ? restored : String(row2.status) : MEMBERSHIP_DISPUTED_STATUS;
4597
+ return {
4598
+ status: next,
4599
+ disputeId: fact.objectId,
4600
+ disputeStatus: status2,
4601
+ disputeEventId: eventId,
4602
+ disputeClosedAt: closed ? now : 0,
4603
+ ...fact.disputeReason ? { disputeReason: fact.disputeReason } : {},
4604
+ ...fact.amountCents !== void 0 ? { disputeAmountCents: fact.amountCents } : {},
4605
+ ...sameDispute ? {} : { disputedAt: now },
4606
+ ...next === MEMBERSHIP_DISPUTED_STATUS && !suspended ? { disputePriorStatus: String(row2.status) } : {},
4607
+ revision: Number(row2.revision) + 1,
4608
+ updatedAt: now
4609
+ };
4610
+ }
4611
+
4612
+ // src/membership-seat-dispute.ts
4584
4613
  var text3 = (value) => typeof value === "string" && value ? value : void 0;
4585
- var refOf = (value) => typeof value === "string" ? text3(value) : text3(value?.id);
4614
+ var TERMINAL = ["payment_pending", "payment_processing", "payment_canceling", "refund_pending", "refunded", "canceled"];
4615
+ var namedSeatDisputeStanding = (seat, applied) => ({ seatId: String(seat.id), status: String(seat.status), ...text3(seat.disputeStatus) ? { disputeStatus: text3(seat.disputeStatus) } : {}, applied });
4616
+ function disputedChargeCoversSeat(charge, seat) {
4617
+ return charge.seatId === seat.id && charge.customerId === seat.providerCustomerId && charge.paymentIntentId !== void 0 && (charge.paymentIntentId === seat.providerPaymentIntentId || charge.paymentIntentId === seat.rechargePaymentIntentId);
4618
+ }
4619
+ async function reconcileNamedSeatDispute(db, input, now, charge, evidence) {
4620
+ if (!charge.seatId) membershipFail("membership_dispute_scope_mismatch");
4621
+ const membership = async (applied, seat) => {
4622
+ const id11 = await networkMembershipId(input), member = await membershipLoad(db, "networkMembership", id11);
4623
+ requireMembershipScope(member, input);
4624
+ return {
4625
+ membershipId: id11,
4626
+ status: String(member.status),
4627
+ termEndsAt: Number(member.termEndsAt),
4628
+ graceEndsAt: Number(member.graceEndsAt ?? 0),
4629
+ autoRenew: member.autoRenew === true,
4630
+ applied,
4631
+ ...text3(member.disputeStatus) ? { disputeStatus: text3(member.disputeStatus) } : {},
4632
+ ...seat ? { seat: namedSeatDisputeStanding(seat, applied) } : {}
4633
+ };
4634
+ };
4635
+ for (let attempt = 0; attempt < 8; attempt++) {
4636
+ const state = await loadMembershipAuthorityState(db, input), seat = await membershipLoad(db, "networkSeat", charge.seatId);
4637
+ if (!seat) return membership(false);
4638
+ requireMembershipScope(seat, input);
4639
+ if (charge.customerId !== seat.providerCustomerId) membershipFail("membership_dispute_scope_mismatch");
4640
+ if (!disputedChargeCoversSeat(charge, seat) || seat.disputeEventId === input.eventId) return membership(seat.disputeEventId === input.eventId, seat);
4641
+ const attrs = disputeTransition(seat, evidence.fact, input.eventId, now, TERMINAL);
4642
+ if (!attrs) return membership(false, seat);
4643
+ try {
4644
+ await db.transact([membershipUpdate("networkSeat", String(seat.id), attrs)], { mutationId: `seat-dispute:${seat.id}:${input.eventId}`, guards: [
4645
+ { ns: "networkSeat", id: String(seat.id), exists: true, equals: { revision: seat.revision } },
4646
+ { ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative", revision: state.revision } },
4647
+ { ns: "networkCommercialEvents", id: String(evidence.row.id), exists: true, equals: { digest: evidence.fact.digest } },
4648
+ evidence.guard
4649
+ ] });
4650
+ } catch (error) {
4651
+ if (error.code !== "transact_guard_failed") throw error;
4652
+ continue;
4653
+ }
4654
+ const saved = await membershipLoad(db, "networkSeat", String(seat.id));
4655
+ requireMembershipScope(saved, input);
4656
+ return membership(true, saved);
4657
+ }
4658
+ return membershipFail("membership_dispute_retry_required");
4659
+ }
4660
+
4661
+ // src/membership-dispute-authority.ts
4662
+ var TERMINAL2 = ["refunded", "refund_pending", "canceled", "declined", "restarting", "superseded"];
4663
+ var text4 = (value) => typeof value === "string" && value ? value : void 0;
4664
+ var refOf = (value) => typeof value === "string" ? text4(value) : text4(value?.id);
4586
4665
  async function readDisputedCharge(secretKey, chargeId, fetch2) {
4587
4666
  if (!/^ch_[A-Za-z0-9]+$/.test(chargeId)) membershipFail("invalid_dispute_charge");
4588
4667
  const config = { secretKey, ...fetch2 ? { fetch: fetch2 } : {} };
@@ -4596,7 +4675,7 @@ async function readDisputedCharge(secretKey, chargeId, fetch2) {
4596
4675
  if (!invoice.ok || invoice.body.id !== invoiceId) membershipFail("membership_dispute_provider_unavailable");
4597
4676
  subscriptionId = refOf(invoice.body.subscription) ?? refOf(invoice.body.parent?.subscription_details?.subscription);
4598
4677
  }
4599
- const seatId = text3(charge.body.metadata?.seatId);
4678
+ const seatId = text4(charge.body.metadata?.seatId);
4600
4679
  return {
4601
4680
  chargeId,
4602
4681
  customerId,
@@ -4622,37 +4701,23 @@ async function reconcileMembershipDispute(db, input, now, secretKey, resolved) {
4622
4701
  termEndsAt: Number(value.termEndsAt),
4623
4702
  graceEndsAt: Number(value.graceEndsAt ?? 0),
4624
4703
  autoRenew: value.autoRenew === true,
4625
- ...text3(value.disputeStatus) ? { disputeStatus: text3(value.disputeStatus) } : {},
4704
+ ...text4(value.disputeStatus) ? { disputeStatus: text4(value.disputeStatus) } : {},
4626
4705
  applied
4627
4706
  });
4628
4707
  const known = await membershipLoad(db, "networkMembership", id11);
4629
4708
  requireMembershipScope(known, input);
4630
4709
  if (known.disputeEventId === input.eventId) return receipt(known);
4710
+ const seatKnown = known.namedSeatId ? await membershipLoad(db, "networkSeat", String(known.namedSeatId)) : void 0;
4711
+ if (seatKnown?.disputeEventId === input.eventId) return { ...receipt(known, true), seat: namedSeatDisputeStanding(seatKnown, true) };
4631
4712
  const charge = resolved && resolved.chargeId === fact.chargeId ? resolved : await readDisputedCharge(secretKey, fact.chargeId);
4713
+ if (charge.customerId !== known.providerCustomerId) membershipFail("membership_dispute_scope_mismatch");
4714
+ if (charge.seatId) return reconcileNamedSeatDispute(db, input, now, charge, { row: row2, fact, guard: evidenceGuard });
4632
4715
  for (let attempt = 0; attempt < 8; attempt++) {
4633
4716
  const state = await loadMembershipAuthorityState(db, input), member = await membershipLoad(db, "networkMembership", id11);
4634
4717
  requireMembershipScope(member, input);
4635
- if (charge.customerId !== member.providerCustomerId) membershipFail("membership_dispute_scope_mismatch");
4636
4718
  if (!disputedChargeCoversMembership(charge, member) || member.disputeEventId === input.eventId) return receipt(member);
4637
- const status2 = String(fact.status ?? "unknown"), closed = CLOSED_STATUSES.includes(status2);
4638
- const sameDispute = member.disputeId === fact.objectId;
4639
- if (sameDispute && Number(member.disputeClosedAt ?? 0) > 0 && !closed) return receipt(member, false);
4640
- const won = DISPUTE_WON_STATUSES.includes(status2);
4641
- const suspended = member.status === MEMBERSHIP_DISPUTED_STATUS, terminal = TERMINAL.includes(String(member.status));
4642
- const next = terminal ? String(member.status) : won ? suspended ? String(member.disputePriorStatus ?? (member.journeyApprovedAt ? "active" : "paid_pending_approval")) : String(member.status) : MEMBERSHIP_DISPUTED_STATUS;
4643
- const attrs = {
4644
- status: next,
4645
- disputeId: fact.objectId,
4646
- disputeStatus: status2,
4647
- disputeEventId: input.eventId,
4648
- disputeClosedAt: closed ? now : 0,
4649
- ...fact.disputeReason ? { disputeReason: fact.disputeReason } : {},
4650
- ...fact.amountCents !== void 0 ? { disputeAmountCents: fact.amountCents } : {},
4651
- ...sameDispute ? {} : { disputedAt: now },
4652
- ...next === MEMBERSHIP_DISPUTED_STATUS && !suspended ? { disputePriorStatus: String(member.status) } : {},
4653
- revision: Number(member.revision) + 1,
4654
- updatedAt: now
4655
- };
4719
+ const attrs = disputeTransition(member, fact, input.eventId, now, TERMINAL2);
4720
+ if (!attrs) return receipt(member, false);
4656
4721
  try {
4657
4722
  await db.transact([membershipUpdate("networkMembership", id11, attrs)], { mutationId: `membership-dispute:${id11}:${input.eventId}`, guards: [
4658
4723
  { ns: "networkMembership", id: id11, exists: true, equals: { revision: member.revision } },
@@ -6056,7 +6121,7 @@ async function readBoundedJson(response, signal, maxBytes, label) {
6056
6121
  };
6057
6122
  signal.addEventListener("abort", cancel, { once: true });
6058
6123
  let size = 0;
6059
- let text7 = "";
6124
+ let text8 = "";
6060
6125
  const decoder = new TextDecoder();
6061
6126
  try {
6062
6127
  while (true) {
@@ -6066,10 +6131,10 @@ async function readBoundedJson(response, signal, maxBytes, label) {
6066
6131
  if (done) break;
6067
6132
  size += value.byteLength;
6068
6133
  if (size > maxBytes) throw new TypeError(`${label} exceeds limit`);
6069
- text7 += decoder.decode(value, { stream: true });
6134
+ text8 += decoder.decode(value, { stream: true });
6070
6135
  }
6071
6136
  try {
6072
- return JSON.parse(text7 + decoder.decode());
6137
+ return JSON.parse(text8 + decoder.decode());
6073
6138
  } catch {
6074
6139
  throw new TypeError(`invalid ${label}`);
6075
6140
  }
@@ -6556,6 +6621,19 @@ var id10 = () => attr9("string", false, true, true);
6556
6621
  var key2 = (optional = false) => attr9("string", optional, false, true);
6557
6622
  var scope = () => ({ appId: key2(), chapterId: key2(), environment: key2(), runtime: key2(), applicationId: key2(), skuId: key2() });
6558
6623
  var times = () => ({ revision: attr9("number"), createdAt: attr9("number"), updatedAt: attr9("number") });
6624
+ var dispute = () => ({
6625
+ disputeId: key2(true),
6626
+ disputeStatus: attr9("string", true),
6627
+ disputeReason: attr9("string", true),
6628
+ disputeAmountCents: attr9("number", true),
6629
+ disputedAt: attr9("number", true),
6630
+ disputeClosedAt: attr9("number", true),
6631
+ disputePriorStatus: attr9("string", true),
6632
+ disputeEventId: key2(true),
6633
+ rechargePaymentIntentId: key2(true),
6634
+ rechargedAt: attr9("number", true),
6635
+ rechargeAttempt: attr9("json", true)
6636
+ });
6559
6637
  var membershipAuthorityEntities = {
6560
6638
  networkMembership: { attrs: {
6561
6639
  id: id10(),
@@ -6596,19 +6674,7 @@ var membershipAuthorityEntities = {
6596
6674
  ...times(),
6597
6675
  refundRequestedAt: attr9("number", true),
6598
6676
  restartId: key2(true),
6599
- // A card-network dispute on this membership's payment: Stripe's dispute id and status, the reason the cardholder gave,
6600
- // the status the membership held before it was suspended, and the re-issued charge once an operator collected again.
6601
- disputeId: key2(true),
6602
- disputeStatus: attr9("string", true),
6603
- disputeReason: attr9("string", true),
6604
- disputeAmountCents: attr9("number", true),
6605
- disputedAt: attr9("number", true),
6606
- disputeClosedAt: attr9("number", true),
6607
- disputePriorStatus: attr9("string", true),
6608
- disputeEventId: key2(true),
6609
- rechargePaymentIntentId: key2(true),
6610
- rechargedAt: attr9("number", true),
6611
- rechargeAttempt: attr9("json", true)
6677
+ ...dispute()
6612
6678
  } },
6613
6679
  membershipRestart: { attrs: {
6614
6680
  id: id10(),
@@ -6684,7 +6750,8 @@ var membershipAuthorityEntities = {
6684
6750
  refundedCents: attr9("number", true),
6685
6751
  providerRefundIds: attr9("json", true),
6686
6752
  regiftedAt: attr9("number", true),
6687
- formerRecipients: attr9("json", true)
6753
+ formerRecipients: attr9("json", true),
6754
+ ...dispute()
6688
6755
  } },
6689
6756
  networkSeatRecipient: { attrs: { id: id10(), appId: key2(), chapterId: key2(), environment: key2(), runtime: key2(), seatId: key2(), recipientEmailNormalized: key2(), createdAt: attr9("number") } },
6690
6757
  membershipEffect: { attrs: {
@@ -7044,7 +7111,7 @@ var record2 = (value) => value !== null && typeof value === "object" && !Array.i
7044
7111
  function unknownKeys2(value, allowed2, path, errors) {
7045
7112
  for (const key3 of Object.keys(value)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
7046
7113
  }
7047
- function text4(value, path, errors, max = MAX_TEXT2, required = true) {
7114
+ function text5(value, path, errors, max = MAX_TEXT2, required = true) {
7048
7115
  if (value === void 0 && !required) return void 0;
7049
7116
  if (typeof value !== "string" || required && !value.trim()) {
7050
7117
  errors.push(`${path} must be a non-empty string`);
@@ -7070,7 +7137,7 @@ function scopeValue(value, path, errors) {
7070
7137
  return { kind: raw2.kind };
7071
7138
  }
7072
7139
  if (raw2.kind === "chapter") {
7073
- const chapterId = text4(raw2.chapterId, `${path}.chapterId`, errors, 63) ?? "";
7140
+ const chapterId = text5(raw2.chapterId, `${path}.chapterId`, errors, 63) ?? "";
7074
7141
  if (chapterId && !APP_ID.test(chapterId)) errors.push(`${path}.chapterId must be a lowercase app slug`);
7075
7142
  return { kind: "chapter", chapterId };
7076
7143
  }
@@ -7084,22 +7151,22 @@ function resourceValue(value, path, errors) {
7084
7151
  return null;
7085
7152
  }
7086
7153
  unknownKeys2(raw2, RESOURCE_KEYS, path, errors);
7087
- const id11 = text4(raw2.id, `${path}.id`, errors, 80) ?? "";
7154
+ const id11 = text5(raw2.id, `${path}.id`, errors, 80) ?? "";
7088
7155
  if (id11 && !ID2.test(id11)) errors.push(`${path}.id must be lowercase kebab-case`);
7089
- const kind = text4(raw2.kind, `${path}.kind`, errors) ?? "";
7156
+ const kind = text5(raw2.kind, `${path}.kind`, errors) ?? "";
7090
7157
  if (!KINDS.has(kind)) errors.push(`${path}.kind is unsupported`);
7091
- const title = text4(raw2.title, `${path}.title`, errors, 240) ?? "";
7092
- const summary = text4(raw2.summary, `${path}.summary`, errors, 1e3, false);
7093
- const status2 = text4(raw2.status, `${path}.status`, errors) ?? "";
7158
+ const title = text5(raw2.title, `${path}.title`, errors, 240) ?? "";
7159
+ const summary = text5(raw2.summary, `${path}.summary`, errors, 1e3, false);
7160
+ const status2 = text5(raw2.status, `${path}.status`, errors) ?? "";
7094
7161
  if (!STATUSES.has(status2)) errors.push(`${path}.status must be draft, published, or archived`);
7095
7162
  const revision = integer3(raw2.revision, `${path}.revision`, errors);
7096
7163
  const scope2 = scopeValue(raw2.scope, `${path}.scope`, errors) ?? { kind: "global" };
7097
- const tags = Array.isArray(raw2.tags) ? raw2.tags.map((tag, index2) => text4(tag, `${path}.tags[${index2}]`, errors, 80) ?? "") : [];
7164
+ const tags = Array.isArray(raw2.tags) ? raw2.tags.map((tag, index2) => text5(tag, `${path}.tags[${index2}]`, errors, 80) ?? "") : [];
7098
7165
  if (!Array.isArray(raw2.tags)) errors.push(`${path}.tags must be an array`);
7099
7166
  if (tags.length > MAX_TAGS) errors.push(`${path}.tags may contain at most ${MAX_TAGS} items`);
7100
7167
  if (new Set(tags).size !== tags.length) errors.push(`${path}.tags must be unique`);
7101
- const bodyMarkdown = text4(raw2.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2, false);
7102
- const url = text4(raw2.url, `${path}.url`, errors, 2048, false);
7168
+ const bodyMarkdown = text5(raw2.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2, false);
7169
+ const url = text5(raw2.url, `${path}.url`, errors, 2048, false);
7103
7170
  if (url) {
7104
7171
  try {
7105
7172
  if (new URL(url).protocol !== "https:") errors.push(`${path}.url must use https`);
@@ -7111,9 +7178,9 @@ function resourceValue(value, path, errors) {
7111
7178
  if (raw2.brandAsset !== void 0 && !asset) errors.push(`${path}.brandAsset must be an object`);
7112
7179
  if (asset) unknownKeys2(asset, ASSET_KEYS, `${path}.brandAsset`, errors);
7113
7180
  const brandAsset = asset ? {
7114
- bookId: text4(asset.bookId, `${path}.brandAsset.bookId`, errors, 160) ?? "",
7115
- assetId: text4(asset.assetId, `${path}.brandAsset.assetId`, errors, 160) ?? "",
7116
- ...asset.filename === void 0 ? {} : { filename: text4(asset.filename, `${path}.brandAsset.filename`, errors, 240) ?? "" }
7181
+ bookId: text5(asset.bookId, `${path}.brandAsset.bookId`, errors, 160) ?? "",
7182
+ assetId: text5(asset.assetId, `${path}.brandAsset.assetId`, errors, 160) ?? "",
7183
+ ...asset.filename === void 0 ? {} : { filename: text5(asset.filename, `${path}.brandAsset.filename`, errors, 240) ?? "" }
7117
7184
  } : void 0;
7118
7185
  if ((kind === "document" || kind === "note") && !bodyMarkdown) errors.push(`${path}.bodyMarkdown is required for ${kind}`);
7119
7186
  if ((kind === "link" || kind === "inspiration") && !url) errors.push(`${path}.url is required for ${kind}`);
@@ -7128,17 +7195,17 @@ function resourceValue(value, path, errors) {
7128
7195
  if (provenanceRaw?.promotedFrom !== void 0 && !promotedRaw) errors.push(`${path}.provenance.promotedFrom must be an object`);
7129
7196
  if (promotedRaw) unknownKeys2(promotedRaw, PROMOTION_KEYS, `${path}.provenance.promotedFrom`, errors);
7130
7197
  const promotedFrom = promotedRaw ? {
7131
- chapterId: text4(promotedRaw.chapterId, `${path}.provenance.promotedFrom.chapterId`, errors, 63) ?? "",
7132
- resourceId: text4(promotedRaw.resourceId, `${path}.provenance.promotedFrom.resourceId`, errors, 80) ?? "",
7198
+ chapterId: text5(promotedRaw.chapterId, `${path}.provenance.promotedFrom.chapterId`, errors, 63) ?? "",
7199
+ resourceId: text5(promotedRaw.resourceId, `${path}.provenance.promotedFrom.resourceId`, errors, 80) ?? "",
7133
7200
  resourceRevision: integer3(promotedRaw.resourceRevision, `${path}.provenance.promotedFrom.resourceRevision`, errors),
7134
7201
  documentRevision: integer3(promotedRaw.documentRevision, `${path}.provenance.promotedFrom.documentRevision`, errors),
7135
- promotedBy: text4(promotedRaw.promotedBy, `${path}.provenance.promotedFrom.promotedBy`, errors, 320) ?? "",
7202
+ promotedBy: text5(promotedRaw.promotedBy, `${path}.provenance.promotedFrom.promotedBy`, errors, 320) ?? "",
7136
7203
  promotedAt: integer3(promotedRaw.promotedAt, `${path}.provenance.promotedFrom.promotedAt`, errors, 0)
7137
7204
  } : void 0;
7138
7205
  const provenance = {
7139
- createdBy: text4(provenanceRaw?.createdBy, `${path}.provenance.createdBy`, errors, 320) ?? "",
7206
+ createdBy: text5(provenanceRaw?.createdBy, `${path}.provenance.createdBy`, errors, 320) ?? "",
7140
7207
  createdAt: integer3(provenanceRaw?.createdAt, `${path}.provenance.createdAt`, errors, 0),
7141
- updatedBy: text4(provenanceRaw?.updatedBy, `${path}.provenance.updatedBy`, errors, 320) ?? "",
7208
+ updatedBy: text5(provenanceRaw?.updatedBy, `${path}.provenance.updatedBy`, errors, 320) ?? "",
7142
7209
  updatedAt: integer3(provenanceRaw?.updatedAt, `${path}.provenance.updatedAt`, errors, 0),
7143
7210
  ...promotedFrom ? { promotedFrom } : {}
7144
7211
  };
@@ -7157,7 +7224,7 @@ function validateBuilderLibraryDocument(value, options = {}) {
7157
7224
  if (sourceRaw) unknownKeys2(sourceRaw, SOURCE_KEYS2, "document.source", errors);
7158
7225
  const sourceType = sourceRaw?.type;
7159
7226
  if (sourceType !== "chapter" && sourceType !== "built_not_found") errors.push("document.source.type must be chapter or built_not_found");
7160
- const sourceAppId = text4(sourceRaw?.appId, "document.source.appId", errors, 63) ?? "";
7227
+ const sourceAppId = text5(sourceRaw?.appId, "document.source.appId", errors, 63) ?? "";
7161
7228
  if (sourceAppId && !APP_ID.test(sourceAppId)) errors.push("document.source.appId must be a lowercase app slug");
7162
7229
  if (options.sourceType && sourceType !== options.sourceType) errors.push(`document.source.type must be ${options.sourceType}`);
7163
7230
  if (options.sourceAppId && sourceAppId !== options.sourceAppId) errors.push(`document.source.appId must be ${options.sourceAppId}`);
@@ -7165,8 +7232,8 @@ function validateBuilderLibraryDocument(value, options = {}) {
7165
7232
  if (raw2.target !== void 0 && !targetRaw) errors.push("document.target must be an object");
7166
7233
  if (targetRaw) unknownKeys2(targetRaw, TARGET_KEYS2, "document.target", errors);
7167
7234
  const target = targetRaw ? {
7168
- appId: text4(targetRaw.appId, "document.target.appId", errors, 63) ?? "",
7169
- environment: text4(targetRaw.environment, "document.target.environment", errors, 80) ?? ""
7235
+ appId: text5(targetRaw.appId, "document.target.appId", errors, 63) ?? "",
7236
+ environment: text5(targetRaw.environment, "document.target.environment", errors, 80) ?? ""
7170
7237
  } : void 0;
7171
7238
  if (options.targetAppId && target?.appId !== options.targetAppId) errors.push(`document.target.appId must be ${options.targetAppId}`);
7172
7239
  if (!Array.isArray(raw2.resources)) errors.push("document.resources must be an array");
@@ -7261,9 +7328,9 @@ async function createBuilderLibraryEnvelope(document) {
7261
7328
  }
7262
7329
 
7263
7330
  // src/email-retirement.ts
7264
- function isRetiredEmailCopy(subject, text7) {
7331
+ function isRetiredEmailCopy(subject, text8) {
7265
7332
  const subjectCopy = subject.trimStart().replace(/^\[dev\]\s*/i, "");
7266
- const bodyCopy = text7.trimStart().replace(/^\(dev redirect; original recipient: [^\r\n]*\)\r?\n\r?\n/, "");
7333
+ const bodyCopy = text8.trimStart().replace(/^\(dev redirect; original recipient: [^\r\n]*\)\r?\n\r?\n/, "");
7267
7334
  return /^\(\s*unused\s*:/i.test(subjectCopy) || /^Unused\.\s*Switched off\b/i.test(bodyCopy);
7268
7335
  }
7269
7336
 
@@ -7341,14 +7408,92 @@ function planDelivery(input) {
7341
7408
  if (isRetiredEmailCopy(renderedSubject, renderedText)) return { deliver: false, reason: "template-retired" };
7342
7409
  if (validateEmailCopy({ subject: renderedSubject, text: renderedText }, { allowedVariables: [] }).length) return { deliver: false, reason: "template-invalid" };
7343
7410
  const subject = (redirect ? "[dev] " : "") + renderedSubject;
7344
- const text7 = redirect ? `(dev redirect; original recipient: ${input.to})
7411
+ const text8 = redirect ? `(dev redirect; original recipient: ${input.to})
7345
7412
 
7346
7413
  ` + renderedText : renderedText;
7347
- return { deliver: true, transport, to, subject, text: text7, redirected: redirect };
7414
+ return { deliver: true, transport, to, subject, text: text8, redirected: redirect };
7348
7415
  }
7349
7416
 
7350
7417
  // src/email-readiness.ts
7351
7418
  import { isEmailTierName, validateEmailCopy as validateEmailCopy2 } from "@odla-ai/crm";
7419
+
7420
+ // src/email-spam-signals.ts
7421
+ var TRIGGER_PHRASES = [
7422
+ "act now",
7423
+ "click here",
7424
+ "limited time",
7425
+ "guaranteed",
7426
+ "risk-free",
7427
+ "risk free",
7428
+ "no obligation",
7429
+ "100% free",
7430
+ "winner",
7431
+ "congratulations",
7432
+ "urgent",
7433
+ "double your",
7434
+ "earn money",
7435
+ "make money",
7436
+ "cash bonus",
7437
+ "once in a lifetime",
7438
+ "exclusive deal",
7439
+ "order now",
7440
+ "buy now",
7441
+ "this is not spam",
7442
+ "dear friend",
7443
+ "dear customer"
7444
+ ];
7445
+ var SUBJECT_TRIGGERS = ["free", ...TRIGGER_PHRASES];
7446
+ var DEV_HOSTS = /(?:^|\.)(?:workers\.dev|pages\.dev|localhost|ngrok(?:-free)?\.app|trycloudflare\.com)$/i;
7447
+ var SHORTENERS = /^(?:bit\.ly|tinyurl\.com|t\.co|goo\.gl|ow\.ly|is\.gd|buff\.ly|rebrand\.ly|cutt\.ly|tiny\.cc|shorturl\.at|rb\.gy)$/i;
7448
+ var URL_RE = /https?:\/\/[^\s<>"')\]]+/g;
7449
+ var strip = (s) => s.replace(/\{\{\w+\}\}/g, " ");
7450
+ var words = (s) => strip(s).split(/\s+/).filter(Boolean);
7451
+ var phraseIn = (phrases, haystack) => phrases.find((phrase) => new RegExp(`(?:^|[^a-z0-9])${phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![a-z0-9])`, "i").test(haystack));
7452
+ function emailSpamSignals(copy) {
7453
+ const subject = typeof copy.subject === "string" ? copy.subject : "";
7454
+ const text8 = typeof copy.text === "string" ? copy.text : "";
7455
+ const out = [];
7456
+ const letters = strip(subject).replace(/[^A-Za-z]/g, "");
7457
+ 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, "")));
7458
+ if (shouting) out.push({ code: "subject-caps", message: "The subject shouts in capital letters. Filters read that as a spam signal; use sentence case." });
7459
+ if (/[!?]{2,}|!\s*$/.test(subject)) out.push({ code: "subject-punctuation", message: "Repeated or trailing exclamation marks in the subject are a classic spam signal." });
7460
+ 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." });
7461
+ const subjectHit = phraseIn(SUBJECT_TRIGGERS, strip(subject));
7462
+ if (subjectHit) out.push({ code: "subject-trigger-words", message: `The subject uses "${subjectHit}", a word filters weight against the sender.` });
7463
+ const bodyHit = phraseIn(TRIGGER_PHRASES, strip(text8));
7464
+ if (bodyHit) out.push({ code: "body-trigger-words", message: `The body uses "${bodyHit}", a phrase filters weight against the sender.` });
7465
+ if (/(?:^|\s)[A-Z]{3,}(?:\s+[A-Z]{3,}){2,}(?=\s|$)/.test(strip(text8))) out.push({ code: "body-caps", message: "The body shouts a run of capitalised words; use sentence case." });
7466
+ if (/!{2,}/.test(text8)) out.push({ code: "body-punctuation", message: "Repeated exclamation marks in the body are a classic spam signal." });
7467
+ const urls = [...text8.matchAll(URL_RE)].map((match) => match[0]);
7468
+ const hosts = urls.map((url) => {
7469
+ try {
7470
+ return new URL(url).hostname;
7471
+ } catch {
7472
+ return "";
7473
+ }
7474
+ });
7475
+ 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." });
7476
+ 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." });
7477
+ 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." });
7478
+ 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." });
7479
+ 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." });
7480
+ return out;
7481
+ }
7482
+ function emailSenderWarnings(input) {
7483
+ const out = [];
7484
+ const from = (input.fromEmail ?? "").trim().toLowerCase().replace(/^.*<([^>]+)>.*$/, "$1");
7485
+ const reply = (input.replyTo ?? "").trim().toLowerCase();
7486
+ const domain = (address) => address.split("@")[1] ?? "";
7487
+ if (/^(?:no-?reply|do-?not-?reply|donotreply)(?:[+.-][^@]*)?@/.test(from)) {
7488
+ 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." });
7489
+ }
7490
+ if (domain(from) && domain(reply) && domain(from) !== domain(reply)) {
7491
+ 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.` });
7492
+ }
7493
+ return out;
7494
+ }
7495
+
7496
+ // src/email-readiness.ts
7352
7497
  function validateEmailReadiness(input) {
7353
7498
  const issues = [];
7354
7499
  for (const key3 of Object.keys(input.templates)) if (!input.events[key3]) {
@@ -7373,9 +7518,56 @@ ${template.text ?? ""}`.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
7373
7518
  }
7374
7519
  if (event2.requiresTier && input.tiersUnavailable) problems.push({ code: "tiers-unavailable", message: "Tier names are unavailable; delivery checks will block unresolved memberships." });
7375
7520
  else if (event2.requiresTier && noTierNames) problems.push({ code: "tiers-missing", message: "Configure the membership tier names needed by this event." });
7376
- return { ...event2, key: key3, status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready", issues: problems };
7521
+ return {
7522
+ ...event2,
7523
+ key: key3,
7524
+ status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready",
7525
+ issues: problems,
7526
+ warnings: template ? emailSpamSignals(template) : []
7527
+ };
7377
7528
  });
7378
- return { checkedAt: input.now ?? Date.now(), events: events2, issues };
7529
+ return { checkedAt: input.now ?? Date.now(), events: events2, issues, warnings: emailSenderWarnings(input.sender ?? {}) };
7530
+ }
7531
+
7532
+ // src/email-html.ts
7533
+ var URL_RE2 = /https?:\/\/[^\s<>"')\]]+[^\s<>"').,;:!?\]]/g;
7534
+ function escapeHtml(s) {
7535
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
7536
+ }
7537
+ function lineHtml(line) {
7538
+ let out = "", last = 0;
7539
+ for (const match of line.matchAll(URL_RE2)) {
7540
+ out += escapeHtml(line.slice(last, match.index));
7541
+ const url = escapeHtml(match[0]);
7542
+ out += `<a href="${url}">${url}</a>`;
7543
+ last = match.index + match[0].length;
7544
+ }
7545
+ return out + escapeHtml(line.slice(last));
7546
+ }
7547
+ function textToHtml(text8) {
7548
+ const paragraphs = text8.replace(/\r\n/g, "\n").trim().split(/\n{2,}/).filter((paragraph) => paragraph.trim());
7549
+ const body = paragraphs.map((paragraph) => `<p>${paragraph.split("\n").map(lineHtml).join("<br>\n")}</p>`).join("\n");
7550
+ return `<!doctype html>
7551
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
7552
+ <body style="margin:0;padding:24px 16px;background:#ffffff;color:#1a1a1a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;font-size:16px;line-height:1.5">
7553
+ ${body}
7554
+ </body></html>`;
7555
+ }
7556
+
7557
+ // src/email-deliverability.ts
7558
+ var ADMINISTRATOR_AUDIENCE = /^(?:administrator|admin|team|operator|owner|staff)s?$/i;
7559
+ function isRecipientFacing(event2) {
7560
+ return !event2 || !ADMINISTRATOR_AUDIENCE.test(event2.audience.trim());
7561
+ }
7562
+ function listUnsubscribeHeader(replyTo) {
7563
+ const address = replyTo.trim();
7564
+ if (!/^[^\s@,;<>?]+@[^\s@,;<>?]+\.[^\s@,;<>?]+$/.test(address)) return void 0;
7565
+ return `<mailto:${address}?subject=${encodeURIComponent("unsubscribe")}>`;
7566
+ }
7567
+ function deliverabilityHeaders(input) {
7568
+ if (!isRecipientFacing(input.event)) return void 0;
7569
+ const value = listUnsubscribeHeader(input.replyTo);
7570
+ return value ? { "List-Unsubscribe": value } : void 0;
7379
7571
  }
7380
7572
 
7381
7573
  // src/notify.ts
@@ -7543,11 +7735,16 @@ async function sendTemplated(deps, input) {
7543
7735
  if (decision.transport === "log-only") return { sent: true };
7544
7736
  let messageId;
7545
7737
  try {
7738
+ const text8 = String(row2.body);
7739
+ const html = deps.renderHtml === false ? void 0 : (deps.renderHtml ?? textToHtml)(text8);
7740
+ const headers = deliverabilityHeaders({ event: event2, replyTo: input.group.replyTo });
7546
7741
  const response = await deps.sender.send({
7547
7742
  from: fromAddress(deps.from, input.group.fromName),
7548
7743
  to: [decision.to],
7549
7744
  subject: String(row2.subject),
7550
- text: String(row2.body),
7745
+ text: text8,
7746
+ ...html ? { html } : {},
7747
+ ...headers ? { headers } : {},
7551
7748
  replyTo: input.group.replyTo
7552
7749
  });
7553
7750
  if (!response || typeof response.messageId !== "string" || !response.messageId) throw new Error("missing delivery receipt");
@@ -8000,7 +8197,7 @@ var ID4 = /^[a-z][a-z0-9-]{1,79}$/;
8000
8197
  var PRICE = /^price_[A-Za-z0-9]{12,}$/;
8001
8198
  var CURRENCY = /^[a-z]{3}$/;
8002
8199
  var record3 = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
8003
- var text5 = (value, path, errors, max) => {
8200
+ var text6 = (value, path, errors, max) => {
8004
8201
  if (typeof value !== "string" || !value.trim()) {
8005
8202
  errors.push(`${path} is required`);
8006
8203
  return "";
@@ -8028,23 +8225,23 @@ function validateGiftOfferCatalog(input, tierIds) {
8028
8225
  }
8029
8226
  const allowed2 = /* @__PURE__ */ new Set(["id", "target", "billingMode", "stripePriceId", "priceCents", "currency", "redemptionLifetimeHours", "activeFrom", "activeUntil", "title", "description", "ctaLabel"]);
8030
8227
  for (const key3 of Object.keys(item)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
8031
- const id11 = text5(item.id, `${path}.id`, errors, 80);
8228
+ const id11 = text6(item.id, `${path}.id`, errors, 80);
8032
8229
  if (id11 && !ID4.test(id11)) errors.push(`${path}.id must be a lowercase kebab-case id`);
8033
8230
  if (ids2.has(id11)) errors.push(`${path}.id duplicates "${id11}"`);
8034
8231
  ids2.add(id11);
8035
8232
  const targetRaw = record3(item.target);
8036
8233
  let target = { type: "entitlement", entitlementKey: "invalid" };
8037
8234
  if (targetRaw?.type === "tier") {
8038
- const tierId = text5(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
8235
+ const tierId = text6(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
8039
8236
  if (!tierIds.has(tierId)) errors.push(`${path}.target.tierId must reference a declared tier`);
8040
8237
  target = { type: "tier", tierId };
8041
8238
  } else if (targetRaw?.type === "entitlement") {
8042
- const entitlementKey = text5(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
8239
+ const entitlementKey = text6(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
8043
8240
  if (entitlementKey && !ID4.test(entitlementKey)) errors.push(`${path}.target.entitlementKey must be a lowercase kebab-case id`);
8044
8241
  target = { type: "entitlement", entitlementKey };
8045
8242
  } else errors.push(`${path}.target.type must be tier or entitlement`);
8046
8243
  if (item.billingMode !== "one_time" && item.billingMode !== "subscription") errors.push(`${path}.billingMode must be one_time or subscription`);
8047
- const stripePriceId = text5(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
8244
+ const stripePriceId = text6(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
8048
8245
  if (stripePriceId && !PRICE.test(stripePriceId)) errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
8049
8246
  if (!Number.isSafeInteger(item.priceCents) || Number(item.priceCents) < 1 || Number(item.priceCents) > 1e8) errors.push(`${path}.priceCents must be an integer from 1 to 100,000,000`);
8050
8247
  const currency = typeof item.currency === "string" ? item.currency.trim().toLowerCase() : "";
@@ -8066,9 +8263,9 @@ function validateGiftOfferCatalog(input, tierIds) {
8066
8263
  redemptionLifetimeHours: lifetime,
8067
8264
  ...activeFrom !== void 0 ? { activeFrom } : {},
8068
8265
  ...activeUntil !== void 0 ? { activeUntil } : {},
8069
- title: text5(item.title, `${path}.title`, errors, 160),
8070
- description: text5(item.description, `${path}.description`, errors, 2e3),
8071
- ctaLabel: text5(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
8266
+ title: text6(item.title, `${path}.title`, errors, 160),
8267
+ description: text6(item.description, `${path}.description`, errors, 2e3),
8268
+ ctaLabel: text6(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
8072
8269
  });
8073
8270
  }
8074
8271
  return errors.length ? { ok: false, errors } : { ok: true, value: { schemaVersion: 1, offers } };
@@ -9156,10 +9353,10 @@ function boundedString(value, path, errors, max, required = false) {
9156
9353
  errors.push(`${path} must be a string`);
9157
9354
  return void 0;
9158
9355
  }
9159
- const text7 = value.trim();
9160
- if (required && !text7) errors.push(`${path} is required`);
9161
- if (text7.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
9162
- return text7;
9356
+ const text8 = value.trim();
9357
+ if (required && !text8) errors.push(`${path} is required`);
9358
+ if (text8.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
9359
+ return text8;
9163
9360
  }
9164
9361
  function validateMembershipPageDocument(input, options = {}) {
9165
9362
  const errors = [];
@@ -9208,8 +9405,8 @@ function validateMembershipPageDocument(input, options = {}) {
9208
9405
  else {
9209
9406
  if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
9210
9407
  benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
9211
- const text7 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
9212
- return text7 ? [text7] : [];
9408
+ const text8 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
9409
+ return text8 ? [text8] : [];
9213
9410
  });
9214
9411
  }
9215
9412
  }
@@ -9308,7 +9505,7 @@ var recordOf = (value) => value !== null && typeof value === "object" && !Array.
9308
9505
  function unknownKeys3(value, allowed2, path, errors) {
9309
9506
  for (const key3 of Object.keys(value)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
9310
9507
  }
9311
- function text6(value, path, errors, max = 65536) {
9508
+ function text7(value, path, errors, max = 65536) {
9312
9509
  if (typeof value !== "string" || !value.trim()) {
9313
9510
  errors.push(`${path} is required`);
9314
9511
  return "";
@@ -9331,11 +9528,11 @@ function tiersOf(value, errors) {
9331
9528
  return [];
9332
9529
  }
9333
9530
  unknownKeys3(item, TIER_KEYS2, path, errors);
9334
- const id11 = text6(item.id, `${path}.id`, errors, 80);
9531
+ const id11 = text7(item.id, `${path}.id`, errors, 80);
9335
9532
  if (id11 && !TIER.test(id11)) errors.push(`${path}.id must be a lowercase kebab-case id`);
9336
9533
  if (seen.has(id11)) errors.push(`${path}.id duplicates "${id11}"`);
9337
9534
  seen.add(id11);
9338
- const name = text6(item.name, `${path}.name`, errors, 160);
9535
+ const name = text7(item.name, `${path}.name`, errors, 160);
9339
9536
  if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
9340
9537
  if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
9341
9538
  if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
@@ -9353,7 +9550,7 @@ function tiersOf(value, errors) {
9353
9550
  if (item.interval !== void 0 && item.interval !== "year" && item.interval !== "month") {
9354
9551
  errors.push(`${path}.interval must be year or month`);
9355
9552
  }
9356
- const blurb = item.blurb === void 0 ? void 0 : text6(item.blurb, `${path}.blurb`, errors, 4e3);
9553
+ const blurb = item.blurb === void 0 ? void 0 : text7(item.blurb, `${path}.blurb`, errors, 4e3);
9357
9554
  return [{
9358
9555
  id: id11,
9359
9556
  name,
@@ -9411,7 +9608,7 @@ function policyOf(value, errors, tiers2) {
9411
9608
  const result = {};
9412
9609
  for (const key3 of allowed2) {
9413
9610
  if (input[key3] === void 0 && key3 !== "disclaimerText" && key3 !== "refundPolicyText") continue;
9414
- result[key3] = text6(input[key3], `document.policy.${key3}`, errors);
9611
+ result[key3] = text7(input[key3], `document.policy.${key3}`, errors);
9415
9612
  }
9416
9613
  const tierPolicies = {};
9417
9614
  if (input.tierPolicies !== void 0) {
@@ -9426,8 +9623,8 @@ function policyOf(value, errors, tiers2) {
9426
9623
  }
9427
9624
  unknownKeys3(policy2, /* @__PURE__ */ new Set(["refundPolicyText", "trustCopy"]), path, errors);
9428
9625
  tierPolicies[id11] = {
9429
- refundPolicyText: text6(policy2.refundPolicyText, `${path}.refundPolicyText`, errors),
9430
- ...policy2.trustCopy !== void 0 ? { trustCopy: text6(policy2.trustCopy, `${path}.trustCopy`, errors) } : {}
9626
+ refundPolicyText: text7(policy2.refundPolicyText, `${path}.refundPolicyText`, errors),
9627
+ ...policy2.trustCopy !== void 0 ? { trustCopy: text7(policy2.trustCopy, `${path}.trustCopy`, errors) } : {}
9431
9628
  };
9432
9629
  }
9433
9630
  }
@@ -9467,9 +9664,9 @@ function validateSignupControlDocument(input) {
9467
9664
  if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
9468
9665
  const source = recordOf(root.source);
9469
9666
  const target = recordOf(root.target);
9470
- const appId = text6(source?.appId, "document.source.appId", errors, 63);
9471
- const chapterId = text6(target?.chapterId, "document.target.chapterId", errors, 63);
9472
- const environment = text6(target?.environment, "document.target.environment", errors, 63);
9667
+ const appId = text7(source?.appId, "document.source.appId", errors, 63);
9668
+ const chapterId = text7(target?.chapterId, "document.target.chapterId", errors, 63);
9669
+ const environment = text7(target?.environment, "document.target.environment", errors, 63);
9473
9670
  if (appId && !SLUG9.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
9474
9671
  if (chapterId && !SLUG9.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
9475
9672
  if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
@@ -9646,9 +9843,9 @@ function markdownBenefits(lines, path, errors) {
9646
9843
  errors.push(`${path} line ${index2 + 1} must be a Markdown bullet`);
9647
9844
  continue;
9648
9845
  }
9649
- const text7 = (match[1] ?? "").trim();
9650
- if (text7.length > 2e3) errors.push(`${path} line ${index2 + 1} must be at most 2,000 characters`);
9651
- if (text7) benefits.push(text7);
9846
+ const text8 = (match[1] ?? "").trim();
9847
+ if (text8.length > 2e3) errors.push(`${path} line ${index2 + 1} must be at most 2,000 characters`);
9848
+ if (text8) benefits.push(text8);
9652
9849
  }
9653
9850
  if (benefits.length > MAX_BENEFITS2) errors.push(`${path} supports at most ${MAX_BENEFITS2} entries`);
9654
9851
  return benefits.slice(0, MAX_BENEFITS2);
@@ -10306,6 +10503,7 @@ function createMembershipAuthorityIntegration() {
10306
10503
  };
10307
10504
  }
10308
10505
  export {
10506
+ ADMINISTRATOR_AUDIENCE,
10309
10507
  ADMISSION_GRANTS_PATH,
10310
10508
  ADMISSION_GRANTS_SCHEMA_VERSION,
10311
10509
  BUILDER_LIBRARY_ADMIN_PATH,
@@ -10399,9 +10597,12 @@ export {
10399
10597
  dashboardMetricData,
10400
10598
  defaultCrm,
10401
10599
  defineChapter,
10600
+ deliverabilityHeaders,
10402
10601
  drainMembershipEffects,
10403
10602
  edgeUnpaired,
10404
10603
  emailGroupFrom,
10604
+ emailSenderWarnings,
10605
+ emailSpamSignals,
10405
10606
  endForSlot,
10406
10607
  exportChapterPromotion,
10407
10608
  findAdmissionGrant,
@@ -10420,6 +10621,7 @@ export {
10420
10621
  introIdempotencyKey,
10421
10622
  isAdminRole,
10422
10623
  isAlreadySent,
10624
+ isRecipientFacing,
10423
10625
  isReconcilable,
10424
10626
  isSlotAvailable,
10425
10627
  isValidEmail,
@@ -10428,6 +10630,7 @@ export {
10428
10630
  joinConfigTiers,
10429
10631
  leaderCrmConfig,
10430
10632
  listStripeObjects,
10633
+ listUnsubscribeHeader,
10431
10634
  loadLatestAdmissionRevision,
10432
10635
  loadOfferableTiers,
10433
10636
  loadTiers,
@@ -10545,6 +10748,7 @@ export {
10545
10748
  submitApplication,
10546
10749
  subscriptionIdempotencyKey,
10547
10750
  syncApplicationToCrm,
10751
+ textToHtml,
10548
10752
  tierIsFree,
10549
10753
  tierPayable,
10550
10754
  updateClerkUserMetadata,