@blamejs/blamejs-shop 0.4.16 → 0.4.18
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/CHANGELOG.md +4 -0
- package/README.md +1 -1
- package/SECURITY.md +11 -0
- package/lib/admin.js +1149 -23
- package/lib/asset-manifest.json +1 -1
- package/lib/email-campaigns.js +799 -9
- package/lib/index.js +1 -0
- package/lib/operator-accounts.js +543 -0
- package/lib/security-middleware.js +1 -0
- package/package.json +1 -1
package/lib/email-campaigns.js
CHANGED
|
@@ -92,8 +92,46 @@
|
|
|
92
92
|
* the audience factory was wired with `emailSuppressions`);
|
|
93
93
|
* `email.send`-shaped mailer handles the per-recipient send.
|
|
94
94
|
*
|
|
95
|
+
* ## Consent-gated broadcast (`broadcast` / `broadcastTick`)
|
|
96
|
+
*
|
|
97
|
+
* Customer email is stored HASH-ONLY in this store; the only place a
|
|
98
|
+
* DELIVERABLE plaintext address lives is the `newsletter` subscriber
|
|
99
|
+
* list (which persists the plaintext alongside the hash + an
|
|
100
|
+
* `unsubscribed_at` opt-out flag). `broadcast` is the honest send
|
|
101
|
+
* path: it resolves the deliverable plaintext address from the
|
|
102
|
+
* audience (which resolves over `newsletter_signups`), and mails ONLY
|
|
103
|
+
* marketing-consented, reachable recipients. Wire it with a
|
|
104
|
+
* `newsletter` handle + an `unsubscribeBaseUrl` (https) and the send
|
|
105
|
+
* path lights up; without them `canBroadcast()` is false and the
|
|
106
|
+
* console says so plainly (a campaign can still be authored +
|
|
107
|
+
* previewed, but Send refuses).
|
|
108
|
+
*
|
|
109
|
+
* Consent is resolved AT THE SEND MOMENT, per recipient — not at
|
|
110
|
+
* creation time. Two independent opt-out checks gate every recipient:
|
|
111
|
+
* the newsletter `unsubscribed_at` flag (the marketing-consent state)
|
|
112
|
+
* AND the `emailSuppressions` `marketing` scope. A recipient who
|
|
113
|
+
* unsubscribes after the broadcast starts is honored mid-send. Every
|
|
114
|
+
* recipient's send-time decision lands in `email_campaign_sends`
|
|
115
|
+
* (sent / failed / skipped_unsubscribed / skipped_suppressed), keyed
|
|
116
|
+
* UNIQUE per (campaign, recipient) so a resumed broadcast never
|
|
117
|
+
* re-mails. `reachability(slug)` computes the true reachable count
|
|
118
|
+
* live so the console shows "send to N" as the real number.
|
|
119
|
+
*
|
|
120
|
+
* Every broadcast carries an RFC 8058 one-click `List-Unsubscribe` /
|
|
121
|
+
* `List-Unsubscribe-Post` header pair (composed + shape-validated
|
|
122
|
+
* through `b.guardListUnsubscribe`) plus an in-body unsubscribe link
|
|
123
|
+
* minted through the newsletter one-shot token flow, and an optional
|
|
124
|
+
* RFC 2919 `List-Id` (`b.guardListId`). The operator-authored body is
|
|
125
|
+
* treated as hostile: `renderBody` is escape-by-default Markdown (raw
|
|
126
|
+
* `<` lands as `<`; links pass the https-only `b.safeUrl` gate) so
|
|
127
|
+
* a compromised admin key can't get script into mail or stored XSS
|
|
128
|
+
* into the console. Sends are rate-bound on a rolling window
|
|
129
|
+
* (reserve-before-await); one bad address is counted, never fatal.
|
|
130
|
+
*
|
|
95
131
|
* @primitive emailCampaigns
|
|
96
|
-
* @related shop.email, shop.mailingAudiences, shop.emailSuppressions,
|
|
132
|
+
* @related shop.email, shop.mailingAudiences, shop.emailSuppressions,
|
|
133
|
+
* shop.newsletter, b.guardListUnsubscribe, b.guardListId,
|
|
134
|
+
* b.safeUrl, b.template.escapeHtml, b.fsm
|
|
97
135
|
*/
|
|
98
136
|
|
|
99
137
|
// ---- constants ----------------------------------------------------------
|
|
@@ -107,11 +145,195 @@ var MAX_BATCH_SIZE = 1000;
|
|
|
107
145
|
var DEFAULT_BATCH_SIZE = 100;
|
|
108
146
|
var RESOLVE_PAGE_LIMIT = 500; // matches mailingAudiences MAX_LIST_LIMIT
|
|
109
147
|
|
|
148
|
+
var b = require("./vendor/blamejs");
|
|
149
|
+
// Framework duration constants (C.TIME.minutes(n) etc.). The index entry
|
|
150
|
+
// point exposes `framework` before the require cascade, so resolving this
|
|
151
|
+
// at module-eval is safe — same pattern lib/admin.js / lib/cart.js use.
|
|
152
|
+
var C = b.constants;
|
|
153
|
+
|
|
154
|
+
// Broadcast send rate bound — recipients per minute. The drain reserves
|
|
155
|
+
// a slot in this rolling window BEFORE awaiting the mailer (the
|
|
156
|
+
// resend-confirmation reserve-before-await shape) so a slow SMTP host
|
|
157
|
+
// can't let a burst sail past the bound while sends are in flight. When
|
|
158
|
+
// the window is full the drain stops for this pass; the cron tick picks
|
|
159
|
+
// the campaign back up on the next fire and resumes past the recipients
|
|
160
|
+
// already in the send ledger.
|
|
161
|
+
var DEFAULT_SEND_RATE_PER_MIN = 60;
|
|
162
|
+
var SEND_RATE_WINDOW_MS = C.TIME.minutes(1);
|
|
163
|
+
|
|
164
|
+
// Per-recipient broadcast outcomes recorded in email_campaign_sends.
|
|
165
|
+
var SEND_OUTCOMES = ["sent", "failed", "skipped_unsubscribed", "skipped_suppressed"];
|
|
166
|
+
|
|
167
|
+
var CONTROL_BYTE_LINE_RE = /[\u0000-\u001f\u007f]/;
|
|
168
|
+
var ZERO_WIDTH_RE = /[\u200b-\u200f\u2028\u2029\ufeff]/;
|
|
169
|
+
|
|
110
170
|
var STATUSES = ["draft", "scheduled", "sending", "sent", "paused", "cancelled"];
|
|
111
171
|
var EVENT_TYPES = ["delivered", "opened", "clicked", "bounced", "unsubscribed"];
|
|
112
172
|
var TERMINAL = ["sent", "cancelled"];
|
|
113
173
|
|
|
114
|
-
|
|
174
|
+
// ---- escape-by-default body renderer ------------------------------------
|
|
175
|
+
//
|
|
176
|
+
// The operator-authored body is HOSTILE at render time — a compromised
|
|
177
|
+
// admin key must not get script into a recipient's mail client or
|
|
178
|
+
// stored XSS into the console preview / list. Every byte of the body is
|
|
179
|
+
// HTML-escaped before it reaches the rendered output; the only HTML the
|
|
180
|
+
// renderer ever emits is the fixed tag set it produces itself from the
|
|
181
|
+
// Markdown structure. Raw `<` in the body lands as `<`. Link targets
|
|
182
|
+
// pass `b.safeUrl.parse` (https-only) or an allow-list for `/`-rooted
|
|
183
|
+
// absolute paths; anything else degrades to inert escaped text. This is
|
|
184
|
+
// the same defense lib/blog-articles.js renders operator post bodies
|
|
185
|
+
// with — kept byte-identical so the audited posture carries over.
|
|
186
|
+
|
|
187
|
+
function _esc(s) {
|
|
188
|
+
return b.template.escapeHtml(String(s == null ? "" : s));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function _mdSafeLinkUrl(url) {
|
|
192
|
+
if (typeof url !== "string" || !url.length || url.length > 2048) return null;
|
|
193
|
+
if (CONTROL_BYTE_LINE_RE.test(url) || ZERO_WIDTH_RE.test(url)) return null;
|
|
194
|
+
if (url.charCodeAt(0) === 47 /* "/" */) {
|
|
195
|
+
if (url.length > 1 && url.charCodeAt(1) === 47) return null;
|
|
196
|
+
if (url.indexOf("..") !== -1) return null;
|
|
197
|
+
return url;
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
b.safeUrl.parse(url, { allowedProtocols: ["https:"] });
|
|
201
|
+
} catch (_e) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
return url;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function _mdInline(line) {
|
|
208
|
+
var out = "";
|
|
209
|
+
var i = 0;
|
|
210
|
+
while (i < line.length) {
|
|
211
|
+
var ch = line.charAt(i);
|
|
212
|
+
if (ch === "`") {
|
|
213
|
+
var end = line.indexOf("`", i + 1);
|
|
214
|
+
if (end !== -1) {
|
|
215
|
+
out += "<code>" + _esc(line.slice(i + 1, end)) + "</code>";
|
|
216
|
+
i = end + 1;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (ch === "[") {
|
|
221
|
+
var closeBracket = line.indexOf("]", i + 1);
|
|
222
|
+
if (closeBracket !== -1 && line.charAt(closeBracket + 1) === "(") {
|
|
223
|
+
var closeParen = line.indexOf(")", closeBracket + 2);
|
|
224
|
+
if (closeParen !== -1) {
|
|
225
|
+
var text = line.slice(i + 1, closeBracket);
|
|
226
|
+
var url = line.slice(closeBracket + 2, closeParen);
|
|
227
|
+
var safe = _mdSafeLinkUrl(url);
|
|
228
|
+
if (safe) {
|
|
229
|
+
out += '<a href="' + _esc(safe) + '">' + _mdInline(text) + "</a>";
|
|
230
|
+
} else {
|
|
231
|
+
out += _mdInline(text);
|
|
232
|
+
}
|
|
233
|
+
i = closeParen + 1;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (ch === "*" && line.charAt(i + 1) === "*") {
|
|
239
|
+
var endBold = line.indexOf("**", i + 2);
|
|
240
|
+
if (endBold !== -1) {
|
|
241
|
+
out += "<strong>" + _mdInline(line.slice(i + 2, endBold)) + "</strong>";
|
|
242
|
+
i = endBold + 2;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (ch === "*" || ch === "_") {
|
|
247
|
+
var endItalic = line.indexOf(ch, i + 1);
|
|
248
|
+
if (endItalic !== -1 && endItalic !== i + 1) {
|
|
249
|
+
out += "<em>" + _mdInline(line.slice(i + 1, endItalic)) + "</em>";
|
|
250
|
+
i = endItalic + 1;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
out += _esc(ch);
|
|
255
|
+
i += 1;
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Render the operator body (Markdown) to escape-by-default HTML. Returns
|
|
261
|
+
// the body fragment only — callers wrap it in the surrounding mail /
|
|
262
|
+
// preview chrome. Used at PREVIEW + at SEND so the recipient and the
|
|
263
|
+
// operator see byte-identical output.
|
|
264
|
+
function renderBody(body) {
|
|
265
|
+
var normalized = String(body == null ? "" : body).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
266
|
+
var lines = normalized.split("\n");
|
|
267
|
+
var out = [];
|
|
268
|
+
var i = 0;
|
|
269
|
+
while (i < lines.length) {
|
|
270
|
+
var line = lines[i];
|
|
271
|
+
if (line.trim() === "") { i += 1; continue; }
|
|
272
|
+
if (/^-{3,}\s*$/.test(line)) { out.push("<hr />"); i += 1; continue; }
|
|
273
|
+
var hMatch = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
274
|
+
if (hMatch) {
|
|
275
|
+
var level = hMatch[1].length;
|
|
276
|
+
out.push("<h" + level + ">" + _mdInline(hMatch[2].trim()) + "</h" + level + ">");
|
|
277
|
+
i += 1;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (/^>\s?/.test(line)) {
|
|
281
|
+
var quoteLines = [];
|
|
282
|
+
while (i < lines.length && /^>\s?/.test(lines[i])) {
|
|
283
|
+
quoteLines.push(lines[i].replace(/^>\s?/, ""));
|
|
284
|
+
i += 1;
|
|
285
|
+
}
|
|
286
|
+
out.push("<blockquote><p>" + _mdInline(quoteLines.join(" ")) + "</p></blockquote>");
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (/^[-*]\s+/.test(line)) {
|
|
290
|
+
var ulItems = [];
|
|
291
|
+
while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
|
|
292
|
+
ulItems.push(lines[i].replace(/^[-*]\s+/, ""));
|
|
293
|
+
i += 1;
|
|
294
|
+
}
|
|
295
|
+
out.push("<ul>" + ulItems.map(function (it) { return "<li>" + _mdInline(it) + "</li>"; }).join("") + "</ul>");
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (/^\d+\.\s+/.test(line)) {
|
|
299
|
+
var olItems = [];
|
|
300
|
+
while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
|
|
301
|
+
olItems.push(lines[i].replace(/^\d+\.\s+/, ""));
|
|
302
|
+
i += 1;
|
|
303
|
+
}
|
|
304
|
+
out.push("<ol>" + olItems.map(function (it) { return "<li>" + _mdInline(it) + "</li>"; }).join("") + "</ol>");
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
var paraLines = [line];
|
|
308
|
+
i += 1;
|
|
309
|
+
while (
|
|
310
|
+
i < lines.length &&
|
|
311
|
+
lines[i].trim() !== "" &&
|
|
312
|
+
!/^#{1,6}\s+/.test(lines[i]) &&
|
|
313
|
+
!/^[-*]\s+/.test(lines[i]) &&
|
|
314
|
+
!/^\d+\.\s+/.test(lines[i]) &&
|
|
315
|
+
!/^>\s?/.test(lines[i]) &&
|
|
316
|
+
!/^-{3,}\s*$/.test(lines[i])
|
|
317
|
+
) {
|
|
318
|
+
paraLines.push(lines[i]);
|
|
319
|
+
i += 1;
|
|
320
|
+
}
|
|
321
|
+
out.push("<p>" + _mdInline(paraLines.join(" ")) + "</p>");
|
|
322
|
+
}
|
|
323
|
+
return out.join("\n");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Plain-text rendering of the body — the multipart text/plain alt for
|
|
327
|
+
// the mail. Strips Markdown markers but keeps the visible text; never
|
|
328
|
+
// emits HTML. The `[text](url)` link renders as `text (url)` when the
|
|
329
|
+
// URL is safe, `text` otherwise.
|
|
330
|
+
function renderBodyText(body) {
|
|
331
|
+
var normalized = String(body == null ? "" : body).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
332
|
+
return normalized.replace(/\[([^\]]*)\]\(([^)]*)\)/g, function (_m, text, url) {
|
|
333
|
+
var safe = _mdSafeLinkUrl(url);
|
|
334
|
+
return safe ? (text + " (" + safe + ")") : text;
|
|
335
|
+
}).replace(/[*_`#>]/g, "");
|
|
336
|
+
}
|
|
115
337
|
|
|
116
338
|
// ---- FSM definition -----------------------------------------------------
|
|
117
339
|
|
|
@@ -352,6 +574,76 @@ function create(opts) {
|
|
|
352
574
|
var audiences = opts.mailingAudiences;
|
|
353
575
|
var suppressions = opts.emailSuppressions || null;
|
|
354
576
|
|
|
577
|
+
// The newsletter handle is the deliverable-address + marketing-consent
|
|
578
|
+
// source. Customer email is stored HASH-ONLY in this store; the
|
|
579
|
+
// newsletter signup table is the one place a deliverable plaintext
|
|
580
|
+
// address lives, alongside the `unsubscribed_at` opt-out flag that IS
|
|
581
|
+
// the marketing-consent state. `broadcast` needs it for three things:
|
|
582
|
+
// the per-recipient consent re-check at the send moment, the plaintext
|
|
583
|
+
// address to mint a one-shot unsubscribe token, and the resubscribe /
|
|
584
|
+
// signup lookup. Without it the consent-gated broadcast path is
|
|
585
|
+
// unavailable (the dispatcher / sendNow hash-only paths still work).
|
|
586
|
+
var newsletter = opts.newsletter || null;
|
|
587
|
+
|
|
588
|
+
// Absolute origin the in-body + RFC 8058 unsubscribe links resolve
|
|
589
|
+
// against (e.g. "https://shop.example"). Required for `broadcast`:
|
|
590
|
+
// Gmail / Yahoo one-click unsubscribe demands an https URI, and the
|
|
591
|
+
// in-body link has to be clickable from the recipient's mail client.
|
|
592
|
+
// The value must be https — b.guardListUnsubscribe refuses anything
|
|
593
|
+
// else at compose time, which would silently strip the unsubscribe
|
|
594
|
+
// headers + in-body link from every message while the console's Send
|
|
595
|
+
// stayed enabled. Refusing HERE keeps the gate honest: a non-https
|
|
596
|
+
// value leaves `canBroadcast()` false and the console shows the
|
|
597
|
+
// unconfigured notice instead of broadcasting without an unsubscribe
|
|
598
|
+
// path. The trailing-slash strip is a character loop, not a regex, so
|
|
599
|
+
// a slash-heavy value can't make the parse crawl.
|
|
600
|
+
var unsubscribeBaseUrl = null;
|
|
601
|
+
if (typeof opts.unsubscribeBaseUrl === "string" && opts.unsubscribeBaseUrl) {
|
|
602
|
+
var unsubRaw = opts.unsubscribeBaseUrl;
|
|
603
|
+
while (unsubRaw.length && unsubRaw.charAt(unsubRaw.length - 1) === "/") {
|
|
604
|
+
unsubRaw = unsubRaw.slice(0, -1);
|
|
605
|
+
}
|
|
606
|
+
try {
|
|
607
|
+
if (b.safeUrl.parse(unsubRaw).protocol === "https:") unsubscribeBaseUrl = unsubRaw;
|
|
608
|
+
} catch (_eUnsubBase) { /* not a parseable URL — broadcast stays disabled */ }
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// RFC 2919 List-Id phrase (e.g. "marketing.shop.example"). Stamped on
|
|
612
|
+
// every broadcast so mailbox providers group + thread the list and the
|
|
613
|
+
// recipient's per-list unsubscribe machinery engages. Optional — when
|
|
614
|
+
// absent the List-Id header is omitted (the List-Unsubscribe pair is
|
|
615
|
+
// what the one-click machinery actually keys off).
|
|
616
|
+
var listId = typeof opts.listId === "string" && opts.listId.length ? opts.listId : null;
|
|
617
|
+
|
|
618
|
+
var sendRatePerMin = DEFAULT_SEND_RATE_PER_MIN;
|
|
619
|
+
if (opts.sendRatePerMin != null) {
|
|
620
|
+
if (!Number.isInteger(opts.sendRatePerMin) || opts.sendRatePerMin <= 0) {
|
|
621
|
+
throw new TypeError("emailCampaigns.create: sendRatePerMin must be a positive integer");
|
|
622
|
+
}
|
|
623
|
+
sendRatePerMin = opts.sendRatePerMin;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Rolling send-rate window — timestamps of recent broadcast sends.
|
|
627
|
+
// Reserve-before-await: a slot is pushed BEFORE the mailer await so
|
|
628
|
+
// concurrent broadcast passes for two campaigns can't both read the
|
|
629
|
+
// same pre-send count and blow past the bound together. A failed send
|
|
630
|
+
// releases its reservation so a bad address doesn't consume budget.
|
|
631
|
+
var _sendWindow = [];
|
|
632
|
+
function _sendBudgetAvailable(now) {
|
|
633
|
+
var cutoff = now - SEND_RATE_WINDOW_MS;
|
|
634
|
+
var keep = [];
|
|
635
|
+
for (var i = 0; i < _sendWindow.length; i += 1) {
|
|
636
|
+
if (_sendWindow[i] > cutoff) keep.push(_sendWindow[i]);
|
|
637
|
+
}
|
|
638
|
+
_sendWindow = keep;
|
|
639
|
+
return _sendWindow.length < sendRatePerMin;
|
|
640
|
+
}
|
|
641
|
+
function _reserveSendSlot(now) { _sendWindow.push(now); }
|
|
642
|
+
function _releaseSendSlot(now) {
|
|
643
|
+
var idx = _sendWindow.indexOf(now);
|
|
644
|
+
if (idx !== -1) _sendWindow.splice(idx, 1);
|
|
645
|
+
}
|
|
646
|
+
|
|
355
647
|
// ---- internal helpers (closed over factory state) --------------------
|
|
356
648
|
|
|
357
649
|
async function _getRow(slug) {
|
|
@@ -458,10 +750,250 @@ function create(opts) {
|
|
|
458
750
|
return { resolved_count: resolvedTotal, sent_count: sentTotal };
|
|
459
751
|
}
|
|
460
752
|
|
|
753
|
+
// ---- consent-gated broadcast send ------------------------------------
|
|
754
|
+
//
|
|
755
|
+
// The honest send path. Where _drainSend mails the audience HASH (a
|
|
756
|
+
// namespaced digest, NOT a deliverable mailbox — that path predates a
|
|
757
|
+
// plaintext address source and only works against an ESP that
|
|
758
|
+
// translates the hash), `_broadcastDrain` resolves the deliverable
|
|
759
|
+
// PLAINTEXT address from newsletter_signups, re-checks marketing
|
|
760
|
+
// consent AT THE SEND MOMENT, attaches RFC 8058 one-click unsubscribe
|
|
761
|
+
// headers + an in-body unsubscribe link, and records a per-recipient
|
|
762
|
+
// row in email_campaign_sends (the dedupe + consent-decision ledger).
|
|
763
|
+
//
|
|
764
|
+
// Consent is resolved at SEND time, never at creation time: a
|
|
765
|
+
// recipient who unsubscribes after the broadcast starts is honored
|
|
766
|
+
// mid-send. Two independent opt-out checks gate every recipient — the
|
|
767
|
+
// newsletter `unsubscribed_at` flag (the marketing-consent state) AND
|
|
768
|
+
// the email_suppressions `marketing` scope. A recipient already in the
|
|
769
|
+
// send ledger is skipped, so a cron-resumed broadcast never re-mails.
|
|
770
|
+
|
|
771
|
+
// Build the RFC 8058 List-Unsubscribe header pair + the in-body link
|
|
772
|
+
// for one recipient. Mints a one-shot unsubscribe token through the
|
|
773
|
+
// newsletter flow (the plaintext leaves the token mint exactly once;
|
|
774
|
+
// the DB stores only its hash). Returns null when no signup row backs
|
|
775
|
+
// the address (it can't be in the audience without one) or the
|
|
776
|
+
// unsubscribe base URL isn't configured.
|
|
777
|
+
async function _unsubscribeHeadersFor(emailNormalized) {
|
|
778
|
+
if (!newsletter || !unsubscribeBaseUrl) return null;
|
|
779
|
+
var emailHash;
|
|
780
|
+
try {
|
|
781
|
+
emailHash = b.crypto.namespaceHash(newsletter.EMAIL_NAMESPACE, emailNormalized);
|
|
782
|
+
} catch (_e) { return null; }
|
|
783
|
+
var signup = await newsletter.byEmailHash(emailHash);
|
|
784
|
+
if (!signup || !signup.id) return null;
|
|
785
|
+
var issued = await newsletter.issueUnsubscribeToken(signup.id);
|
|
786
|
+
var url = unsubscribeBaseUrl + "/newsletter/unsubscribe?token=" + encodeURIComponent(issued.token);
|
|
787
|
+
// Validate the header SHAPE through the vendored RFC 2369 + RFC 8058
|
|
788
|
+
// guard so a malformed link (non-https, control byte) never reaches
|
|
789
|
+
// the wire — Gmail / Yahoo refuse mail that carries a broken pair.
|
|
790
|
+
var listUnsub = "<" + url + ">";
|
|
791
|
+
var headers = {
|
|
792
|
+
"List-Unsubscribe": listUnsub,
|
|
793
|
+
"List-Unsubscribe-Post": b.guardListUnsubscribe.ONE_CLICK_POST_VALUE,
|
|
794
|
+
};
|
|
795
|
+
var verdict = b.guardListUnsubscribe.validate({
|
|
796
|
+
listUnsubscribe: listUnsub,
|
|
797
|
+
listUnsubscribePost: headers["List-Unsubscribe-Post"],
|
|
798
|
+
}, { profile: "strict" });
|
|
799
|
+
if (!verdict || verdict.action !== "accept") return null;
|
|
800
|
+
if (listId) {
|
|
801
|
+
var liVerdict = b.guardListId.validate("<" + listId + ">", { profile: "strict" });
|
|
802
|
+
if (liVerdict && liVerdict.action === "accept") {
|
|
803
|
+
headers["List-Id"] = "<" + listId + ">";
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return { headers: headers, url: url, email_hash: emailHash };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Has this recipient already been attempted for this campaign? The
|
|
810
|
+
// UNIQUE (campaign_slug, email_hash) on email_campaign_sends is the
|
|
811
|
+
// hard guarantee; this read lets the drain skip cheaply on a resume.
|
|
812
|
+
async function _alreadyAttempted(slug, emailHash) {
|
|
813
|
+
var r = await query(
|
|
814
|
+
"SELECT 1 FROM email_campaign_sends WHERE campaign_slug = ?1 AND email_hash = ?2 LIMIT 1",
|
|
815
|
+
[slug, emailHash],
|
|
816
|
+
);
|
|
817
|
+
return r.rows.length > 0;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function _recordSend(slug, emailHash, outcome, failReason) {
|
|
821
|
+
// INSERT OR IGNORE so a same-millisecond double-walk (concurrent
|
|
822
|
+
// cron tick) collapses onto the UNIQUE constraint rather than
|
|
823
|
+
// throwing — the first writer wins, the second no-ops. Used for the
|
|
824
|
+
// SKIP outcomes only; the send path claims first (_claimRecipient).
|
|
825
|
+
await query(
|
|
826
|
+
"INSERT OR IGNORE INTO email_campaign_sends " +
|
|
827
|
+
"(id, campaign_slug, email_hash, outcome, fail_reason, attempted_at) " +
|
|
828
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
829
|
+
[b.uuid.v7(), slug, emailHash, outcome, failReason || null, Date.now()],
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Atomically CLAIM a recipient BEFORE the mailer await. Two drains can
|
|
834
|
+
// overlap — an operator Send racing the cron tick, or two ticks reading
|
|
835
|
+
// the same `sending` row — and both pass the _alreadyAttempted read, so
|
|
836
|
+
// a read alone cannot stop a double mail. The UNIQUE (campaign_slug,
|
|
837
|
+
// email_hash) makes exactly one INSERT the winner; the loser writes
|
|
838
|
+
// zero rows and skips the recipient. The claim is finalized to its real
|
|
839
|
+
// outcome once the mailer answers; a crash between claim and send
|
|
840
|
+
// leaves the row at `claimed`, which is never re-mailed — at-most-once
|
|
841
|
+
// is the deliberate stance for marketing mail.
|
|
842
|
+
async function _claimRecipient(slug, emailHash) {
|
|
843
|
+
var r = await query(
|
|
844
|
+
"INSERT OR IGNORE INTO email_campaign_sends " +
|
|
845
|
+
"(id, campaign_slug, email_hash, outcome, fail_reason, attempted_at) " +
|
|
846
|
+
"VALUES (?1, ?2, ?3, 'claimed', NULL, ?4)",
|
|
847
|
+
[b.uuid.v7(), slug, emailHash, Date.now()],
|
|
848
|
+
);
|
|
849
|
+
return r.rowCount === 1;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
async function _finalizeSend(slug, emailHash, outcome, failReason) {
|
|
853
|
+
await query(
|
|
854
|
+
"UPDATE email_campaign_sends SET outcome = ?3, fail_reason = ?4, attempted_at = ?5 " +
|
|
855
|
+
"WHERE campaign_slug = ?1 AND email_hash = ?2 AND outcome = 'claimed'",
|
|
856
|
+
[slug, emailHash, outcome, failReason || null, Date.now()],
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Drain one campaign's audience as a consent-gated broadcast. Returns
|
|
861
|
+
// the per-outcome tallies for this pass. `more` is true when the rate
|
|
862
|
+
// budget stopped the drain before the audience was exhausted — the
|
|
863
|
+
// caller leaves the campaign in `sending` so the next cron tick
|
|
864
|
+
// resumes it past the recipients already in the send ledger.
|
|
865
|
+
async function _broadcastDrain(row) {
|
|
866
|
+
var tally = { sent: 0, failed: 0, skipped_unsubscribed: 0, skipped_suppressed: 0, resolved: 0 };
|
|
867
|
+
var cursor = null;
|
|
868
|
+
var more = false;
|
|
869
|
+
while (true) {
|
|
870
|
+
var page = await audiences.resolve({
|
|
871
|
+
slug: row.audience_slug,
|
|
872
|
+
limit: RESOLVE_PAGE_LIMIT,
|
|
873
|
+
cursor: cursor,
|
|
874
|
+
include_plaintext: true,
|
|
875
|
+
skip_suppressed: true,
|
|
876
|
+
});
|
|
877
|
+
var emails = page.emails_normalised || [];
|
|
878
|
+
var hashes = page.emails_hashed || [];
|
|
879
|
+
for (var i = 0; i < emails.length; i += 1) {
|
|
880
|
+
var emailNormalized = emails[i];
|
|
881
|
+
var emailHash = hashes[i];
|
|
882
|
+
if (!emailNormalized) continue;
|
|
883
|
+
tally.resolved += 1;
|
|
884
|
+
if (emailHash && await _alreadyAttempted(row.slug, emailHash)) continue;
|
|
885
|
+
|
|
886
|
+
// Consent re-check AT THE SEND MOMENT — the newsletter opt-out
|
|
887
|
+
// flag is the marketing-consent state; a recipient who
|
|
888
|
+
// unsubscribed after the send started is honored here, never
|
|
889
|
+
// mailed. byEmailHash returns the signup row incl.
|
|
890
|
+
// `unsubscribed_at`.
|
|
891
|
+
var consentHash = emailHash;
|
|
892
|
+
if (newsletter) {
|
|
893
|
+
try { consentHash = b.crypto.namespaceHash(newsletter.EMAIL_NAMESPACE, emailNormalized); }
|
|
894
|
+
catch (_e) { consentHash = emailHash; }
|
|
895
|
+
var signup = consentHash ? await newsletter.byEmailHash(consentHash) : null;
|
|
896
|
+
if (!signup || signup.unsubscribed_at != null) {
|
|
897
|
+
tally.skipped_unsubscribed += 1;
|
|
898
|
+
await _recordSend(row.slug, consentHash || emailHash, "skipped_unsubscribed");
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
// Second-line marketing-suppression check — catches a
|
|
903
|
+
// suppression that landed between the audience recompute and
|
|
904
|
+
// this drain (the audience-side filter is the first line).
|
|
905
|
+
if (suppressions && typeof suppressions.isSuppressed === "function") {
|
|
906
|
+
try {
|
|
907
|
+
var ss = await suppressions.isSuppressed({ email: emailNormalized, scope: "marketing" });
|
|
908
|
+
if (ss && ss.suppressed) {
|
|
909
|
+
tally.skipped_suppressed += 1;
|
|
910
|
+
await _recordSend(row.slug, consentHash || emailHash, "skipped_suppressed");
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
} catch (_eSupp) { /* drop-silent — fall through to send; the per-recipient gate already passed consent */ }
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Rate bound — reserve a slot BEFORE the mailer await. When the
|
|
917
|
+
// budget is exhausted, stop the drain for this pass and signal
|
|
918
|
+
// `more` so the cron tick resumes the campaign.
|
|
919
|
+
var now = Date.now();
|
|
920
|
+
if (!_sendBudgetAvailable(now)) { more = true; break; }
|
|
921
|
+
_reserveSendSlot(now);
|
|
922
|
+
|
|
923
|
+
// Claim BEFORE the send — when a concurrent drain already claimed
|
|
924
|
+
// this recipient, give the budget slot back and move on; the
|
|
925
|
+
// winner's send is the only one that happens.
|
|
926
|
+
if (!(await _claimRecipient(row.slug, consentHash || emailHash))) {
|
|
927
|
+
_releaseSendSlot(now);
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
var unsub = await _unsubscribeHeadersFor(emailNormalized);
|
|
932
|
+
// The stored body is the operator's SOURCE (Markdown/plaintext),
|
|
933
|
+
// not trusted HTML — the broadcast renders it through the same
|
|
934
|
+
// escape-by-default renderer the preview and test-send use, so
|
|
935
|
+
// what the operator approved is exactly what recipients get.
|
|
936
|
+
var bodyHtml = renderBody(row.body_html);
|
|
937
|
+
var bodyText = renderBodyText(row.body_text);
|
|
938
|
+
if (unsub) {
|
|
939
|
+
bodyHtml += "\n<hr />\n<p style=\"font-size:12px;color:#888\">" +
|
|
940
|
+
"You're receiving this because you subscribed to updates. " +
|
|
941
|
+
"<a href=\"" + _esc(unsub.url) + "\">Unsubscribe</a>.</p>";
|
|
942
|
+
bodyText += "\n\n---\nUnsubscribe: " + unsub.url;
|
|
943
|
+
}
|
|
944
|
+
var msg = {
|
|
945
|
+
to: emailNormalized,
|
|
946
|
+
subject: row.subject,
|
|
947
|
+
html: bodyHtml,
|
|
948
|
+
text: bodyText,
|
|
949
|
+
from: row.from_address,
|
|
950
|
+
from_name: row.from_name,
|
|
951
|
+
};
|
|
952
|
+
if (row.reply_to) msg.replyTo = row.reply_to;
|
|
953
|
+
if (unsub && unsub.headers) msg.headers = unsub.headers;
|
|
954
|
+
|
|
955
|
+
try {
|
|
956
|
+
await mailer.send(msg);
|
|
957
|
+
} catch (sendErr) {
|
|
958
|
+
// One bad address must not abort the campaign. Release the
|
|
959
|
+
// reserved budget slot (the send didn't land), record the
|
|
960
|
+
// failure with a scrubbed reason, keep draining.
|
|
961
|
+
_releaseSendSlot(now);
|
|
962
|
+
tally.failed += 1;
|
|
963
|
+
// Scrub the failure reason for the operator-facing ledger:
|
|
964
|
+
// strip control bytes (no CRLF / NUL into the stored row) and
|
|
965
|
+
// cap length. The recipient address is never echoed into the
|
|
966
|
+
// reason — only the mailer's own message.
|
|
967
|
+
var reason = String(sendErr && sendErr.message || sendErr).replace(CONTROL_BYTE_LINE_RE, " ").slice(0, 280);
|
|
968
|
+
await _finalizeSend(row.slug, consentHash || emailHash, "failed", reason);
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
tally.sent += 1;
|
|
972
|
+
await _finalizeSend(row.slug, consentHash || emailHash, "sent");
|
|
973
|
+
// Mirror a `delivered` engagement event so metricsForCampaign's
|
|
974
|
+
// rollup reflects the broadcast send (the ESP webhook backfills
|
|
975
|
+
// opened / clicked / bounced on top).
|
|
976
|
+
await query(
|
|
977
|
+
"INSERT INTO email_campaign_events " +
|
|
978
|
+
"(id, campaign_slug, recipient_hash, event_type, occurred_at) " +
|
|
979
|
+
"VALUES (?1, ?2, ?3, 'delivered', ?4)",
|
|
980
|
+
[b.uuid.v7(), row.slug, consentHash || emailHash, Date.now()],
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
if (more) break;
|
|
984
|
+
if (!page.next_cursor) break;
|
|
985
|
+
cursor = page.next_cursor;
|
|
986
|
+
}
|
|
987
|
+
return { tally: tally, more: more };
|
|
988
|
+
}
|
|
989
|
+
|
|
461
990
|
return {
|
|
462
|
-
STATUSES:
|
|
463
|
-
EVENT_TYPES:
|
|
464
|
-
|
|
991
|
+
STATUSES: STATUSES,
|
|
992
|
+
EVENT_TYPES: EVENT_TYPES,
|
|
993
|
+
SEND_OUTCOMES: SEND_OUTCOMES,
|
|
994
|
+
TERMINAL: TERMINAL,
|
|
995
|
+
renderBody: renderBody,
|
|
996
|
+
renderBodyText: renderBodyText,
|
|
465
997
|
|
|
466
998
|
// Define (or upsert) a campaign in `draft`. Re-defining an
|
|
467
999
|
// existing slug rewrites the body / sender / audience and bumps
|
|
@@ -829,12 +1361,270 @@ function create(opts) {
|
|
|
829
1361
|
unsubscribe_rate: _rate(counts.unsubscribed, delivered),
|
|
830
1362
|
};
|
|
831
1363
|
},
|
|
1364
|
+
|
|
1365
|
+
// Whether the consent-gated broadcast path is wired. The console
|
|
1366
|
+
// gates its Send button on this — without a deliverable-address
|
|
1367
|
+
// source (newsletter) + an https unsubscribe origin, the campaign
|
|
1368
|
+
// can be authored but not sent, and the screen says so plainly.
|
|
1369
|
+
canBroadcast: function () {
|
|
1370
|
+
return !!(newsletter && unsubscribeBaseUrl);
|
|
1371
|
+
},
|
|
1372
|
+
|
|
1373
|
+
// Resolved reachable count + breakdown, computed AT THE SEND-DECISION
|
|
1374
|
+
// moment (not at creation). Walks the campaign's audience, resolves
|
|
1375
|
+
// the deliverable plaintext address, and counts who is actually
|
|
1376
|
+
// marketing-consented + not suppressed right now. The console shows
|
|
1377
|
+
// this before the operator confirms a send so "send to N recipients"
|
|
1378
|
+
// is the true reachable count, never the raw membership. Read-only:
|
|
1379
|
+
// no token is minted, no mail is sent.
|
|
1380
|
+
reachability: async function (slug) {
|
|
1381
|
+
_validateSlug(slug, "slug");
|
|
1382
|
+
var row = await _getRow(slug);
|
|
1383
|
+
if (!row) {
|
|
1384
|
+
throw new TypeError("emailCampaigns.reachability: campaign '" + slug + "' not found");
|
|
1385
|
+
}
|
|
1386
|
+
var out = { slug: slug, audience_slug: row.audience_slug, resolved: 0, reachable: 0, unsubscribed: 0, suppressed: 0, no_address: 0 };
|
|
1387
|
+
if (!newsletter) {
|
|
1388
|
+
// No deliverable-address source — honestly report zero reachable
|
|
1389
|
+
// rather than implying the raw membership can be mailed.
|
|
1390
|
+
return out;
|
|
1391
|
+
}
|
|
1392
|
+
var cursor = null;
|
|
1393
|
+
while (true) {
|
|
1394
|
+
var page = await audiences.resolve({
|
|
1395
|
+
slug: row.audience_slug,
|
|
1396
|
+
limit: RESOLVE_PAGE_LIMIT,
|
|
1397
|
+
cursor: cursor,
|
|
1398
|
+
include_plaintext: true,
|
|
1399
|
+
skip_suppressed: false,
|
|
1400
|
+
});
|
|
1401
|
+
var emails = page.emails_normalised || [];
|
|
1402
|
+
for (var i = 0; i < emails.length; i += 1) {
|
|
1403
|
+
var emailNormalized = emails[i];
|
|
1404
|
+
out.resolved += 1;
|
|
1405
|
+
if (!emailNormalized) { out.no_address += 1; continue; }
|
|
1406
|
+
var hash;
|
|
1407
|
+
try { hash = b.crypto.namespaceHash(newsletter.EMAIL_NAMESPACE, emailNormalized); }
|
|
1408
|
+
catch (_e) { out.no_address += 1; continue; }
|
|
1409
|
+
var signup = await newsletter.byEmailHash(hash);
|
|
1410
|
+
if (!signup) { out.no_address += 1; continue; }
|
|
1411
|
+
if (signup.unsubscribed_at != null) { out.unsubscribed += 1; continue; }
|
|
1412
|
+
if (suppressions && typeof suppressions.isSuppressed === "function") {
|
|
1413
|
+
try {
|
|
1414
|
+
var ss = await suppressions.isSuppressed({ email: emailNormalized, scope: "marketing" });
|
|
1415
|
+
if (ss && ss.suppressed) { out.suppressed += 1; continue; }
|
|
1416
|
+
} catch (_eSupp) { /* drop-silent — treat as reachable; the send-time gate re-checks */ }
|
|
1417
|
+
}
|
|
1418
|
+
out.reachable += 1;
|
|
1419
|
+
}
|
|
1420
|
+
if (!page.next_cursor) break;
|
|
1421
|
+
cursor = page.next_cursor;
|
|
1422
|
+
}
|
|
1423
|
+
return out;
|
|
1424
|
+
},
|
|
1425
|
+
|
|
1426
|
+
// Per-recipient send-ledger rollup for a campaign — sent / failed /
|
|
1427
|
+
// skipped_unsubscribed / skipped_suppressed. The console renders
|
|
1428
|
+
// this as the per-campaign delivery counts.
|
|
1429
|
+
sendCounts: async function (slug) {
|
|
1430
|
+
_validateSlug(slug, "slug");
|
|
1431
|
+
var rows = (await query(
|
|
1432
|
+
"SELECT outcome, COUNT(*) AS n FROM email_campaign_sends WHERE campaign_slug = ?1 GROUP BY outcome",
|
|
1433
|
+
[slug],
|
|
1434
|
+
)).rows;
|
|
1435
|
+
var counts = {};
|
|
1436
|
+
for (var i = 0; i < SEND_OUTCOMES.length; i += 1) counts[SEND_OUTCOMES[i]] = 0;
|
|
1437
|
+
for (var j = 0; j < rows.length; j += 1) counts[rows[j].outcome] = Number(rows[j].n || 0);
|
|
1438
|
+
return counts;
|
|
1439
|
+
},
|
|
1440
|
+
|
|
1441
|
+
// Operator preview — the rendered (escape-by-default) body + a
|
|
1442
|
+
// re-rendered subject, exactly as a recipient would see it. Never
|
|
1443
|
+
// sends; mints no token. Throws on an unknown slug so the console
|
|
1444
|
+
// 404s rather than rendering an empty shell.
|
|
1445
|
+
previewCampaign: async function (slug) {
|
|
1446
|
+
_validateSlug(slug, "slug");
|
|
1447
|
+
var row = await _getRow(slug);
|
|
1448
|
+
if (!row) {
|
|
1449
|
+
throw new TypeError("emailCampaigns.previewCampaign: campaign '" + slug + "' not found");
|
|
1450
|
+
}
|
|
1451
|
+
return {
|
|
1452
|
+
slug: slug,
|
|
1453
|
+
subject: row.subject,
|
|
1454
|
+
from_address: row.from_address,
|
|
1455
|
+
from_name: row.from_name,
|
|
1456
|
+
reply_to: row.reply_to,
|
|
1457
|
+
body_html: renderBody(row.body_html),
|
|
1458
|
+
body_text: renderBodyText(row.body_text),
|
|
1459
|
+
};
|
|
1460
|
+
},
|
|
1461
|
+
|
|
1462
|
+
// Test-send — render + mail the campaign to ONE operator-supplied
|
|
1463
|
+
// address, bypassing the audience + consent gate (it's the
|
|
1464
|
+
// operator's own inbox, not a customer broadcast). Still carries the
|
|
1465
|
+
// RFC 8058 unsubscribe pair so the operator sees the real message.
|
|
1466
|
+
// The address is validated through the same strict guard the sender
|
|
1467
|
+
// identity uses. Returns `{ to }`. Rate-bound on the shared window.
|
|
1468
|
+
testSend: async function (slug, toRaw) {
|
|
1469
|
+
_validateSlug(slug, "slug");
|
|
1470
|
+
var to = _validateEmail(toRaw, "test recipient");
|
|
1471
|
+
var row = await _getRow(slug);
|
|
1472
|
+
if (!row) {
|
|
1473
|
+
throw new TypeError("emailCampaigns.testSend: campaign '" + slug + "' not found");
|
|
1474
|
+
}
|
|
1475
|
+
var now = Date.now();
|
|
1476
|
+
if (!_sendBudgetAvailable(now)) {
|
|
1477
|
+
var rl = new TypeError("emailCampaigns.testSend: send rate limit reached — try again shortly");
|
|
1478
|
+
rl.code = "EMAIL_CAMPAIGN_RATE_LIMITED";
|
|
1479
|
+
throw rl;
|
|
1480
|
+
}
|
|
1481
|
+
_reserveSendSlot(now);
|
|
1482
|
+
var msg = {
|
|
1483
|
+
to: to,
|
|
1484
|
+
subject: "[TEST] " + row.subject,
|
|
1485
|
+
html: renderBody(row.body_html),
|
|
1486
|
+
text: renderBodyText(row.body_text),
|
|
1487
|
+
from: row.from_address,
|
|
1488
|
+
from_name: row.from_name,
|
|
1489
|
+
};
|
|
1490
|
+
if (row.reply_to) msg.replyTo = row.reply_to;
|
|
1491
|
+
try {
|
|
1492
|
+
await mailer.send(msg);
|
|
1493
|
+
} catch (sendErr) {
|
|
1494
|
+
_releaseSendSlot(now);
|
|
1495
|
+
throw sendErr;
|
|
1496
|
+
}
|
|
1497
|
+
return { to: to };
|
|
1498
|
+
},
|
|
1499
|
+
|
|
1500
|
+
// Consent-gated broadcast send. The operator's "send now" — drives a
|
|
1501
|
+
// draft/scheduled campaign through sending → sent, draining the
|
|
1502
|
+
// audience as a consent-gated, RFC 8058-unsubscribable, rate-bound
|
|
1503
|
+
// broadcast against the deliverable plaintext address source. One bad
|
|
1504
|
+
// address is counted, not fatal. When the rate budget stops the drain
|
|
1505
|
+
// before the audience is exhausted the campaign stays in `sending`
|
|
1506
|
+
// and `complete` is false — the cron tick resumes it. Refuses when
|
|
1507
|
+
// the broadcast path isn't wired (no newsletter / no unsubscribe
|
|
1508
|
+
// origin) so the console never half-sends.
|
|
1509
|
+
broadcast: async function (slug) {
|
|
1510
|
+
_validateSlug(slug, "slug");
|
|
1511
|
+
if (!newsletter || !unsubscribeBaseUrl) {
|
|
1512
|
+
var e = new TypeError(
|
|
1513
|
+
"emailCampaigns.broadcast: not available — a deliverable-address source " +
|
|
1514
|
+
"(newsletter) and an https unsubscribe origin must be wired"
|
|
1515
|
+
);
|
|
1516
|
+
e.code = "EMAIL_CAMPAIGN_BROADCAST_UNAVAILABLE";
|
|
1517
|
+
throw e;
|
|
1518
|
+
}
|
|
1519
|
+
var row = await _getRow(slug);
|
|
1520
|
+
if (!row) {
|
|
1521
|
+
throw new TypeError("emailCampaigns.broadcast: campaign '" + slug + "' not found");
|
|
1522
|
+
}
|
|
1523
|
+
// Resume path — a campaign already in `sending` (rate-budget
|
|
1524
|
+
// pause from a prior pass) drains again without re-firing the FSM.
|
|
1525
|
+
if (row.status !== "sending") {
|
|
1526
|
+
var nowSched = Date.now();
|
|
1527
|
+
if (row.status === "draft") {
|
|
1528
|
+
await _fire(row, "schedule");
|
|
1529
|
+
await query(
|
|
1530
|
+
"UPDATE email_campaigns SET status = 'scheduled', schedule_at = ?1, updated_at = ?1 WHERE slug = ?2",
|
|
1531
|
+
[nowSched, slug],
|
|
1532
|
+
);
|
|
1533
|
+
row = await _getRow(slug);
|
|
1534
|
+
}
|
|
1535
|
+
if (row.status !== "scheduled") {
|
|
1536
|
+
throw new TypeError(
|
|
1537
|
+
"emailCampaigns.broadcast: campaign '" + slug + "' is in status '" +
|
|
1538
|
+
row.status + "' — cannot send"
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
await _fire(row, "start");
|
|
1542
|
+
await query(
|
|
1543
|
+
"UPDATE email_campaigns SET status = 'sending', updated_at = ?1 WHERE slug = ?2",
|
|
1544
|
+
[Date.now(), slug],
|
|
1545
|
+
);
|
|
1546
|
+
row = await _getRow(slug);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
var result = await _broadcastDrain(row);
|
|
1550
|
+
var tally = result.tally;
|
|
1551
|
+
var counts = await query(
|
|
1552
|
+
"SELECT outcome, COUNT(*) AS n FROM email_campaign_sends WHERE campaign_slug = ?1 GROUP BY outcome",
|
|
1553
|
+
[slug],
|
|
1554
|
+
);
|
|
1555
|
+
var sentTotal = 0;
|
|
1556
|
+
var resolvedTotal = 0;
|
|
1557
|
+
for (var k = 0; k < counts.rows.length; k += 1) {
|
|
1558
|
+
resolvedTotal += Number(counts.rows[k].n || 0);
|
|
1559
|
+
if (counts.rows[k].outcome === "sent") sentTotal = Number(counts.rows[k].n || 0);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
if (result.more) {
|
|
1563
|
+
// Rate budget paused the drain mid-audience — leave the campaign
|
|
1564
|
+
// in `sending`, stamp the running counts, let the cron tick
|
|
1565
|
+
// resume. Not terminal: `complete: false`.
|
|
1566
|
+
await query(
|
|
1567
|
+
"UPDATE email_campaigns SET recipients_resolved_count = ?1, sent_count = ?2, updated_at = ?3 WHERE slug = ?4",
|
|
1568
|
+
[resolvedTotal, sentTotal, Date.now(), slug],
|
|
1569
|
+
);
|
|
1570
|
+
return { slug: slug, complete: false, tally: tally, sent_count: sentTotal, resolved_count: resolvedTotal };
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// Audience exhausted — close the campaign out.
|
|
1574
|
+
var midRow = await _getRow(slug);
|
|
1575
|
+
await _fire(midRow, "complete");
|
|
1576
|
+
var sentAt = Date.now();
|
|
1577
|
+
await query(
|
|
1578
|
+
"UPDATE email_campaigns SET status = 'sent', recipients_resolved_count = ?1, " +
|
|
1579
|
+
"sent_count = ?2, sent_at = ?3, updated_at = ?3 WHERE slug = ?4",
|
|
1580
|
+
[resolvedTotal, sentTotal, sentAt, slug],
|
|
1581
|
+
);
|
|
1582
|
+
return { slug: slug, complete: true, tally: tally, sent_count: sentTotal, resolved_count: resolvedTotal };
|
|
1583
|
+
},
|
|
1584
|
+
|
|
1585
|
+
// Scheduler tick for broadcasts. Walks campaigns due for send
|
|
1586
|
+
// (`scheduled` with schedule_at <= now) AND campaigns parked in
|
|
1587
|
+
// `sending` by a rate-budget pause, and drains each as a consent-
|
|
1588
|
+
// gated broadcast. Inert (returns an empty dispatch list) when the
|
|
1589
|
+
// broadcast path isn't wired. Drop-safe — a single campaign's drain
|
|
1590
|
+
// failure is caught so one bad campaign doesn't stall the tick.
|
|
1591
|
+
broadcastTick: async function (input) {
|
|
1592
|
+
input = input || {};
|
|
1593
|
+
var now = input.now == null ? Date.now() : _validateTs(input.now, "now");
|
|
1594
|
+
var batchSize = _validateBatchSize(input.batch_size);
|
|
1595
|
+
if (!newsletter || !unsubscribeBaseUrl) {
|
|
1596
|
+
return { dispatched: [], enabled: false, now: now };
|
|
1597
|
+
}
|
|
1598
|
+
var due = await query(
|
|
1599
|
+
"SELECT slug FROM email_campaigns " +
|
|
1600
|
+
"WHERE (status = 'sending') OR " +
|
|
1601
|
+
"(status = 'scheduled' AND schedule_at IS NOT NULL AND schedule_at <= ?1) " +
|
|
1602
|
+
"ORDER BY schedule_at ASC, slug ASC LIMIT ?2",
|
|
1603
|
+
[now, batchSize],
|
|
1604
|
+
);
|
|
1605
|
+
var dispatched = [];
|
|
1606
|
+
for (var i = 0; i < due.rows.length; i += 1) {
|
|
1607
|
+
var slug = due.rows[i].slug;
|
|
1608
|
+
try {
|
|
1609
|
+
var res = await this.broadcast(slug);
|
|
1610
|
+
dispatched.push({ slug: slug, complete: res.complete, sent_count: res.sent_count });
|
|
1611
|
+
} catch (_e) {
|
|
1612
|
+
// A concurrent ticker race or a per-campaign fault must not
|
|
1613
|
+
// stall the others — skip and continue.
|
|
1614
|
+
continue;
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
return { dispatched: dispatched, enabled: true, now: now };
|
|
1618
|
+
},
|
|
832
1619
|
};
|
|
833
1620
|
}
|
|
834
1621
|
|
|
835
1622
|
module.exports = {
|
|
836
|
-
create:
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1623
|
+
create: create,
|
|
1624
|
+
renderBody: renderBody,
|
|
1625
|
+
renderBodyText: renderBodyText,
|
|
1626
|
+
STATUSES: STATUSES,
|
|
1627
|
+
EVENT_TYPES: EVENT_TYPES,
|
|
1628
|
+
SEND_OUTCOMES: SEND_OUTCOMES,
|
|
1629
|
+
TERMINAL: TERMINAL,
|
|
840
1630
|
};
|