@odla-ai/chapter 0.59.1 → 0.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/dist/{chunk-SDGVPIXF.js → chunk-D5UR5H6R.js} +2 -2
- package/dist/{chunk-P6X3W2XP.js → chunk-J7ES2OWN.js} +12 -2
- package/dist/{chunk-P6X3W2XP.js.map → chunk-J7ES2OWN.js.map} +1 -1
- package/dist/{chunk-GADLTNJF.js → chunk-QNP3UEIL.js} +2 -1
- package/dist/chunk-QNP3UEIL.js.map +1 -0
- package/dist/{copy-context-BuJBQafl.d.ts → copy-context-BP3G1iBd.d.ts} +1 -1
- package/dist/index.cjs +141 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +54 -2
- package/dist/index.d.ts +54 -2
- package/dist/index.js +141 -3
- 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 +143 -5
- 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 +143 -5
- package/dist/worker/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-GADLTNJF.js.map +0 -1
- /package/dist/{chunk-SDGVPIXF.js.map → chunk-D5UR5H6R.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" | "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" | "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
|
@@ -1719,6 +1719,47 @@ function planDelivery(input) {
|
|
|
1719
1719
|
return { deliver: true, transport, to, subject, text: text10, redirected: redirect };
|
|
1720
1720
|
}
|
|
1721
1721
|
|
|
1722
|
+
// src/email-html.ts
|
|
1723
|
+
var URL_RE = /https?:\/\/[^\s<>"')\]]+[^\s<>"').,;:!?\]]/g;
|
|
1724
|
+
function escapeHtml(s) {
|
|
1725
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1726
|
+
}
|
|
1727
|
+
function lineHtml(line) {
|
|
1728
|
+
let out = "", last = 0;
|
|
1729
|
+
for (const match of line.matchAll(URL_RE)) {
|
|
1730
|
+
out += escapeHtml(line.slice(last, match.index));
|
|
1731
|
+
const url = escapeHtml(match[0]);
|
|
1732
|
+
out += `<a href="${url}">${url}</a>`;
|
|
1733
|
+
last = match.index + match[0].length;
|
|
1734
|
+
}
|
|
1735
|
+
return out + escapeHtml(line.slice(last));
|
|
1736
|
+
}
|
|
1737
|
+
function textToHtml(text10) {
|
|
1738
|
+
const paragraphs = text10.replace(/\r\n/g, "\n").trim().split(/\n{2,}/).filter((paragraph) => paragraph.trim());
|
|
1739
|
+
const body = paragraphs.map((paragraph) => `<p>${paragraph.split("\n").map(lineHtml).join("<br>\n")}</p>`).join("\n");
|
|
1740
|
+
return `<!doctype html>
|
|
1741
|
+
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
|
|
1742
|
+
<body style="margin:0;padding:24px 16px;background:#ffffff;color:#1a1a1a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;font-size:16px;line-height:1.5">
|
|
1743
|
+
${body}
|
|
1744
|
+
</body></html>`;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// src/email-deliverability.ts
|
|
1748
|
+
var ADMINISTRATOR_AUDIENCE = /^(?:administrator|admin|team|operator|owner|staff)s?$/i;
|
|
1749
|
+
function isRecipientFacing(event2) {
|
|
1750
|
+
return !event2 || !ADMINISTRATOR_AUDIENCE.test(event2.audience.trim());
|
|
1751
|
+
}
|
|
1752
|
+
function listUnsubscribeHeader(replyTo) {
|
|
1753
|
+
const address = replyTo.trim();
|
|
1754
|
+
if (!/^[^\s@,;<>?]+@[^\s@,;<>?]+\.[^\s@,;<>?]+$/.test(address)) return void 0;
|
|
1755
|
+
return `<mailto:${address}?subject=${encodeURIComponent("unsubscribe")}>`;
|
|
1756
|
+
}
|
|
1757
|
+
function deliverabilityHeaders(input) {
|
|
1758
|
+
if (!isRecipientFacing(input.event)) return void 0;
|
|
1759
|
+
const value = listUnsubscribeHeader(input.replyTo);
|
|
1760
|
+
return value ? { "List-Unsubscribe": value } : void 0;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1722
1763
|
// src/notify.ts
|
|
1723
1764
|
import { validateEmailCopy as validateEmailCopy2 } from "@odla-ai/crm";
|
|
1724
1765
|
|
|
@@ -1990,11 +2031,16 @@ async function sendTemplated(deps, input) {
|
|
|
1990
2031
|
if (decision.transport === "log-only") return { sent: true };
|
|
1991
2032
|
let messageId;
|
|
1992
2033
|
try {
|
|
2034
|
+
const text10 = String(row2.body);
|
|
2035
|
+
const html = deps.renderHtml === false ? void 0 : (deps.renderHtml ?? textToHtml)(text10);
|
|
2036
|
+
const headers = deliverabilityHeaders({ event: event2, replyTo: input.group.replyTo });
|
|
1993
2037
|
const response = await deps.sender.send({
|
|
1994
2038
|
from: fromAddress(deps.from, input.group.fromName),
|
|
1995
2039
|
to: [decision.to],
|
|
1996
2040
|
subject: String(row2.subject),
|
|
1997
|
-
text:
|
|
2041
|
+
text: text10,
|
|
2042
|
+
...html ? { html } : {},
|
|
2043
|
+
...headers ? { headers } : {},
|
|
1998
2044
|
replyTo: input.group.replyTo
|
|
1999
2045
|
});
|
|
2000
2046
|
if (!response || typeof response.messageId !== "string" || !response.messageId) throw new Error("missing delivery receipt");
|
|
@@ -12463,6 +12509,84 @@ import { getConfig, saveConfig, validateEmailCopy as validateEmailCopy4 } from "
|
|
|
12463
12509
|
|
|
12464
12510
|
// src/email-readiness.ts
|
|
12465
12511
|
import { isEmailTierName as isEmailTierName2, validateEmailCopy as validateEmailCopy3 } from "@odla-ai/crm";
|
|
12512
|
+
|
|
12513
|
+
// src/email-spam-signals.ts
|
|
12514
|
+
var TRIGGER_PHRASES = [
|
|
12515
|
+
"act now",
|
|
12516
|
+
"click here",
|
|
12517
|
+
"limited time",
|
|
12518
|
+
"guaranteed",
|
|
12519
|
+
"risk-free",
|
|
12520
|
+
"risk free",
|
|
12521
|
+
"no obligation",
|
|
12522
|
+
"100% free",
|
|
12523
|
+
"winner",
|
|
12524
|
+
"congratulations",
|
|
12525
|
+
"urgent",
|
|
12526
|
+
"double your",
|
|
12527
|
+
"earn money",
|
|
12528
|
+
"make money",
|
|
12529
|
+
"cash bonus",
|
|
12530
|
+
"once in a lifetime",
|
|
12531
|
+
"exclusive deal",
|
|
12532
|
+
"order now",
|
|
12533
|
+
"buy now",
|
|
12534
|
+
"this is not spam",
|
|
12535
|
+
"dear friend",
|
|
12536
|
+
"dear customer"
|
|
12537
|
+
];
|
|
12538
|
+
var SUBJECT_TRIGGERS = ["free", ...TRIGGER_PHRASES];
|
|
12539
|
+
var DEV_HOSTS = /(?:^|\.)(?:workers\.dev|pages\.dev|localhost|ngrok(?:-free)?\.app|trycloudflare\.com)$/i;
|
|
12540
|
+
var SHORTENERS = /^(?:bit\.ly|tinyurl\.com|t\.co|goo\.gl|ow\.ly|is\.gd|buff\.ly|rebrand\.ly|cutt\.ly|tiny\.cc|shorturl\.at|rb\.gy)$/i;
|
|
12541
|
+
var URL_RE2 = /https?:\/\/[^\s<>"')\]]+/g;
|
|
12542
|
+
var strip = (s) => s.replace(/\{\{\w+\}\}/g, " ");
|
|
12543
|
+
var words = (s) => strip(s).split(/\s+/).filter(Boolean);
|
|
12544
|
+
var phraseIn = (phrases, haystack) => phrases.find((phrase) => new RegExp(`(?:^|[^a-z0-9])${phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![a-z0-9])`, "i").test(haystack));
|
|
12545
|
+
function emailSpamSignals(copy) {
|
|
12546
|
+
const subject = typeof copy.subject === "string" ? copy.subject : "";
|
|
12547
|
+
const text10 = typeof copy.text === "string" ? copy.text : "";
|
|
12548
|
+
const out = [];
|
|
12549
|
+
const letters = strip(subject).replace(/[^A-Za-z]/g, "");
|
|
12550
|
+
const shouting = letters.length >= 6 && letters.replace(/[^A-Z]/g, "").length / letters.length > 0.5 || words(subject).some((word) => /^[A-Z]{4,}$/.test(word.replace(/[^A-Za-z]/g, "")));
|
|
12551
|
+
if (shouting) out.push({ code: "subject-caps", message: "The subject shouts in capital letters. Filters read that as a spam signal; use sentence case." });
|
|
12552
|
+
if (/[!?]{2,}|!\s*$/.test(subject)) out.push({ code: "subject-punctuation", message: "Repeated or trailing exclamation marks in the subject are a classic spam signal." });
|
|
12553
|
+
if (/^\s*(?:re|fw|fwd)\s*:/i.test(subject)) out.push({ code: "subject-fake-reply", message: "A subject that starts like a reply or forward reads as a spoof when the recipient never wrote first." });
|
|
12554
|
+
const subjectHit = phraseIn(SUBJECT_TRIGGERS, strip(subject));
|
|
12555
|
+
if (subjectHit) out.push({ code: "subject-trigger-words", message: `The subject uses "${subjectHit}", a word filters weight against the sender.` });
|
|
12556
|
+
const bodyHit = phraseIn(TRIGGER_PHRASES, strip(text10));
|
|
12557
|
+
if (bodyHit) out.push({ code: "body-trigger-words", message: `The body uses "${bodyHit}", a phrase filters weight against the sender.` });
|
|
12558
|
+
if (/(?:^|\s)[A-Z]{3,}(?:\s+[A-Z]{3,}){2,}(?=\s|$)/.test(strip(text10))) out.push({ code: "body-caps", message: "The body shouts a run of capitalised words; use sentence case." });
|
|
12559
|
+
if (/!{2,}/.test(text10)) out.push({ code: "body-punctuation", message: "Repeated exclamation marks in the body are a classic spam signal." });
|
|
12560
|
+
const urls = [...text10.matchAll(URL_RE2)].map((match) => match[0]);
|
|
12561
|
+
const hosts = urls.map((url) => {
|
|
12562
|
+
try {
|
|
12563
|
+
return new URL(url).hostname;
|
|
12564
|
+
} catch {
|
|
12565
|
+
return "";
|
|
12566
|
+
}
|
|
12567
|
+
});
|
|
12568
|
+
if (urls.some((url) => /^http:\/\//i.test(url))) out.push({ code: "link-insecure", message: "A link uses http:// instead of https://. Filters flag insecure links." });
|
|
12569
|
+
if (hosts.some((host) => DEV_HOSTS.test(host) || /^\d+\.\d+\.\d+\.\d+$/.test(host))) out.push({ code: "link-origin", message: "A link points at a development host (workers.dev, pages.dev, localhost or a raw IP). Link to the site's own domain." });
|
|
12570
|
+
if (hosts.some((host) => SHORTENERS.test(host))) out.push({ code: "link-shortener", message: "A link uses a URL shortener, which hides the destination and is heavily abused. Link to the real address." });
|
|
12571
|
+
if (new Set(urls).size > 5) out.push({ code: "link-many", message: "The body has more than five links. Keep lifecycle mail to the one or two the reader needs." });
|
|
12572
|
+
if (urls.length && words(text10).length < 20) out.push({ code: "body-link-only", message: "The body is little more than a link. Say what the reader is being sent and why before the address." });
|
|
12573
|
+
return out;
|
|
12574
|
+
}
|
|
12575
|
+
function emailSenderWarnings(input) {
|
|
12576
|
+
const out = [];
|
|
12577
|
+
const from = (input.fromEmail ?? "").trim().toLowerCase().replace(/^.*<([^>]+)>.*$/, "$1");
|
|
12578
|
+
const reply = (input.replyTo ?? "").trim().toLowerCase();
|
|
12579
|
+
const domain = (address) => address.split("@")[1] ?? "";
|
|
12580
|
+
if (/^(?:no-?reply|do-?not-?reply|donotreply)(?:[+.-][^@]*)?@/.test(from)) {
|
|
12581
|
+
out.push({ code: "sender-no-reply", message: "Mail is sent from a no-reply address. Gmail's sender guidelines advise against it; send from a monitored address so replies and unsubscribe requests reach someone." });
|
|
12582
|
+
}
|
|
12583
|
+
if (domain(from) && domain(reply) && domain(from) !== domain(reply)) {
|
|
12584
|
+
out.push({ code: "reply-to-domain", message: `Replies go to ${domain(reply)} while mail is sent from ${domain(from)}. A Reply-To on another domain is a mild spam signal; prefer an address on the sending domain.` });
|
|
12585
|
+
}
|
|
12586
|
+
return out;
|
|
12587
|
+
}
|
|
12588
|
+
|
|
12589
|
+
// src/email-readiness.ts
|
|
12466
12590
|
function validateEmailReadiness(input) {
|
|
12467
12591
|
const issues = [];
|
|
12468
12592
|
for (const key3 of Object.keys(input.templates)) if (!input.events[key3]) {
|
|
@@ -12487,9 +12611,15 @@ ${template.text ?? ""}`.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
|
|
|
12487
12611
|
}
|
|
12488
12612
|
if (event2.requiresTier && input.tiersUnavailable) problems.push({ code: "tiers-unavailable", message: "Tier names are unavailable; delivery checks will block unresolved memberships." });
|
|
12489
12613
|
else if (event2.requiresTier && noTierNames) problems.push({ code: "tiers-missing", message: "Configure the membership tier names needed by this event." });
|
|
12490
|
-
return {
|
|
12614
|
+
return {
|
|
12615
|
+
...event2,
|
|
12616
|
+
key: key3,
|
|
12617
|
+
status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready",
|
|
12618
|
+
issues: problems,
|
|
12619
|
+
warnings: template ? emailSpamSignals(template) : []
|
|
12620
|
+
};
|
|
12491
12621
|
});
|
|
12492
|
-
return { checkedAt: input.now ?? Date.now(), events, issues };
|
|
12622
|
+
return { checkedAt: input.now ?? Date.now(), events, issues, warnings: emailSenderWarnings(input.sender ?? {}) };
|
|
12493
12623
|
}
|
|
12494
12624
|
|
|
12495
12625
|
// src/email-admin-config.ts
|
|
@@ -12515,7 +12645,14 @@ async function emailSettings(db, ctx, env) {
|
|
|
12515
12645
|
}
|
|
12516
12646
|
}
|
|
12517
12647
|
const groupValues = Object.fromEntries(["refundPolicyText", "commitmentText", "normsText"].map((key3) => [key3, str2(group?.[key3])]));
|
|
12518
|
-
const validation = validateEmailReadiness({
|
|
12648
|
+
const validation = validateEmailReadiness({
|
|
12649
|
+
events,
|
|
12650
|
+
templates: stored,
|
|
12651
|
+
groupValues,
|
|
12652
|
+
tiers: hub ? void 0 : tiers,
|
|
12653
|
+
tiersUnavailable,
|
|
12654
|
+
sender: { fromEmail: env.EMAIL_FROM ?? null, replyTo: str2(group?.replyTo) }
|
|
12655
|
+
});
|
|
12519
12656
|
return { group, events, data: {
|
|
12520
12657
|
groupId: ctx.chapter.id,
|
|
12521
12658
|
name: ctx.chapter.name,
|
|
@@ -12569,7 +12706,8 @@ async function updateEmailSettings(db, ctx, env, body) {
|
|
|
12569
12706
|
{ t: "update", ns: "groups", id: id7, attrs: { commitmentText, normsText, notificationEmail, replyTo, debugEmail, fromName } }
|
|
12570
12707
|
]);
|
|
12571
12708
|
}
|
|
12572
|
-
|
|
12709
|
+
const warnings = Object.fromEntries(Object.entries(clean).map(([key3, template]) => [key3, emailSpamSignals(template)]).filter(([, found]) => found.length));
|
|
12710
|
+
return json({ ok: true, warnings });
|
|
12573
12711
|
}
|
|
12574
12712
|
|
|
12575
12713
|
// src/worker-routes-admin-comms.ts
|