@blamejs/blamejs-shop 0.0.61 → 0.0.62

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.
@@ -0,0 +1,844 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.emailCampaigns
4
+ * @title Email campaigns — operator-scheduled bulk marketing sends
5
+ *
6
+ * @intro
7
+ * Marketing companion to `email` (transactional). Where `email`
8
+ * composes a single per-recipient send the application triggers
9
+ * (order receipt, ship notification, refund), this primitive owns
10
+ * the operator-defined broadcast — a single message body, an
11
+ * audience resolved through `mailingAudiences`, a `schedule_at` the
12
+ * dispatcher walks on a cron tick, and a per-event ledger
13
+ * (`delivered` / `opened` / `clicked` / `bounced` /
14
+ * `unsubscribed`) the dashboard rolls up into engagement rates.
15
+ *
16
+ * Composition:
17
+ *
18
+ * var campaigns = bShop.emailCampaigns.create({
19
+ * query: q,
20
+ * mailingAudiences: audiences,
21
+ * email: transactionalEmail,
22
+ * emailSuppressions: suppressions,
23
+ * });
24
+ *
25
+ * await campaigns.defineCampaign({
26
+ * slug: "release-0.1.0",
27
+ * subject: "blamejs.shop 0.1.0 is here",
28
+ * body_html: "<p>Hi {{customer_name}}…</p>",
29
+ * body_text: "Hi {{customer_name}}…",
30
+ * audience_slug: "release-watchers",
31
+ * schedule_at: Date.now() + 3600_000,
32
+ * from_address: "release@shop.example",
33
+ * from_name: "blamejs.shop",
34
+ * reply_to: "support@shop.example",
35
+ * });
36
+ *
37
+ * // Operator wires the scheduler tick (cron / Workers Cron Trigger).
38
+ * // Walks `WHERE status='scheduled' AND schedule_at <= now`,
39
+ * // resolves the audience, drains per-recipient sends through the
40
+ * // injected `email` mailer, records a `delivered` event per
41
+ * // recipient, then transitions the campaign to `sent`.
42
+ * await campaigns.dispatchTick({ now: Date.now() });
43
+ *
44
+ * // ESP webhook backfill — operator routes `opened` / `clicked` /
45
+ * // `bounced` / `unsubscribed` callbacks through here so the
46
+ * // per-campaign rates reflect downstream engagement.
47
+ * await campaigns.recordEvent({
48
+ * campaign_slug: "release-0.1.0",
49
+ * recipient_hash: hash,
50
+ * event_type: "opened",
51
+ * });
52
+ *
53
+ * await campaigns.metricsForCampaign("release-0.1.0");
54
+ * // → { delivered: 4200, opened: 1890, clicked: 420, bounced: 12,
55
+ * // unsubscribed: 7, open_rate: 0.45, click_rate: 0.1, … }
56
+ *
57
+ * The campaign FSM:
58
+ *
59
+ * draft — defined, not yet scheduled. `scheduleCampaign`
60
+ * moves it forward; `cancelCampaign` is terminal.
61
+ * scheduled — `schedule_at` populated; the dispatcher will pick
62
+ * it up at the next tick at-or-after that time.
63
+ * `pauseCampaign` parks it, `sendNow` short-circuits
64
+ * the wait, `cancelCampaign` aborts.
65
+ * sending — dispatcher is currently draining recipients.
66
+ * Internal-only — the only normal exit is `sent`.
67
+ * sent — terminal happy path. Per-recipient events keep
68
+ * flowing in via `recordEvent` (ESP webhooks).
69
+ * paused — operator paused a scheduled campaign. Resume via
70
+ * `resumeCampaign` (back to `scheduled` with the
71
+ * same / updated schedule_at); `cancelCampaign`
72
+ * still works.
73
+ * cancelled — terminal. `cancel_reason` records why.
74
+ *
75
+ * Transitional events (b.fsm):
76
+ *
77
+ * schedule : draft → scheduled
78
+ * pause : scheduled → paused
79
+ * resume : paused → scheduled
80
+ * start : scheduled → sending (dispatcher starts the run)
81
+ * complete : sending → sent (dispatcher drained the audience)
82
+ * cancel : draft|scheduled|paused → cancelled
83
+ *
84
+ * `sendNow(slug)` is the operator's "skip the schedule" — a draft
85
+ * moves draft → scheduled → sending → sent in one call, a scheduled
86
+ * campaign moves scheduled → sending → sent. The cron tick is the
87
+ * normal path; `sendNow` is the manual-fire bypass.
88
+ *
89
+ * Composition only — zero npm runtime deps. b.fsm owns the state
90
+ * transitions; b.uuid.v7 mints event ids; `mailingAudiences.resolve`
91
+ * produces the recipient hashes (already suppression-filtered if
92
+ * the audience factory was wired with `emailSuppressions`);
93
+ * `email.send`-shaped mailer handles the per-recipient send.
94
+ *
95
+ * @primitive emailCampaigns
96
+ * @related shop.email, shop.mailingAudiences, shop.emailSuppressions, b.fsm
97
+ */
98
+
99
+ // ---- constants ----------------------------------------------------------
100
+
101
+ var SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,62}[a-z0-9]$|^[a-z0-9]$/;
102
+ var MAX_SUBJECT_LEN = 200;
103
+ var MAX_BODY_LEN = 256 * 1024; // 256 KiB — wide enough for marketing HTML
104
+ var MAX_FROM_NAME_LEN = 100;
105
+ var MAX_REASON_LEN = 280;
106
+ var MAX_BATCH_SIZE = 1000;
107
+ var DEFAULT_BATCH_SIZE = 100;
108
+ var RESOLVE_PAGE_LIMIT = 500; // matches mailingAudiences MAX_LIST_LIMIT
109
+
110
+ var STATUSES = ["draft", "scheduled", "sending", "sent", "paused", "cancelled"];
111
+ var EVENT_TYPES = ["delivered", "opened", "clicked", "bounced", "unsubscribed"];
112
+ var TERMINAL = ["sent", "cancelled"];
113
+
114
+ var bShop;
115
+ function _b() {
116
+ if (!bShop) bShop = require("./index");
117
+ return bShop.framework;
118
+ }
119
+
120
+ // ---- FSM definition -----------------------------------------------------
121
+
122
+ var _campaignFsm = null;
123
+ function _getCampaignFsm() {
124
+ if (_campaignFsm) return _campaignFsm;
125
+ // Idempotent namespace registration so b.fsm's audit emit lands in
126
+ // the operator's audit sink instead of dropping with a warning.
127
+ try { _b().audit.registerNamespace("fsm"); } catch (_e) { /* idempotent */ }
128
+ _campaignFsm = _b().fsm.define({
129
+ name: "emailCampaign",
130
+ initial: "draft",
131
+ states: {
132
+ draft: {},
133
+ scheduled: {},
134
+ sending: {},
135
+ sent: {},
136
+ paused: {},
137
+ cancelled: {},
138
+ },
139
+ transitions: [
140
+ { from: "draft", to: "scheduled", on: "schedule" },
141
+ { from: "scheduled", to: "paused", on: "pause" },
142
+ { from: "paused", to: "scheduled", on: "resume" },
143
+ { from: "scheduled", to: "sending", on: "start" },
144
+ { from: "sending", to: "sent", on: "complete" },
145
+ { from: "draft", to: "cancelled", on: "cancel" },
146
+ { from: "scheduled", to: "cancelled", on: "cancel" },
147
+ { from: "paused", to: "cancelled", on: "cancel" },
148
+ ],
149
+ });
150
+ return _campaignFsm;
151
+ }
152
+
153
+ // ---- validators ---------------------------------------------------------
154
+
155
+ function _validateSlug(s, label) {
156
+ if (typeof s !== "string" || !s.length) {
157
+ throw new TypeError("emailCampaigns: " + label + " must be a non-empty string");
158
+ }
159
+ if (!SLUG_RE.test(s)) {
160
+ throw new TypeError(
161
+ "emailCampaigns: " + label + " must match /[a-z0-9][a-z0-9._-]*[a-z0-9]/"
162
+ );
163
+ }
164
+ return s;
165
+ }
166
+
167
+ function _validateSubject(s) {
168
+ if (typeof s !== "string" || !s.length) {
169
+ throw new TypeError("emailCampaigns: subject must be a non-empty string");
170
+ }
171
+ if (s.length > MAX_SUBJECT_LEN) {
172
+ throw new TypeError("emailCampaigns: subject must be <= " + MAX_SUBJECT_LEN + " characters");
173
+ }
174
+ if (/[\r\n\0]/.test(s)) {
175
+ throw new TypeError("emailCampaigns: subject must not contain CR / LF / NUL");
176
+ }
177
+ return s;
178
+ }
179
+
180
+ function _validateBody(s, label) {
181
+ if (typeof s !== "string" || !s.length) {
182
+ throw new TypeError("emailCampaigns: " + label + " must be a non-empty string");
183
+ }
184
+ if (s.length > MAX_BODY_LEN) {
185
+ throw new TypeError("emailCampaigns: " + label + " must be <= " + MAX_BODY_LEN + " bytes");
186
+ }
187
+ return s;
188
+ }
189
+
190
+ function _validateEmail(s, label) {
191
+ if (typeof s !== "string" || !s.length) {
192
+ throw new TypeError("emailCampaigns: " + label + " must be a non-empty string");
193
+ }
194
+ // Compose b.guardEmail.validate so the same strict profile as the
195
+ // transactional `email` primitive gates marketing sender identity.
196
+ var report;
197
+ try {
198
+ report = _b().guardEmail.validate(s, { profile: "strict" });
199
+ } catch (e) {
200
+ throw new TypeError("emailCampaigns: " + label + " — " + (e && e.message || "invalid email"));
201
+ }
202
+ if (!report || report.ok === false) {
203
+ var first = (report && report.issues && report.issues[0]) || {};
204
+ throw new TypeError(
205
+ "emailCampaigns: " + label + " — " + (first.ruleId || first.snippet || "refused at strict profile")
206
+ );
207
+ }
208
+ return _b().guardEmail.sanitize(s, { profile: "strict" });
209
+ }
210
+
211
+ function _validateFromName(s) {
212
+ if (typeof s !== "string" || !s.length) {
213
+ throw new TypeError("emailCampaigns: from_name must be a non-empty string");
214
+ }
215
+ if (s.length > MAX_FROM_NAME_LEN) {
216
+ throw new TypeError("emailCampaigns: from_name must be <= " + MAX_FROM_NAME_LEN + " characters");
217
+ }
218
+ if (/[\r\n\0]/.test(s)) {
219
+ throw new TypeError("emailCampaigns: from_name must not contain CR / LF / NUL");
220
+ }
221
+ return s;
222
+ }
223
+
224
+ function _validateTs(ts, label) {
225
+ if (typeof ts !== "number" || !Number.isInteger(ts) || ts < 0) {
226
+ throw new TypeError(
227
+ "emailCampaigns: " + label + " must be a non-negative integer epoch-ms"
228
+ );
229
+ }
230
+ return ts;
231
+ }
232
+
233
+ function _validateEventType(t) {
234
+ if (typeof t !== "string" || EVENT_TYPES.indexOf(t) === -1) {
235
+ throw new TypeError(
236
+ "emailCampaigns: event_type must be one of " + EVENT_TYPES.join(", ")
237
+ );
238
+ }
239
+ return t;
240
+ }
241
+
242
+ function _validateRecipientHash(h) {
243
+ if (typeof h !== "string" || !h.length) {
244
+ throw new TypeError("emailCampaigns: recipient_hash must be a non-empty string");
245
+ }
246
+ // Same shape gate as emailSuppressions / mailingAudiences — refuse
247
+ // anything outside the hash alphabet so a hand-crafted hash can't
248
+ // smuggle SQL through a parameter slot. The cap is loose (256 chars)
249
+ // because the underlying hash primitive may evolve; the strict shape
250
+ // is what matters.
251
+ if (h.length > 256 || !/^[A-Za-z0-9_-]+$/.test(h)) {
252
+ throw new TypeError(
253
+ "emailCampaigns: recipient_hash must match /[A-Za-z0-9_-]+/ (<=256 chars)"
254
+ );
255
+ }
256
+ return h;
257
+ }
258
+
259
+ function _validateReason(s) {
260
+ if (typeof s !== "string" || !s.length) {
261
+ throw new TypeError("emailCampaigns: reason must be a non-empty string");
262
+ }
263
+ if (s.length > MAX_REASON_LEN) {
264
+ throw new TypeError("emailCampaigns: reason must be <= " + MAX_REASON_LEN + " characters");
265
+ }
266
+ if (/[\r\n\0]/.test(s)) {
267
+ throw new TypeError("emailCampaigns: reason must not contain CR / LF / NUL");
268
+ }
269
+ return s;
270
+ }
271
+
272
+ function _validateStatus(s, label) {
273
+ if (typeof s !== "string" || STATUSES.indexOf(s) === -1) {
274
+ throw new TypeError(
275
+ "emailCampaigns: " + label + " must be one of " + STATUSES.join(", ")
276
+ );
277
+ }
278
+ return s;
279
+ }
280
+
281
+ function _validateBatchSize(n) {
282
+ if (n == null) return DEFAULT_BATCH_SIZE;
283
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_BATCH_SIZE) {
284
+ throw new TypeError(
285
+ "emailCampaigns: batch_size must be 1..." + MAX_BATCH_SIZE
286
+ );
287
+ }
288
+ return n;
289
+ }
290
+
291
+ // ---- row → public shape -------------------------------------------------
292
+
293
+ function _rowToCampaign(row) {
294
+ if (!row) return null;
295
+ return {
296
+ slug: row.slug,
297
+ subject: row.subject,
298
+ body_html: row.body_html,
299
+ body_text: row.body_text,
300
+ audience_slug: row.audience_slug,
301
+ schedule_at: row.schedule_at == null ? null : Number(row.schedule_at),
302
+ from_address: row.from_address,
303
+ from_name: row.from_name,
304
+ reply_to: row.reply_to,
305
+ status: row.status,
306
+ recipients_resolved_count: row.recipients_resolved_count == null ? null : Number(row.recipients_resolved_count),
307
+ sent_count: row.sent_count == null ? null : Number(row.sent_count),
308
+ sent_at: row.sent_at == null ? null : Number(row.sent_at),
309
+ paused_at: row.paused_at == null ? null : Number(row.paused_at),
310
+ cancelled_at: row.cancelled_at == null ? null : Number(row.cancelled_at),
311
+ cancel_reason: row.cancel_reason,
312
+ created_at: Number(row.created_at),
313
+ updated_at: Number(row.updated_at),
314
+ };
315
+ }
316
+
317
+ // ---- factory ------------------------------------------------------------
318
+
319
+ function create(opts) {
320
+ opts = opts || {};
321
+ var query = opts.query;
322
+ if (!query) {
323
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
324
+ }
325
+ // Required composition handles — the dispatcher needs the audience
326
+ // resolver to turn a slug into recipient hashes + the mailer to
327
+ // actually drain the per-recipient sends. emailSuppressions is
328
+ // optional because the audience-side resolver typically composes it
329
+ // already; the dispatcher takes a second-line check when it's wired
330
+ // so a suppression that landed BETWEEN audience recompute and
331
+ // dispatch tick still blocks the send.
332
+ if (!opts.mailingAudiences || typeof opts.mailingAudiences.resolve !== "function") {
333
+ throw new TypeError(
334
+ "emailCampaigns.create: opts.mailingAudiences (with .resolve()) is required"
335
+ );
336
+ }
337
+ if (!opts.email) {
338
+ throw new TypeError("emailCampaigns.create: opts.email is required");
339
+ }
340
+ // The transactional `email` factory exposes `.orderReceipt` /
341
+ // `.shipNotification` / etc.; for marketing sends we need raw access
342
+ // to the underlying mailer. Operators that pass the email factory
343
+ // result get a clear refusal; operators that pass a raw mailer
344
+ // (b.mail.create result) get accepted directly. The contract is "an
345
+ // object with .send(msg)".
346
+ var mailer;
347
+ if (typeof opts.email.send === "function") {
348
+ mailer = opts.email;
349
+ } else if (opts.email._mailer && typeof opts.email._mailer.send === "function") {
350
+ mailer = opts.email._mailer;
351
+ } else {
352
+ throw new TypeError(
353
+ "emailCampaigns.create: opts.email must be a mailer (object with .send()) or expose ._mailer"
354
+ );
355
+ }
356
+ var audiences = opts.mailingAudiences;
357
+ var suppressions = opts.emailSuppressions || null;
358
+
359
+ // ---- internal helpers (closed over factory state) --------------------
360
+
361
+ async function _getRow(slug) {
362
+ var r = await query(
363
+ "SELECT * FROM email_campaigns WHERE slug = ?1 LIMIT 1",
364
+ [slug],
365
+ );
366
+ return r.rows[0] || null;
367
+ }
368
+
369
+ // Drive a single FSM event for a campaign row. Rebuilds the FSM at
370
+ // the persisted state, fires the event, returns `{ from, to }`. The
371
+ // caller is responsible for the persistence side-effects that go
372
+ // with the transition (timestamp columns, side composition, etc.).
373
+ async function _fire(row, event) {
374
+ var fsm = _getCampaignFsm();
375
+ var instance = fsm.restore({
376
+ state: row.status,
377
+ history: [],
378
+ context: {},
379
+ });
380
+ try {
381
+ return await instance.transition(event);
382
+ } catch (e) {
383
+ var err = new Error(
384
+ "emailCampaigns: transition '" + event + "' refused from '" +
385
+ row.status + "' — " + (e && e.message || e)
386
+ );
387
+ err.code = (e && e.code) || "EMAIL_CAMPAIGN_TRANSITION_REFUSED";
388
+ err.cause = e;
389
+ throw err;
390
+ }
391
+ }
392
+
393
+ // Drain the recipient set for a campaign. Pages through the
394
+ // audience resolver, applies the second-line suppression check
395
+ // (if wired), invokes the mailer per recipient, records a
396
+ // `delivered` event per successful send. Returns the actual sent
397
+ // count + resolved count.
398
+ async function _drainSend(row) {
399
+ var resolvedTotal = 0;
400
+ var sentTotal = 0;
401
+ var cursor = null;
402
+ // Per-page loop — bounded by the audience's MAX_LIST_LIMIT
403
+ // (500). A 100k-member audience walks 200 pages; each page makes
404
+ // O(page) mailer.send calls. Operators that need throttling /
405
+ // backoff wrap the injected mailer.
406
+ while (true) {
407
+ var page = await audiences.resolve({
408
+ slug: row.audience_slug,
409
+ limit: RESOLVE_PAGE_LIMIT,
410
+ cursor: cursor,
411
+ include_plaintext: false,
412
+ });
413
+ resolvedTotal += page.emails_hashed.length;
414
+ for (var i = 0; i < page.emails_hashed.length; i += 1) {
415
+ var hash = page.emails_hashed[i];
416
+ // Second-line suppression check — only fires when the
417
+ // operator wired the suppressions handle into the campaigns
418
+ // factory. The audience factory's own suppression check is
419
+ // the first line; this catches additions that landed in the
420
+ // interval. The check needs the plaintext address to hit the
421
+ // hash lookup; without plaintext we trust the audience side.
422
+ if (suppressions && typeof suppressions.byHash === "function") {
423
+ try {
424
+ var ssRow = await suppressions.byHash(hash);
425
+ // byHash refuses non-hex-128 shapes; fall back silently
426
+ // (drop-silent — operator-visible suppression mismatch is
427
+ // a sink-side issue, not a per-recipient hard fail).
428
+ if (ssRow && (ssRow.scope === "all" || ssRow.scope === "marketing")) {
429
+ continue;
430
+ }
431
+ } catch (_eHash) { /* drop-silent — second-line only */ }
432
+ }
433
+ try {
434
+ await mailer.send({
435
+ to: hash, // hash is the addressable id; the mailer translates
436
+ subject: row.subject,
437
+ html: row.body_html,
438
+ text: row.body_text,
439
+ from: row.from_address,
440
+ from_name: row.from_name,
441
+ replyTo: row.reply_to || undefined,
442
+ });
443
+ } catch (_eSend) {
444
+ // drop-silent — the ESP webhook backfills `bounced` /
445
+ // `unsubscribed` rows so the per-recipient failure surfaces
446
+ // in metrics. Throwing here would stall the whole campaign
447
+ // on the first bad address.
448
+ continue;
449
+ }
450
+ sentTotal += 1;
451
+ var deliveredAt = Date.now();
452
+ await query(
453
+ "INSERT INTO email_campaign_events " +
454
+ "(id, campaign_slug, recipient_hash, event_type, occurred_at) " +
455
+ "VALUES (?1, ?2, ?3, 'delivered', ?4)",
456
+ [_b().uuid.v7(), row.slug, hash, deliveredAt],
457
+ );
458
+ }
459
+ if (!page.next_cursor) break;
460
+ cursor = page.next_cursor;
461
+ }
462
+ return { resolved_count: resolvedTotal, sent_count: sentTotal };
463
+ }
464
+
465
+ return {
466
+ STATUSES: STATUSES,
467
+ EVENT_TYPES: EVENT_TYPES,
468
+ TERMINAL: TERMINAL,
469
+
470
+ // Define (or upsert) a campaign in `draft`. Re-defining an
471
+ // existing slug rewrites the body / sender / audience and bumps
472
+ // `updated_at`; the campaign returns to `draft` only if it
473
+ // hasn't been scheduled (operators editing a `scheduled` /
474
+ // `sending` / `sent` campaign get refused — they archive +
475
+ // re-define under a new slug). `schedule_at` is optional; when
476
+ // supplied with a non-cancelled campaign, the row lands directly
477
+ // in `scheduled`.
478
+ defineCampaign: async function (input) {
479
+ if (!input || typeof input !== "object") {
480
+ throw new TypeError("emailCampaigns.defineCampaign: input object required");
481
+ }
482
+ var slug = _validateSlug(input.slug, "slug");
483
+ var subject = _validateSubject(input.subject);
484
+ var bodyHtml = _validateBody(input.body_html, "body_html");
485
+ var bodyText = _validateBody(input.body_text, "body_text");
486
+ var audienceSlug = _validateSlug(input.audience_slug, "audience_slug");
487
+ var fromAddress = _validateEmail(input.from_address, "from_address");
488
+ var fromName = _validateFromName(input.from_name);
489
+ var replyTo = null;
490
+ if (input.reply_to != null) replyTo = _validateEmail(input.reply_to, "reply_to");
491
+ var scheduleAt = null;
492
+ if (input.schedule_at != null) scheduleAt = _validateTs(input.schedule_at, "schedule_at");
493
+
494
+ var now = Date.now();
495
+ var existing = await _getRow(slug);
496
+
497
+ // Re-defining a scheduled / sending / sent / paused campaign
498
+ // is refused — operators edit pre-schedule, then schedule.
499
+ // Cancelled campaigns refuse too; the operator picks a new slug.
500
+ if (existing) {
501
+ if (existing.status !== "draft") {
502
+ throw new TypeError(
503
+ "emailCampaigns.defineCampaign: campaign '" + slug +
504
+ "' is in status '" + existing.status + "' — cannot redefine"
505
+ );
506
+ }
507
+ var statusOnUpdate = scheduleAt != null ? "scheduled" : "draft";
508
+ await query(
509
+ "UPDATE email_campaigns SET " +
510
+ "subject = ?1, body_html = ?2, body_text = ?3, audience_slug = ?4, " +
511
+ "schedule_at = ?5, from_address = ?6, from_name = ?7, reply_to = ?8, " +
512
+ "status = ?9, updated_at = ?10 " +
513
+ "WHERE slug = ?11",
514
+ [
515
+ subject, bodyHtml, bodyText, audienceSlug, scheduleAt,
516
+ fromAddress, fromName, replyTo, statusOnUpdate, now, slug,
517
+ ],
518
+ );
519
+ return _rowToCampaign(await _getRow(slug));
520
+ }
521
+
522
+ var status = scheduleAt != null ? "scheduled" : "draft";
523
+ await query(
524
+ "INSERT INTO email_campaigns " +
525
+ "(slug, subject, body_html, body_text, audience_slug, schedule_at, " +
526
+ "from_address, from_name, reply_to, status, " +
527
+ "recipients_resolved_count, sent_count, sent_at, paused_at, " +
528
+ "cancelled_at, cancel_reason, created_at, updated_at) " +
529
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, NULL, NULL, NULL, NULL, NULL, NULL, ?11, ?11)",
530
+ [
531
+ slug, subject, bodyHtml, bodyText, audienceSlug, scheduleAt,
532
+ fromAddress, fromName, replyTo, status, now,
533
+ ],
534
+ );
535
+ return _rowToCampaign(await _getRow(slug));
536
+ },
537
+
538
+ // Operator-driven schedule transition. Drafts move to `scheduled`
539
+ // with the supplied `schedule_at`; already-scheduled campaigns
540
+ // re-stamp `schedule_at` so the operator can shift the window
541
+ // without re-defining.
542
+ scheduleCampaign: async function (input) {
543
+ if (!input || typeof input !== "object") {
544
+ throw new TypeError("emailCampaigns.scheduleCampaign: input object required");
545
+ }
546
+ var slug = _validateSlug(input.slug, "slug");
547
+ var scheduleAt = _validateTs(input.schedule_at, "schedule_at");
548
+ var row = await _getRow(slug);
549
+ if (!row) {
550
+ throw new TypeError("emailCampaigns.scheduleCampaign: campaign '" + slug + "' not found");
551
+ }
552
+ if (row.status === "draft") {
553
+ await _fire(row, "schedule");
554
+ } else if (row.status !== "scheduled") {
555
+ throw new TypeError(
556
+ "emailCampaigns.scheduleCampaign: campaign '" + slug +
557
+ "' is in status '" + row.status + "' — cannot schedule"
558
+ );
559
+ }
560
+ var now = Date.now();
561
+ await query(
562
+ "UPDATE email_campaigns SET status = 'scheduled', schedule_at = ?1, updated_at = ?2 " +
563
+ "WHERE slug = ?3",
564
+ [scheduleAt, now, slug],
565
+ );
566
+ return _rowToCampaign(await _getRow(slug));
567
+ },
568
+
569
+ // Pause a scheduled campaign — the dispatcher walk skips paused
570
+ // rows because the predicate is `status = 'scheduled'`. Refuses
571
+ // non-scheduled rows so the operator knows pausing a draft / sent
572
+ // campaign is not the right verb.
573
+ pauseCampaign: async function (slug) {
574
+ _validateSlug(slug, "slug");
575
+ var row = await _getRow(slug);
576
+ if (!row) {
577
+ throw new TypeError("emailCampaigns.pauseCampaign: campaign '" + slug + "' not found");
578
+ }
579
+ await _fire(row, "pause");
580
+ var now = Date.now();
581
+ await query(
582
+ "UPDATE email_campaigns SET status = 'paused', paused_at = ?1, updated_at = ?1 " +
583
+ "WHERE slug = ?2",
584
+ [now, slug],
585
+ );
586
+ return _rowToCampaign(await _getRow(slug));
587
+ },
588
+
589
+ resumeCampaign: async function (slug) {
590
+ _validateSlug(slug, "slug");
591
+ var row = await _getRow(slug);
592
+ if (!row) {
593
+ throw new TypeError("emailCampaigns.resumeCampaign: campaign '" + slug + "' not found");
594
+ }
595
+ await _fire(row, "resume");
596
+ var now = Date.now();
597
+ await query(
598
+ "UPDATE email_campaigns SET status = 'scheduled', paused_at = NULL, updated_at = ?1 " +
599
+ "WHERE slug = ?2",
600
+ [now, slug],
601
+ );
602
+ return _rowToCampaign(await _getRow(slug));
603
+ },
604
+
605
+ // Cancel — terminal. `reason` is mandatory so the operator can't
606
+ // accidentally cancel without recording why (the dashboard surfaces
607
+ // `cancel_reason` so the next reviewer understands the intent).
608
+ cancelCampaign: async function (slug, reason) {
609
+ _validateSlug(slug, "slug");
610
+ _validateReason(reason);
611
+ var row = await _getRow(slug);
612
+ if (!row) {
613
+ throw new TypeError("emailCampaigns.cancelCampaign: campaign '" + slug + "' not found");
614
+ }
615
+ await _fire(row, "cancel");
616
+ var now = Date.now();
617
+ await query(
618
+ "UPDATE email_campaigns SET status = 'cancelled', cancelled_at = ?1, " +
619
+ "cancel_reason = ?2, updated_at = ?1 WHERE slug = ?3",
620
+ [now, reason, slug],
621
+ );
622
+ return _rowToCampaign(await _getRow(slug));
623
+ },
624
+
625
+ // Manual fire — bypass the scheduler. A draft moves through
626
+ // scheduled → sending → sent in one call; an already-scheduled
627
+ // campaign starts immediately. The drain walks the audience +
628
+ // mailer per the dispatchTick path.
629
+ sendNow: async function (slug) {
630
+ _validateSlug(slug, "slug");
631
+ var row = await _getRow(slug);
632
+ if (!row) {
633
+ throw new TypeError("emailCampaigns.sendNow: campaign '" + slug + "' not found");
634
+ }
635
+ // Drafts auto-schedule into the present so the FSM has a
636
+ // legal scheduled → sending hop. The `schedule_at` reflects
637
+ // the manual-fire moment.
638
+ var now = Date.now();
639
+ if (row.status === "draft") {
640
+ await _fire(row, "schedule");
641
+ await query(
642
+ "UPDATE email_campaigns SET status = 'scheduled', schedule_at = ?1, updated_at = ?1 " +
643
+ "WHERE slug = ?2",
644
+ [now, slug],
645
+ );
646
+ row = await _getRow(slug);
647
+ }
648
+ if (row.status !== "scheduled") {
649
+ throw new TypeError(
650
+ "emailCampaigns.sendNow: campaign '" + slug + "' is in status '" +
651
+ row.status + "' — cannot send"
652
+ );
653
+ }
654
+ await _fire(row, "start");
655
+ await query(
656
+ "UPDATE email_campaigns SET status = 'sending', updated_at = ?1 WHERE slug = ?2",
657
+ [Date.now(), slug],
658
+ );
659
+ var stats = await _drainSend(row);
660
+ // Re-fire complete off the freshly-read row so the FSM sees the
661
+ // 'sending' state we just wrote.
662
+ var midRow = await _getRow(slug);
663
+ await _fire(midRow, "complete");
664
+ var sentAt = Date.now();
665
+ await query(
666
+ "UPDATE email_campaigns SET status = 'sent', " +
667
+ "recipients_resolved_count = ?1, sent_count = ?2, sent_at = ?3, updated_at = ?3 " +
668
+ "WHERE slug = ?4",
669
+ [stats.resolved_count, stats.sent_count, sentAt, slug],
670
+ );
671
+ return _rowToCampaign(await _getRow(slug));
672
+ },
673
+
674
+ // Scheduler tick. Walks `WHERE status = 'scheduled' AND
675
+ // schedule_at <= now` (NULL `schedule_at` rows are never picked
676
+ // up — those landed via sendNow / pause / draft). Drains each
677
+ // matching campaign in turn; `batch_size` caps how many campaigns
678
+ // a single tick will process so a backlog doesn't block the
679
+ // worker indefinitely.
680
+ dispatchTick: async function (input) {
681
+ input = input || {};
682
+ var now = input.now == null ? Date.now() : _validateTs(input.now, "now");
683
+ var batchSize = _validateBatchSize(input.batch_size);
684
+
685
+ var due = await query(
686
+ "SELECT * FROM email_campaigns " +
687
+ "WHERE status = 'scheduled' AND schedule_at IS NOT NULL AND schedule_at <= ?1 " +
688
+ "ORDER BY schedule_at ASC, slug ASC LIMIT ?2",
689
+ [now, batchSize],
690
+ );
691
+ var dispatched = [];
692
+ for (var i = 0; i < due.rows.length; i += 1) {
693
+ var row = due.rows[i];
694
+ try {
695
+ await _fire(row, "start");
696
+ } catch (_eStart) {
697
+ // Concurrent ticker / status race — another worker already
698
+ // picked it up. Skip; the row is no longer scheduled.
699
+ continue;
700
+ }
701
+ await query(
702
+ "UPDATE email_campaigns SET status = 'sending', updated_at = ?1 WHERE slug = ?2",
703
+ [Date.now(), row.slug],
704
+ );
705
+ var stats = await _drainSend(row);
706
+ var midRow = await _getRow(row.slug);
707
+ await _fire(midRow, "complete");
708
+ var sentAt = Date.now();
709
+ await query(
710
+ "UPDATE email_campaigns SET status = 'sent', " +
711
+ "recipients_resolved_count = ?1, sent_count = ?2, sent_at = ?3, updated_at = ?3 " +
712
+ "WHERE slug = ?4",
713
+ [stats.resolved_count, stats.sent_count, sentAt, row.slug],
714
+ );
715
+ dispatched.push({
716
+ slug: row.slug,
717
+ resolved_count: stats.resolved_count,
718
+ sent_count: stats.sent_count,
719
+ sent_at: sentAt,
720
+ });
721
+ }
722
+ return { dispatched: dispatched, now: now };
723
+ },
724
+
725
+ // Record a per-recipient event. `delivered` is normally stamped
726
+ // by the dispatcher; this entry point is operators routing ESP
727
+ // webhook callbacks (`opened` / `clicked` / `bounced` /
728
+ // `unsubscribed`) — and they're free to record `delivered` too
729
+ // when the ESP is the source of truth.
730
+ recordEvent: async function (input) {
731
+ if (!input || typeof input !== "object") {
732
+ throw new TypeError("emailCampaigns.recordEvent: input object required");
733
+ }
734
+ var campaignSlug = _validateSlug(input.campaign_slug, "campaign_slug");
735
+ var recipient = _validateRecipientHash(input.recipient_hash);
736
+ var eventType = _validateEventType(input.event_type);
737
+ var occurredAt;
738
+ if (input.occurred_at == null) {
739
+ occurredAt = Date.now();
740
+ } else {
741
+ occurredAt = _validateTs(input.occurred_at, "occurred_at");
742
+ }
743
+ // Campaign existence is a soft check — the row records what the
744
+ // operator received from the ESP, even if a typo'd slug landed
745
+ // for a campaign that doesn't exist. The dashboard surfaces
746
+ // unmatched events for operator review.
747
+ var id = _b().uuid.v7();
748
+ await query(
749
+ "INSERT INTO email_campaign_events " +
750
+ "(id, campaign_slug, recipient_hash, event_type, occurred_at) " +
751
+ "VALUES (?1, ?2, ?3, ?4, ?5)",
752
+ [id, campaignSlug, recipient, eventType, occurredAt],
753
+ );
754
+ return {
755
+ id: id,
756
+ campaign_slug: campaignSlug,
757
+ recipient_hash: recipient,
758
+ event_type: eventType,
759
+ occurred_at: occurredAt,
760
+ };
761
+ },
762
+
763
+ getCampaign: async function (slug) {
764
+ _validateSlug(slug, "slug");
765
+ var row = await _getRow(slug);
766
+ return _rowToCampaign(row);
767
+ },
768
+
769
+ listCampaigns: async function (listOpts) {
770
+ listOpts = listOpts || {};
771
+ var status = null;
772
+ if (listOpts.status != null) status = _validateStatus(listOpts.status, "status");
773
+ var sql = "SELECT * FROM email_campaigns";
774
+ var params = [];
775
+ if (status) {
776
+ sql += " WHERE status = ?1";
777
+ params.push(status);
778
+ }
779
+ sql += " ORDER BY created_at DESC, slug DESC";
780
+ var rows = (await query(sql, params)).rows;
781
+ var out = [];
782
+ for (var i = 0; i < rows.length; i += 1) out.push(_rowToCampaign(rows[i]));
783
+ return out;
784
+ },
785
+
786
+ // Aggregate per-campaign engagement. Reads the event ledger
787
+ // grouped by event_type; the rate denominators are the
788
+ // `delivered` count (industry-standard for open / click rates) +
789
+ // the `sent_count` snapshot on the campaign row (for the
790
+ // delivery rate against the resolved audience). Returns zeros
791
+ // for every event_type the campaign hasn't seen — easier to
792
+ // render a "0 opens" tile than handle a missing key in the UI.
793
+ metricsForCampaign: async function (slug) {
794
+ _validateSlug(slug, "slug");
795
+ var row = await _getRow(slug);
796
+ if (!row) {
797
+ throw new TypeError("emailCampaigns.metricsForCampaign: campaign '" + slug + "' not found");
798
+ }
799
+ var rows = (await query(
800
+ "SELECT event_type, COUNT(*) AS n FROM email_campaign_events " +
801
+ "WHERE campaign_slug = ?1 GROUP BY event_type",
802
+ [slug],
803
+ )).rows;
804
+ var counts = {};
805
+ for (var i = 0; i < EVENT_TYPES.length; i += 1) counts[EVENT_TYPES[i]] = 0;
806
+ for (var j = 0; j < rows.length; j += 1) {
807
+ counts[rows[j].event_type] = Number(rows[j].n || 0);
808
+ }
809
+ var delivered = counts.delivered;
810
+ // Rate math — guard against div-by-zero so a not-yet-delivered
811
+ // campaign renders 0 instead of NaN. The `delivery_rate` is the
812
+ // count of delivered events over the resolved audience size;
813
+ // the engagement rates (open / click / bounce / unsubscribe)
814
+ // are over delivered.
815
+ function _rate(n, d) { return d > 0 ? n / d : 0; }
816
+ var resolved = row.recipients_resolved_count == null
817
+ ? 0 : Number(row.recipients_resolved_count);
818
+ return {
819
+ slug: slug,
820
+ status: row.status,
821
+ resolved_count: resolved,
822
+ sent_count: row.sent_count == null ? 0 : Number(row.sent_count),
823
+ counts: counts,
824
+ delivered: delivered,
825
+ opened: counts.opened,
826
+ clicked: counts.clicked,
827
+ bounced: counts.bounced,
828
+ unsubscribed: counts.unsubscribed,
829
+ delivery_rate: _rate(delivered, resolved),
830
+ open_rate: _rate(counts.opened, delivered),
831
+ click_rate: _rate(counts.clicked, delivered),
832
+ bounce_rate: _rate(counts.bounced, delivered),
833
+ unsubscribe_rate: _rate(counts.unsubscribed, delivered),
834
+ };
835
+ },
836
+ };
837
+ }
838
+
839
+ module.exports = {
840
+ create: create,
841
+ STATUSES: STATUSES,
842
+ EVENT_TYPES: EVENT_TYPES,
843
+ TERMINAL: TERMINAL,
844
+ };