@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/worker/index.d.cts
CHANGED
|
@@ -122,7 +122,7 @@ interface ChapterCopy {
|
|
|
122
122
|
availability: TextFields<"loading" | "loadFailed" | "saved" | "title" | "days" | "startHour" | "endHour" | "slotMinutes" | "minNoticeHours" | "windowDays" | "timezone" | "summaryTemplate" | "save"> & {
|
|
123
123
|
dayLabels: readonly string[];
|
|
124
124
|
};
|
|
125
|
-
email: TextFields<"loading" | "loadFailed" | "saved" | "sent" | "testFailed" | "delivery" | "notificationAddress" | "replyTo" | "fromName" | "fromNameHint" | "debugInbox" | "debugInboxHint" | "sendTest" | "enabled" | "subject" | "body" | "save" | "sendLog" | "sentColumn" | "templateColumn" | "statusColumn" | "toColumn" | "failed" | "redirected" | "delivered">;
|
|
125
|
+
email: TextFields<"loading" | "loadFailed" | "saved" | "sent" | "testFailed" | "delivery" | "notificationAddress" | "replyTo" | "fromName" | "fromNameHint" | "spamRisk" | "debugInbox" | "debugInboxHint" | "sendTest" | "enabled" | "subject" | "body" | "htmlBody" | "htmlBodyHint" | "save" | "sendLog" | "sentColumn" | "templateColumn" | "statusColumn" | "toColumn" | "failed" | "redirected" | "delivered">;
|
|
126
126
|
meetings: TextFields<"loading" | "loadFailed" | "cancelConfirm" | "newStartPrompt" | "parseFailed" | "agenda" | "empty" | "unknown" | "drift" | "meet" | "reschedule" | "cancel">;
|
|
127
127
|
admission: TextFields<"title" | "description" | "email" | "decision" | "expires" | "note" | "waive" | "require" | "add" | "ledger" | "loading" | "consumed" | "revoked" | "expired" | "active" | "local" | "parent" | "revoke">;
|
|
128
128
|
network: AdminNetworkCopy;
|
package/dist/worker/index.d.ts
CHANGED
|
@@ -122,7 +122,7 @@ interface ChapterCopy {
|
|
|
122
122
|
availability: TextFields<"loading" | "loadFailed" | "saved" | "title" | "days" | "startHour" | "endHour" | "slotMinutes" | "minNoticeHours" | "windowDays" | "timezone" | "summaryTemplate" | "save"> & {
|
|
123
123
|
dayLabels: readonly string[];
|
|
124
124
|
};
|
|
125
|
-
email: TextFields<"loading" | "loadFailed" | "saved" | "sent" | "testFailed" | "delivery" | "notificationAddress" | "replyTo" | "fromName" | "fromNameHint" | "debugInbox" | "debugInboxHint" | "sendTest" | "enabled" | "subject" | "body" | "save" | "sendLog" | "sentColumn" | "templateColumn" | "statusColumn" | "toColumn" | "failed" | "redirected" | "delivered">;
|
|
125
|
+
email: TextFields<"loading" | "loadFailed" | "saved" | "sent" | "testFailed" | "delivery" | "notificationAddress" | "replyTo" | "fromName" | "fromNameHint" | "spamRisk" | "debugInbox" | "debugInboxHint" | "sendTest" | "enabled" | "subject" | "body" | "htmlBody" | "htmlBodyHint" | "save" | "sendLog" | "sentColumn" | "templateColumn" | "statusColumn" | "toColumn" | "failed" | "redirected" | "delivered">;
|
|
126
126
|
meetings: TextFields<"loading" | "loadFailed" | "cancelConfirm" | "newStartPrompt" | "parseFailed" | "agenda" | "empty" | "unknown" | "drift" | "meet" | "reschedule" | "cancel">;
|
|
127
127
|
admission: TextFields<"title" | "description" | "email" | "decision" | "expires" | "note" | "waive" | "require" | "add" | "ledger" | "loading" | "consumed" | "revoked" | "expired" | "active" | "local" | "parent" | "revoke">;
|
|
128
128
|
network: AdminNetworkCopy;
|
package/dist/worker/index.js
CHANGED
|
@@ -1642,18 +1642,72 @@ function isRetiredEmailCopy(subject, text10) {
|
|
|
1642
1642
|
// src/email.ts
|
|
1643
1643
|
import { validateEmailCopy } from "@odla-ai/crm";
|
|
1644
1644
|
|
|
1645
|
+
// src/email-render.ts
|
|
1646
|
+
import { renderTemplate, templateLiveVars, templateSectionIssues, templateSectionNames, templateVars } from "@odla-ai/crm";
|
|
1647
|
+
|
|
1648
|
+
// src/email-html.ts
|
|
1649
|
+
var URL_RE = /https?:\/\/[^\s<>"')\]]+[^\s<>"').,;:!?\]]/g;
|
|
1650
|
+
function escapeHtml(s) {
|
|
1651
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1652
|
+
}
|
|
1653
|
+
function lineHtml(line) {
|
|
1654
|
+
let out = "", last = 0;
|
|
1655
|
+
for (const match of line.matchAll(URL_RE)) {
|
|
1656
|
+
out += escapeHtml(line.slice(last, match.index));
|
|
1657
|
+
const url = escapeHtml(match[0]);
|
|
1658
|
+
out += `<a href="${url}">${url}</a>`;
|
|
1659
|
+
last = match.index + match[0].length;
|
|
1660
|
+
}
|
|
1661
|
+
return out + escapeHtml(line.slice(last));
|
|
1662
|
+
}
|
|
1663
|
+
function textToHtml(text10) {
|
|
1664
|
+
const paragraphs = text10.replace(/\r\n/g, "\n").trim().split(/\n{2,}/).filter((paragraph) => paragraph.trim());
|
|
1665
|
+
const body = paragraphs.map((paragraph) => `<p>${paragraph.split("\n").map(lineHtml).join("<br>\n")}</p>`).join("\n");
|
|
1666
|
+
return `<!doctype html>
|
|
1667
|
+
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
|
|
1668
|
+
<body style="margin:0;padding:24px 16px;background:#ffffff;color:#1a1a1a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;font-size:16px;line-height:1.5">
|
|
1669
|
+
${body}
|
|
1670
|
+
</body></html>`;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// src/email-render.ts
|
|
1674
|
+
function render(template, vars) {
|
|
1675
|
+
return renderTemplate(template, vars);
|
|
1676
|
+
}
|
|
1677
|
+
var HTML_BODY_LIMIT = 2e5;
|
|
1678
|
+
function renderHtmlBody(template, vars) {
|
|
1679
|
+
const escaped = Object.fromEntries(Object.entries(vars).map(([name, value]) => [name, escapeHtml(value).replace(/\r?\n/g, "<br>")]));
|
|
1680
|
+
return renderTemplate(template, escaped);
|
|
1681
|
+
}
|
|
1682
|
+
function htmlBodyIssues(html, options) {
|
|
1683
|
+
const issues = [];
|
|
1684
|
+
if (html.length > HTML_BODY_LIMIT) issues.push({ code: "html-invalid", message: `Keep the HTML body within ${HTML_BODY_LIMIT.toLocaleString("en-US")} characters.` });
|
|
1685
|
+
for (const message of templateSectionIssues(html)) issues.push({ code: "section-invalid", message: `${message} (HTML body)` });
|
|
1686
|
+
const unknown = new Set(templateVars(html).filter((variable) => options.allowedVariables && !options.allowedVariables.includes(variable)));
|
|
1687
|
+
for (const variable of unknown) issues.push({ code: "variable-unknown", variable, message: `This event does not supply {{${variable}}} (HTML body).` });
|
|
1688
|
+
if (options.values) {
|
|
1689
|
+
for (const name of templateSectionNames(html)) if (typeof options.values[name] !== "string") issues.push({ code: "value-missing", variable: name, message: `Missing event value: ${name} (HTML body).` });
|
|
1690
|
+
for (const variable of templateLiveVars(html, options.values)) {
|
|
1691
|
+
if (unknown.has(variable)) continue;
|
|
1692
|
+
const value = options.values[variable];
|
|
1693
|
+
if (typeof value !== "string" || !value.trim() && !options.optionalVariables?.includes(variable)) issues.push({ code: "value-missing", variable, message: `Missing event value: ${variable} (HTML body).` });
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
return issues;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1645
1699
|
// src/email-events.ts
|
|
1646
1700
|
var identity = ["firstName", "lastName", "email", "phone", "state"];
|
|
1647
1701
|
var policy = ["refundPolicyText", "commitmentText", "normsText"];
|
|
1648
|
-
var checkout = ["tier", "tierName", "paymentAmount", "paymentAmountSummary"];
|
|
1649
|
-
var optionalVariables = ["phone", "state", "paymentAmount", "paymentAmountSummary"];
|
|
1702
|
+
var checkout = ["tier", "tierName", "paymentAmount", "paymentAmountSummary", "namedSeat", "namedSeatRecipientName"];
|
|
1703
|
+
var optionalVariables = ["phone", "state", "paymentAmount", "paymentAmountSummary", "namedSeat", "namedSeatRecipientName"];
|
|
1650
1704
|
var event = (label, trigger, audience, variables, requiresTier = true) => ({ label, trigger, audience, variables: [...variables, ...policy], optionalVariables, requiresTier });
|
|
1651
1705
|
var CHAPTER_EMAIL_EVENTS = {
|
|
1652
1706
|
adminNotification: event("Application notification", "Application submitted or payment received, according to notification settings", "Administrator", [...identity, ...checkout, "adminUrl", "membersUrl"]),
|
|
1653
1707
|
submitConfirmation: event("Application receipt", "Application submitted", "Applicant", [...identity, ...checkout, "submitNextStep", "membersUrl"]),
|
|
1654
1708
|
paymentConfirmation: event("Payment confirmation", "Membership payment received", "Applicant", ["firstName", ...checkout, "paymentNextStep"]),
|
|
1655
1709
|
prepEmail: event("Call confirmation", "Introduction call booked", "Applicant", [...identity, ...checkout, "startAt", "endAt", "timezone", "meetUrl", "htmlLink", "meetingTime", "meetingLink"]),
|
|
1656
|
-
onboardingInvite: event("Membership welcome", "Membership approved", "Member", ["firstName", "membersUrl", "tierName"]),
|
|
1710
|
+
onboardingInvite: event("Membership welcome", "Membership approved", "Member", ["firstName", "membersUrl", "tierName", "namedSeat", "namedSeatRecipientName"]),
|
|
1657
1711
|
namedSeatInvitation: event("Gift membership invitation", "A gift membership is ready to be accepted", "Gift recipient", ["firstName", "purchaserName", "chapterName", "acceptBy", "joinUrl", "tierName"]),
|
|
1658
1712
|
seatAccepted: event("Gift accepted", "Gift recipient accepts their membership", "Purchaser", ["firstName", "purchaserName", "recipientName", "tierName", "chapterName", "membersUrl"])
|
|
1659
1713
|
};
|
|
@@ -1668,9 +1722,6 @@ function chapterEmailEvents(chapter) {
|
|
|
1668
1722
|
}
|
|
1669
1723
|
|
|
1670
1724
|
// src/email.ts
|
|
1671
|
-
function render(template, vars) {
|
|
1672
|
-
return template.replace(/\{\{(\w+)\}\}/g, (_, key3) => vars[key3] ?? "");
|
|
1673
|
-
}
|
|
1674
1725
|
function groupVars(group, vars) {
|
|
1675
1726
|
return {
|
|
1676
1727
|
...vars,
|
|
@@ -1703,7 +1754,9 @@ function planDelivery(input) {
|
|
|
1703
1754
|
values: vars,
|
|
1704
1755
|
tierId: vars.tierId
|
|
1705
1756
|
});
|
|
1706
|
-
|
|
1757
|
+
const html = typeof tpl.html === "string" && tpl.html.trim() ? tpl.html : void 0;
|
|
1758
|
+
const htmlIssues = html ? htmlBodyIssues(html, { allowedVariables: event2?.variables, optionalVariables: event2?.optionalVariables, values: vars }) : [];
|
|
1759
|
+
if (issues.length || htmlIssues.length) return { deliver: false, reason: "template-invalid", issues: [...issues, ...htmlIssues].map((issue) => issue.message) };
|
|
1707
1760
|
const isProd = input.envName === "prod";
|
|
1708
1761
|
const redirect = !isProd && !!input.group.debugEmail;
|
|
1709
1762
|
const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
|
|
@@ -1716,7 +1769,25 @@ function planDelivery(input) {
|
|
|
1716
1769
|
const text10 = redirect ? `(dev redirect; original recipient: ${input.to})
|
|
1717
1770
|
|
|
1718
1771
|
` + renderedText : renderedText;
|
|
1719
|
-
|
|
1772
|
+
const renderedHtml = html ? renderHtmlBody(html, vars) : void 0;
|
|
1773
|
+
const htmlOut = renderedHtml && redirect ? `<p style="font:13px monospace;color:#555;margin:0 0 12px">(dev redirect; original recipient: ${escapeHtml(input.to)})</p>` + renderedHtml : renderedHtml;
|
|
1774
|
+
return { deliver: true, transport, to, subject, text: text10, ...htmlOut ? { html: htmlOut } : {}, redirected: redirect };
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// src/email-deliverability.ts
|
|
1778
|
+
var ADMINISTRATOR_AUDIENCE = /^(?:administrator|admin|team|operator|owner|staff)s?$/i;
|
|
1779
|
+
function isRecipientFacing(event2) {
|
|
1780
|
+
return !event2 || !ADMINISTRATOR_AUDIENCE.test(event2.audience.trim());
|
|
1781
|
+
}
|
|
1782
|
+
function listUnsubscribeHeader(replyTo) {
|
|
1783
|
+
const address = replyTo.trim();
|
|
1784
|
+
if (!/^[^\s@,;<>?]+@[^\s@,;<>?]+\.[^\s@,;<>?]+$/.test(address)) return void 0;
|
|
1785
|
+
return `<mailto:${address}?subject=${encodeURIComponent("unsubscribe")}>`;
|
|
1786
|
+
}
|
|
1787
|
+
function deliverabilityHeaders(input) {
|
|
1788
|
+
if (!isRecipientFacing(input.event)) return void 0;
|
|
1789
|
+
const value = listUnsubscribeHeader(input.replyTo);
|
|
1790
|
+
return value ? { "List-Unsubscribe": value } : void 0;
|
|
1720
1791
|
}
|
|
1721
1792
|
|
|
1722
1793
|
// src/notify.ts
|
|
@@ -1832,10 +1903,14 @@ async function frozenCheckoutVariables(db, app) {
|
|
|
1832
1903
|
if (Number.isSafeInteger(amount) && Number(amount) > 0 && typeof currency === "string" && /^[a-z]{3}$/.test(currency)) {
|
|
1833
1904
|
formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase() }).format(Number(amount) / 100);
|
|
1834
1905
|
}
|
|
1906
|
+
const seat = quote?.namedSeat, seatCents = quote?.lineItems?.namedSeatCents;
|
|
1907
|
+
const namedSeatRecipientName = typeof seat?.recipientName === "string" ? seat.recipientName.trim() : "";
|
|
1835
1908
|
return {
|
|
1836
1909
|
paymentAmount: formatted,
|
|
1837
1910
|
paymentAmountSummary: formatted ? ` of ${formatted}` : "",
|
|
1838
|
-
tierName: typeof quote?.tierName === "string" ? quote.tierName : ""
|
|
1911
|
+
tierName: typeof quote?.tierName === "string" ? quote.tierName : "",
|
|
1912
|
+
namedSeat: namedSeatRecipientName ? Number.isSafeInteger(seatCents) && Number(seatCents) > 0 ? "paid" : "included" : "",
|
|
1913
|
+
namedSeatRecipientName
|
|
1839
1914
|
};
|
|
1840
1915
|
}
|
|
1841
1916
|
async function catalogTierName(db, group, app) {
|
|
@@ -1893,6 +1968,8 @@ function sampleVariables(origin, timeZone, now = Date.now()) {
|
|
|
1893
1968
|
purchaserName: "Sample Purchaser",
|
|
1894
1969
|
recipientName: "Sample Recipient",
|
|
1895
1970
|
chapterName: "Sample Chapter",
|
|
1971
|
+
namedSeat: "paid",
|
|
1972
|
+
namedSeatRecipientName: "Sample Recipient",
|
|
1896
1973
|
acceptBy: new Date(now + 7 * 864e5).toISOString().slice(0, 10),
|
|
1897
1974
|
joinUrl: `${origin}/join?seat=sample`
|
|
1898
1975
|
};
|
|
@@ -1990,11 +2067,16 @@ async function sendTemplated(deps, input) {
|
|
|
1990
2067
|
if (decision.transport === "log-only") return { sent: true };
|
|
1991
2068
|
let messageId;
|
|
1992
2069
|
try {
|
|
2070
|
+
const text10 = String(row2.body);
|
|
2071
|
+
const html = deps.renderHtml === false ? void 0 : decision.html ?? (deps.renderHtml ?? textToHtml)(text10);
|
|
2072
|
+
const headers = deliverabilityHeaders({ event: event2, replyTo: input.group.replyTo });
|
|
1993
2073
|
const response = await deps.sender.send({
|
|
1994
2074
|
from: fromAddress(deps.from, input.group.fromName),
|
|
1995
2075
|
to: [decision.to],
|
|
1996
2076
|
subject: String(row2.subject),
|
|
1997
|
-
text:
|
|
2077
|
+
text: text10,
|
|
2078
|
+
...html ? { html } : {},
|
|
2079
|
+
...headers ? { headers } : {},
|
|
1998
2080
|
replyTo: input.group.replyTo
|
|
1999
2081
|
});
|
|
2000
2082
|
if (!response || typeof response.messageId !== "string" || !response.messageId) throw new Error("missing delivery receipt");
|
|
@@ -12462,7 +12544,87 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
|
|
|
12462
12544
|
import { getConfig, saveConfig, validateEmailCopy as validateEmailCopy4 } from "@odla-ai/crm";
|
|
12463
12545
|
|
|
12464
12546
|
// src/email-readiness.ts
|
|
12465
|
-
import { isEmailTierName as isEmailTierName2, validateEmailCopy as validateEmailCopy3 } from "@odla-ai/crm";
|
|
12547
|
+
import { isEmailTierName as isEmailTierName2, templateSectionNames as templateSectionNames2, templateVars as templateVars2, validateEmailCopy as validateEmailCopy3 } from "@odla-ai/crm";
|
|
12548
|
+
|
|
12549
|
+
// src/email-spam-signals.ts
|
|
12550
|
+
var TRIGGER_PHRASES = [
|
|
12551
|
+
"act now",
|
|
12552
|
+
"click here",
|
|
12553
|
+
"limited time",
|
|
12554
|
+
"guaranteed",
|
|
12555
|
+
"risk-free",
|
|
12556
|
+
"risk free",
|
|
12557
|
+
"no obligation",
|
|
12558
|
+
"100% free",
|
|
12559
|
+
"winner",
|
|
12560
|
+
"congratulations",
|
|
12561
|
+
"urgent",
|
|
12562
|
+
"double your",
|
|
12563
|
+
"earn money",
|
|
12564
|
+
"make money",
|
|
12565
|
+
"cash bonus",
|
|
12566
|
+
"once in a lifetime",
|
|
12567
|
+
"exclusive deal",
|
|
12568
|
+
"order now",
|
|
12569
|
+
"buy now",
|
|
12570
|
+
"this is not spam",
|
|
12571
|
+
"dear friend",
|
|
12572
|
+
"dear customer"
|
|
12573
|
+
];
|
|
12574
|
+
var SUBJECT_TRIGGERS = ["free", ...TRIGGER_PHRASES];
|
|
12575
|
+
var DEV_HOSTS = /(?:^|\.)(?:workers\.dev|pages\.dev|localhost|ngrok(?:-free)?\.app|trycloudflare\.com)$/i;
|
|
12576
|
+
var SHORTENERS = /^(?:bit\.ly|tinyurl\.com|t\.co|goo\.gl|ow\.ly|is\.gd|buff\.ly|rebrand\.ly|cutt\.ly|tiny\.cc|shorturl\.at|rb\.gy)$/i;
|
|
12577
|
+
var URL_RE2 = /https?:\/\/[^\s<>"')\]]+/g;
|
|
12578
|
+
var strip = (s) => s.replace(/\{\{\w+\}\}/g, " ");
|
|
12579
|
+
var words = (s) => strip(s).split(/\s+/).filter(Boolean);
|
|
12580
|
+
var phraseIn = (phrases, haystack) => phrases.find((phrase) => new RegExp(`(?:^|[^a-z0-9])${phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![a-z0-9])`, "i").test(haystack));
|
|
12581
|
+
function emailSpamSignals(copy) {
|
|
12582
|
+
const subject = typeof copy.subject === "string" ? copy.subject : "";
|
|
12583
|
+
const text10 = typeof copy.text === "string" ? copy.text : "";
|
|
12584
|
+
const html = typeof copy.html === "string" ? copy.html : "";
|
|
12585
|
+
const out = [];
|
|
12586
|
+
const letters = strip(subject).replace(/[^A-Za-z]/g, "");
|
|
12587
|
+
const shouting = letters.length >= 6 && letters.replace(/[^A-Z]/g, "").length / letters.length > 0.5 || words(subject).some((word) => /^[A-Z]{4,}$/.test(word.replace(/[^A-Za-z]/g, "")));
|
|
12588
|
+
if (shouting) out.push({ code: "subject-caps", message: "The subject shouts in capital letters. Filters read that as a spam signal; use sentence case." });
|
|
12589
|
+
if (/[!?]{2,}|!\s*$/.test(subject)) out.push({ code: "subject-punctuation", message: "Repeated or trailing exclamation marks in the subject are a classic spam signal." });
|
|
12590
|
+
if (/^\s*(?:re|fw|fwd)\s*:/i.test(subject)) out.push({ code: "subject-fake-reply", message: "A subject that starts like a reply or forward reads as a spoof when the recipient never wrote first." });
|
|
12591
|
+
const subjectHit = phraseIn(SUBJECT_TRIGGERS, strip(subject));
|
|
12592
|
+
if (subjectHit) out.push({ code: "subject-trigger-words", message: `The subject uses "${subjectHit}", a word filters weight against the sender.` });
|
|
12593
|
+
const bodyHit = phraseIn(TRIGGER_PHRASES, strip(text10));
|
|
12594
|
+
if (bodyHit) out.push({ code: "body-trigger-words", message: `The body uses "${bodyHit}", a phrase filters weight against the sender.` });
|
|
12595
|
+
if (/(?:^|\s)[A-Z]{3,}(?:\s+[A-Z]{3,}){2,}(?=\s|$)/.test(strip(text10))) out.push({ code: "body-caps", message: "The body shouts a run of capitalised words; use sentence case." });
|
|
12596
|
+
if (/!{2,}/.test(text10)) out.push({ code: "body-punctuation", message: "Repeated exclamation marks in the body are a classic spam signal." });
|
|
12597
|
+
const urls = [...`${text10}
|
|
12598
|
+
${html}`.matchAll(URL_RE2)].map((match) => match[0].replace(/".*$/, ""));
|
|
12599
|
+
const hosts = urls.map((url) => {
|
|
12600
|
+
try {
|
|
12601
|
+
return new URL(url).hostname;
|
|
12602
|
+
} catch {
|
|
12603
|
+
return "";
|
|
12604
|
+
}
|
|
12605
|
+
});
|
|
12606
|
+
if (urls.some((url) => /^http:\/\//i.test(url))) out.push({ code: "link-insecure", message: "A link uses http:// instead of https://. Filters flag insecure links." });
|
|
12607
|
+
if (hosts.some((host) => DEV_HOSTS.test(host) || /^\d+\.\d+\.\d+\.\d+$/.test(host))) out.push({ code: "link-origin", message: "A link points at a development host (workers.dev, pages.dev, localhost or a raw IP). Link to the site's own domain." });
|
|
12608
|
+
if (hosts.some((host) => SHORTENERS.test(host))) out.push({ code: "link-shortener", message: "A link uses a URL shortener, which hides the destination and is heavily abused. Link to the real address." });
|
|
12609
|
+
if (new Set(urls).size > 5) out.push({ code: "link-many", message: "The body has more than five links. Keep lifecycle mail to the one or two the reader needs." });
|
|
12610
|
+
if (urls.length && words(text10).length < 20) out.push({ code: "body-link-only", message: "The body is little more than a link. Say what the reader is being sent and why before the address." });
|
|
12611
|
+
return out;
|
|
12612
|
+
}
|
|
12613
|
+
function emailSenderWarnings(input) {
|
|
12614
|
+
const out = [];
|
|
12615
|
+
const from = (input.fromEmail ?? "").trim().toLowerCase().replace(/^.*<([^>]+)>.*$/, "$1");
|
|
12616
|
+
const reply = (input.replyTo ?? "").trim().toLowerCase();
|
|
12617
|
+
const domain = (address) => address.split("@")[1] ?? "";
|
|
12618
|
+
if (/^(?:no-?reply|do-?not-?reply|donotreply)(?:[+.-][^@]*)?@/.test(from)) {
|
|
12619
|
+
out.push({ code: "sender-no-reply", message: "Mail is sent from a no-reply address. Gmail's sender guidelines advise against it; send from a monitored address so replies and unsubscribe requests reach someone." });
|
|
12620
|
+
}
|
|
12621
|
+
if (domain(from) && domain(reply) && domain(from) !== domain(reply)) {
|
|
12622
|
+
out.push({ code: "reply-to-domain", message: `Replies go to ${domain(reply)} while mail is sent from ${domain(from)}. A Reply-To on another domain is a mild spam signal; prefer an address on the sending domain.` });
|
|
12623
|
+
}
|
|
12624
|
+
return out;
|
|
12625
|
+
}
|
|
12626
|
+
|
|
12627
|
+
// src/email-readiness.ts
|
|
12466
12628
|
function validateEmailReadiness(input) {
|
|
12467
12629
|
const issues = [];
|
|
12468
12630
|
for (const key3 of Object.keys(input.templates)) if (!input.events[key3]) {
|
|
@@ -12478,18 +12640,28 @@ function validateEmailReadiness(input) {
|
|
|
12478
12640
|
const template = input.templates[key3];
|
|
12479
12641
|
const problems = !template ? [{ code: "template-missing", message: "No email is configured for this event." }] : validateEmailCopy3(template, { allowedVariables: event2.variables });
|
|
12480
12642
|
if (template && typeof template.enabled !== "boolean") problems.push({ code: "enabled-missing", message: "Choose explicitly whether this email is enabled. Sending is blocked until then." });
|
|
12643
|
+
const html = typeof template?.html === "string" && template.html.trim() ? template.html : "";
|
|
12644
|
+
if (html) problems.push(...htmlBodyIssues(html, { allowedVariables: event2.variables }));
|
|
12481
12645
|
if (template) {
|
|
12482
|
-
const
|
|
12483
|
-
${template.text ?? ""}
|
|
12484
|
-
|
|
12646
|
+
const copy = `${template.subject ?? ""}
|
|
12647
|
+
${template.text ?? ""}
|
|
12648
|
+
${html}`;
|
|
12649
|
+
const sectionOnly = new Set(templateSectionNames2(copy));
|
|
12650
|
+
for (const name of templateVars2(copy)) if (name in input.groupValues && !input.groupValues[name]?.trim() && !sectionOnly.has(name)) {
|
|
12485
12651
|
problems.push({ code: "value-missing", variable: name, message: `Add the ${name} used by this email.` });
|
|
12486
12652
|
}
|
|
12487
12653
|
}
|
|
12488
12654
|
if (event2.requiresTier && input.tiersUnavailable) problems.push({ code: "tiers-unavailable", message: "Tier names are unavailable; delivery checks will block unresolved memberships." });
|
|
12489
12655
|
else if (event2.requiresTier && noTierNames) problems.push({ code: "tiers-missing", message: "Configure the membership tier names needed by this event." });
|
|
12490
|
-
return {
|
|
12656
|
+
return {
|
|
12657
|
+
...event2,
|
|
12658
|
+
key: key3,
|
|
12659
|
+
status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready",
|
|
12660
|
+
issues: problems,
|
|
12661
|
+
warnings: template ? emailSpamSignals(template) : []
|
|
12662
|
+
};
|
|
12491
12663
|
});
|
|
12492
|
-
return { checkedAt: input.now ?? Date.now(), events, issues };
|
|
12664
|
+
return { checkedAt: input.now ?? Date.now(), events, issues, warnings: emailSenderWarnings(input.sender ?? {}) };
|
|
12493
12665
|
}
|
|
12494
12666
|
|
|
12495
12667
|
// src/email-admin-config.ts
|
|
@@ -12504,6 +12676,7 @@ async function emailSettings(db, ctx, env) {
|
|
|
12504
12676
|
const emailTemplates = Object.fromEntries(Object.keys(events).filter((key3) => key3 in stored).map((key3) => [key3, {
|
|
12505
12677
|
subject: str2(stored[key3]?.subject),
|
|
12506
12678
|
text: str2(stored[key3]?.text),
|
|
12679
|
+
html: str2(stored[key3]?.html),
|
|
12507
12680
|
enabled: stored[key3]?.enabled
|
|
12508
12681
|
}]));
|
|
12509
12682
|
let tiers = [], tiersUnavailable = false;
|
|
@@ -12515,7 +12688,14 @@ async function emailSettings(db, ctx, env) {
|
|
|
12515
12688
|
}
|
|
12516
12689
|
}
|
|
12517
12690
|
const groupValues = Object.fromEntries(["refundPolicyText", "commitmentText", "normsText"].map((key3) => [key3, str2(group?.[key3])]));
|
|
12518
|
-
const validation = validateEmailReadiness({
|
|
12691
|
+
const validation = validateEmailReadiness({
|
|
12692
|
+
events,
|
|
12693
|
+
templates: stored,
|
|
12694
|
+
groupValues,
|
|
12695
|
+
tiers: hub ? void 0 : tiers,
|
|
12696
|
+
tiersUnavailable,
|
|
12697
|
+
sender: { fromEmail: env.EMAIL_FROM ?? null, replyTo: str2(group?.replyTo) }
|
|
12698
|
+
});
|
|
12519
12699
|
return { group, events, data: {
|
|
12520
12700
|
groupId: ctx.chapter.id,
|
|
12521
12701
|
name: ctx.chapter.name,
|
|
@@ -12543,12 +12723,14 @@ async function updateEmailSettings(db, ctx, env, body) {
|
|
|
12543
12723
|
if (!value || typeof value !== "object" || Array.isArray(value)) return json({ error: `Invalid template: ${key3}` }, 400);
|
|
12544
12724
|
const t = value;
|
|
12545
12725
|
if (typeof t.enabled !== "boolean") return json({ error: `Choose whether ${key3} is enabled.` }, 400);
|
|
12546
|
-
const subject = str2(t.subject).trim(), text10 = str2(t.text);
|
|
12726
|
+
const subject = str2(t.subject).trim(), text10 = str2(t.text), html = str2(t.html);
|
|
12727
|
+
if (t.html !== void 0 && typeof t.html !== "string") return json({ error: `Invalid HTML body: ${key3}` }, 400);
|
|
12547
12728
|
const issues = validateEmailCopy4({ subject, text: text10 }, { allowedVariables: events[key3].variables });
|
|
12548
|
-
|
|
12549
|
-
|
|
12729
|
+
const htmlIssues = html.trim() ? htmlBodyIssues(html, { allowedVariables: events[key3].variables }) : [];
|
|
12730
|
+
if (t.enabled && (issues.length || htmlIssues.length) || issues.some((issue) => ["subject-invalid", "body-invalid"].includes(issue.code)) || html.length > HTML_BODY_LIMIT) {
|
|
12731
|
+
return json({ error: `${events[key3].label} is incomplete.`, errors: [...issues, ...htmlIssues].map((issue) => issue.message) }, 400);
|
|
12550
12732
|
}
|
|
12551
|
-
clean[key3] = { subject, text: text10, enabled: t.enabled };
|
|
12733
|
+
clean[key3] = { subject, text: text10, ...html.trim() ? { html } : {}, enabled: t.enabled };
|
|
12552
12734
|
}
|
|
12553
12735
|
const notificationEmail = str2(body.notificationEmail).trim(), replyTo = str2(body.replyTo).trim(), debugEmail = str2(body.debugEmail).trim();
|
|
12554
12736
|
const emailish = /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+$/;
|
|
@@ -12569,7 +12751,8 @@ async function updateEmailSettings(db, ctx, env, body) {
|
|
|
12569
12751
|
{ t: "update", ns: "groups", id: id7, attrs: { commitmentText, normsText, notificationEmail, replyTo, debugEmail, fromName } }
|
|
12570
12752
|
]);
|
|
12571
12753
|
}
|
|
12572
|
-
|
|
12754
|
+
const warnings = Object.fromEntries(Object.entries(clean).map(([key3, template]) => [key3, emailSpamSignals(template)]).filter(([, found]) => found.length));
|
|
12755
|
+
return json({ ok: true, warnings });
|
|
12573
12756
|
}
|
|
12574
12757
|
|
|
12575
12758
|
// src/worker-routes-admin-comms.ts
|