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