@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.
@@ -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 `&lt;`; 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, b.fsm
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
- var b = require("./vendor/blamejs");
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 `&lt;`. 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: STATUSES,
463
- EVENT_TYPES: EVENT_TYPES,
464
- TERMINAL: TERMINAL,
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: create,
837
- STATUSES: STATUSES,
838
- EVENT_TYPES: EVENT_TYPES,
839
- TERMINAL: TERMINAL,
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
  };