@blamejs/blamejs-shop 0.0.62 → 0.0.65

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,795 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.emailWarmup
4
+ * @title Email warmup — gradual SMTP IP / domain warmup schedules
5
+ *
6
+ * @intro
7
+ * Operator brings up a new sending IP or sending domain — direct
8
+ * high-volume traffic at it on day one and the mailbox providers
9
+ * bucket the sender as "cold" or worse, "snowshoe spammer". The
10
+ * industry workaround is a multi-week ramp: a handful of sends on
11
+ * day one, a few hundred on day two, low thousands by week's end,
12
+ * tens of thousands by week three. `emailWarmup` owns the schedule
13
+ * + the per-day gate.
14
+ *
15
+ * The two sibling marketing surfaces — `emailCampaigns` (operator-
16
+ * scheduled bulk sends) and `dunning` (subscription past-due
17
+ * reminders) — consult `canSend({ slug, count_planned })` before
18
+ * each batch. The gate answers in one shape:
19
+ *
20
+ * { ok, sends_used_today, daily_target, remaining }
21
+ *
22
+ * `ok` is true iff the schedule is active, the calendar day is
23
+ * within the ramp window (`day_index` ≤ `daily_targets.length`),
24
+ * and `sends_used_today + count_planned ≤ daily_target`. When ok is
25
+ * false the caller defers — queue the message for tomorrow, page
26
+ * the operator, or fall back to a non-warming sender — but the
27
+ * primitive doesn't itself queue. After a successful batch the
28
+ * caller invokes `recordSends({ slug, count })` to bump the
29
+ * ledger; subsequent `canSend` calls reflect the new total.
30
+ *
31
+ * Composition:
32
+ *
33
+ * var warmup = bShop.emailWarmup.create({ query: q });
34
+ *
35
+ * await warmup.defineSchedule({
36
+ * slug: "release-ip-2026",
37
+ * sender_domain: "release.shop.example",
38
+ * sender_ip: "192.0.2.42", // optional
39
+ * start_date: "2026-05-22",
40
+ * daily_targets: [50, 100, 250, 500, 1000, 2000, 4000,
41
+ * 8000, 16000, 32000, 50000, 50000],
42
+ * status_check_threshold: 200, // 2.0% bps
43
+ * });
44
+ *
45
+ * var verdict = await warmup.canSend({
46
+ * slug: "release-ip-2026",
47
+ * count_planned: 40,
48
+ * });
49
+ * if (!verdict.ok) defer();
50
+ * else send40Messages();
51
+ * await warmup.recordSends({ slug: "release-ip-2026", count: 40 });
52
+ *
53
+ * // FSM transitions:
54
+ * await warmup.pauseSchedule({
55
+ * slug: "release-ip-2026",
56
+ * reason: "bounce spike — investigating",
57
+ * });
58
+ * await warmup.resumeSchedule({ slug: "release-ip-2026" });
59
+ * await warmup.archiveSchedule("release-ip-2026");
60
+ *
61
+ * // Listings + per-day rollup:
62
+ * await warmup.listSchedules({ active_only: true });
63
+ * await warmup.metricsForSchedule("release-ip-2026");
64
+ *
65
+ * Day-index math:
66
+ * The schedule's `start_date` is the operator's chosen Day 1.
67
+ * `currentDay(slug, now?)` returns the 1-based ordinal computed
68
+ * from UTC midnight diff between `start_date` and `now`:
69
+ *
70
+ * day_index = floor((now_utc_midnight - start_utc_midnight) / 1d) + 1
71
+ *
72
+ * Day 1 starts at the UTC midnight of `start_date`. A `now` that
73
+ * falls before `start_date` returns 0 (schedule hasn't begun);
74
+ * a `now` that falls beyond the ramp returns `length + 1`
75
+ * ("ramp complete"). `canSend` refuses sends in both of those
76
+ * out-of-window cases — pre-ramp is "not yet"; post-ramp is "the
77
+ * gate has retired, the operator should archive or graduate the
78
+ * sender to non-warming".
79
+ *
80
+ * Storage:
81
+ * - email_warmup_schedules + email_warmup_daily_sends
82
+ * (migration 0116_email_warmup.sql).
83
+ *
84
+ * Bounce + complaint rate is reported by `metricsForSchedule` per
85
+ * day; the gate (auto-pause when threshold is breached) is operator
86
+ * policy outside this primitive — same posture as fraud-screen +
87
+ * refund-policy.
88
+ *
89
+ * @primitive emailWarmup
90
+ * @related emailCampaigns, dunning, b.uuid.v7
91
+ */
92
+
93
+ // ---- constants ----------------------------------------------------------
94
+
95
+ var MAX_SLUG_LEN = 80;
96
+ var MAX_DAILY_TARGETS = 365; // a year of ramp ceiling — operator schedule, not framework
97
+ var MAX_DAILY_TARGET = 10000000; // 10M sends in one day — well above any reasonable IP ceiling
98
+ var MAX_THRESHOLD_BPS = 10000; // 100.0% in basis points
99
+ var MAX_REASON_LEN = 280;
100
+ var MAX_LIST_LIMIT = 500;
101
+ var MAX_DOMAIN_LEN = 253; // DNS hostname cap
102
+ var MAX_IP_LEN = 45; // IPv6 max textual length
103
+ var MS_PER_DAY = 24 * 60 * 60 * 1000;
104
+
105
+ var SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,79}$/;
106
+ // Lowercase domain — IDN punycode + alnum + hyphen + dot, ending in a
107
+ // 2+ alpha TLD. Same shape as tenants.DOMAIN_RE so the two primitives
108
+ // agree on what a sender domain looks like.
109
+ var DOMAIN_RE = /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/;
110
+ // IPv4 dotted-quad or IPv6 textual. Loose enough to admit every
111
+ // legitimate sender IP; strict enough to refuse arbitrary strings
112
+ // passed where an IP belongs.
113
+ var IPV4_RE = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/;
114
+ var IPV6_RE = /^[0-9a-f:]+$/;
115
+ var ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
116
+
117
+ var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
118
+ var ZERO_WIDTH_RE = new RegExp(
119
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
120
+ );
121
+
122
+ var ALLOWED_STATUSES = Object.freeze(["active", "paused", "archived"]);
123
+
124
+ // FSM transition graph. Archived is terminal — no outbound edges.
125
+ var TRANSITIONS = Object.freeze({
126
+ active: { pause: "paused", resume: null, archive: "archived" },
127
+ paused: { pause: null, resume: "active", archive: "archived" },
128
+ archived: { pause: null, resume: null, archive: null },
129
+ });
130
+
131
+ var bShop;
132
+ function _b() {
133
+ if (!bShop) bShop = require("./index");
134
+ return bShop.framework;
135
+ }
136
+
137
+ // ---- validators ---------------------------------------------------------
138
+
139
+ function _slug(s, label) {
140
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
141
+ throw new TypeError("emailWarmup: " + (label || "slug") +
142
+ " must match /^[a-z0-9][a-z0-9._-]*$/ (<= " + MAX_SLUG_LEN + " chars)");
143
+ }
144
+ return s;
145
+ }
146
+
147
+ function _senderDomain(s) {
148
+ if (typeof s !== "string" || !s.length || s.length > MAX_DOMAIN_LEN) {
149
+ throw new TypeError("emailWarmup: sender_domain must be a non-empty string <= " + MAX_DOMAIN_LEN + " chars");
150
+ }
151
+ var lower = s.toLowerCase();
152
+ if (!DOMAIN_RE.test(lower)) {
153
+ throw new TypeError("emailWarmup: sender_domain must be a lowercase DNS hostname");
154
+ }
155
+ return lower;
156
+ }
157
+
158
+ function _senderIp(s) {
159
+ if (s == null) return null;
160
+ if (typeof s !== "string" || !s.length || s.length > MAX_IP_LEN) {
161
+ throw new TypeError("emailWarmup: sender_ip must be a non-empty string <= " + MAX_IP_LEN + " chars when provided");
162
+ }
163
+ var lower = s.toLowerCase();
164
+ if (IPV4_RE.test(lower)) return lower;
165
+ if (IPV6_RE.test(lower) && lower.indexOf(":") !== -1) return lower;
166
+ throw new TypeError("emailWarmup: sender_ip must be a valid IPv4 dotted-quad or IPv6 textual form");
167
+ }
168
+
169
+ function _isoDate(s, label) {
170
+ if (typeof s !== "string" || !ISO_DATE_RE.test(s)) {
171
+ throw new TypeError("emailWarmup: " + label + " must be an ISO YYYY-MM-DD date string");
172
+ }
173
+ // Use Date.UTC to refuse impossible calendar dates (2026-02-31, etc.).
174
+ var parts = s.split("-");
175
+ var y = parseInt(parts[0], 10);
176
+ var m = parseInt(parts[1], 10);
177
+ var d = parseInt(parts[2], 10);
178
+ var ts = Date.UTC(y, m - 1, d);
179
+ var back = new Date(ts);
180
+ if (back.getUTCFullYear() !== y || back.getUTCMonth() !== m - 1 || back.getUTCDate() !== d) {
181
+ throw new TypeError("emailWarmup: " + label + " is not a valid calendar date");
182
+ }
183
+ return s;
184
+ }
185
+
186
+ function _dailyTargets(arr) {
187
+ if (!Array.isArray(arr) || !arr.length) {
188
+ throw new TypeError("emailWarmup: daily_targets must be a non-empty array of positive integers");
189
+ }
190
+ if (arr.length > MAX_DAILY_TARGETS) {
191
+ throw new TypeError("emailWarmup: daily_targets must be <= " + MAX_DAILY_TARGETS + " entries");
192
+ }
193
+ for (var i = 0; i < arr.length; i += 1) {
194
+ var n = arr[i];
195
+ if (!Number.isInteger(n) || n < 1 || n > MAX_DAILY_TARGET) {
196
+ throw new TypeError("emailWarmup: daily_targets[" + i + "] must be an integer in [1, " + MAX_DAILY_TARGET + "]");
197
+ }
198
+ }
199
+ // Defensive clone so the caller can't mutate the persisted ramp
200
+ // after defineSchedule returns.
201
+ return arr.slice();
202
+ }
203
+
204
+ function _thresholdBps(n) {
205
+ if (!Number.isInteger(n) || n < 0 || n > MAX_THRESHOLD_BPS) {
206
+ throw new TypeError("emailWarmup: status_check_threshold must be an integer in [0, " + MAX_THRESHOLD_BPS + "] (basis points)");
207
+ }
208
+ return n;
209
+ }
210
+
211
+ function _posInt(n, label) {
212
+ if (!Number.isInteger(n) || n < 1) {
213
+ throw new TypeError("emailWarmup: " + label + " must be a positive integer");
214
+ }
215
+ return n;
216
+ }
217
+
218
+ function _nonNegInt(n, label) {
219
+ if (!Number.isInteger(n) || n < 0) {
220
+ throw new TypeError("emailWarmup: " + label + " must be a non-negative integer");
221
+ }
222
+ return n;
223
+ }
224
+
225
+ function _epochMs(n, label) {
226
+ if (!Number.isInteger(n) || n < 0) {
227
+ throw new TypeError("emailWarmup: " + label + " must be a non-negative integer (epoch ms)");
228
+ }
229
+ return n;
230
+ }
231
+
232
+ function _reason(s) {
233
+ if (typeof s !== "string" || !s.length) {
234
+ throw new TypeError("emailWarmup: reason must be a non-empty string");
235
+ }
236
+ if (s.length > MAX_REASON_LEN) {
237
+ throw new TypeError("emailWarmup: reason must be <= " + MAX_REASON_LEN + " characters");
238
+ }
239
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
240
+ throw new TypeError("emailWarmup: reason contains control / zero-width bytes");
241
+ }
242
+ return s;
243
+ }
244
+
245
+ // ---- date math ----------------------------------------------------------
246
+
247
+ function _isoFromMs(ms) {
248
+ var d = new Date(ms);
249
+ var y = d.getUTCFullYear();
250
+ var m = d.getUTCMonth() + 1;
251
+ var dd = d.getUTCDate();
252
+ var mm = m < 10 ? "0" + m : String(m);
253
+ var dds = dd < 10 ? "0" + dd : String(dd);
254
+ return y + "-" + mm + "-" + dds;
255
+ }
256
+
257
+ function _midnightUtcMs(iso) {
258
+ var parts = iso.split("-");
259
+ return Date.UTC(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10));
260
+ }
261
+
262
+ // Day-index from start_date + now. Day 1 starts at UTC midnight of
263
+ // start_date. Returns 0 when now is before start_date; returns
264
+ // (targets.length + 1) when now is beyond the last day of the ramp.
265
+ function _computeDayIndex(startIso, nowMs) {
266
+ var startMs = _midnightUtcMs(startIso);
267
+ var nowMidIso = _isoFromMs(nowMs);
268
+ var nowMidMs = _midnightUtcMs(nowMidIso);
269
+ if (nowMidMs < startMs) return 0;
270
+ var diffDays = Math.floor((nowMidMs - startMs) / MS_PER_DAY);
271
+ return diffDays + 1;
272
+ }
273
+
274
+ // ---- row hydration ------------------------------------------------------
275
+
276
+ function _hydrateRow(r) {
277
+ if (!r) return null;
278
+ var targets;
279
+ try { targets = JSON.parse(r.daily_targets_json); }
280
+ catch (_e) { targets = []; }
281
+ return {
282
+ slug: r.slug,
283
+ sender_domain: r.sender_domain,
284
+ sender_ip: r.sender_ip == null ? null : r.sender_ip,
285
+ start_date: r.start_date,
286
+ daily_targets: targets,
287
+ status_check_threshold: Number(r.status_check_threshold),
288
+ status: r.status,
289
+ paused_reason: r.paused_reason == null ? null : r.paused_reason,
290
+ paused_at: r.paused_at == null ? null : Number(r.paused_at),
291
+ archived_at: r.archived_at == null ? null : Number(r.archived_at),
292
+ created_at: Number(r.created_at),
293
+ updated_at: Number(r.updated_at),
294
+ };
295
+ }
296
+
297
+ // ---- factory ------------------------------------------------------------
298
+
299
+ function create(opts) {
300
+ opts = opts || {};
301
+ var query = opts.query;
302
+ if (!query) {
303
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
304
+ }
305
+ var nowFn = typeof opts.now === "function" ? opts.now : function () { return Date.now(); };
306
+
307
+ async function _getRow(slug) {
308
+ var r = await query(
309
+ "SELECT * FROM email_warmup_schedules WHERE slug = ?1 LIMIT 1",
310
+ [slug],
311
+ );
312
+ return r.rows[0] || null;
313
+ }
314
+
315
+ async function _getDailyRow(slug, isoDate) {
316
+ var r = await query(
317
+ "SELECT * FROM email_warmup_daily_sends " +
318
+ "WHERE schedule_slug = ?1 AND occurred_date = ?2 LIMIT 1",
319
+ [slug, isoDate],
320
+ );
321
+ return r.rows[0] || null;
322
+ }
323
+
324
+ // -- defineSchedule ----------------------------------------------------
325
+
326
+ async function defineSchedule(input) {
327
+ if (!input || typeof input !== "object") {
328
+ throw new TypeError("emailWarmup.defineSchedule: input object required");
329
+ }
330
+ var slug = _slug(input.slug);
331
+ var senderDomain = _senderDomain(input.sender_domain);
332
+ var senderIp = _senderIp(input.sender_ip == null ? null : input.sender_ip);
333
+ var startDate = _isoDate(input.start_date, "start_date");
334
+ var dailyTargets = _dailyTargets(input.daily_targets);
335
+ var threshold = _thresholdBps(input.status_check_threshold);
336
+
337
+ var existing = await _getRow(slug);
338
+ if (existing) {
339
+ throw new TypeError(
340
+ "emailWarmup.defineSchedule: slug " + JSON.stringify(slug) +
341
+ " already exists in status " + JSON.stringify(existing.status) +
342
+ " — archive the existing schedule and pick a new slug"
343
+ );
344
+ }
345
+
346
+ var ts = nowFn();
347
+ await query(
348
+ "INSERT INTO email_warmup_schedules " +
349
+ "(slug, sender_domain, sender_ip, start_date, daily_targets_json, " +
350
+ "status_check_threshold, status, paused_reason, paused_at, archived_at, " +
351
+ "created_at, updated_at) " +
352
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'active', NULL, NULL, NULL, ?7, ?7)",
353
+ [slug, senderDomain, senderIp, startDate, JSON.stringify(dailyTargets), threshold, ts],
354
+ );
355
+ return _hydrateRow(await _getRow(slug));
356
+ }
357
+
358
+ // -- canSend -----------------------------------------------------------
359
+
360
+ // Returns the per-day gate verdict for a planned batch. Shape:
361
+ //
362
+ // {
363
+ // ok: bool,
364
+ // sends_used_today: int, // count already recorded today
365
+ // daily_target: int, // 0 when out-of-window
366
+ // remaining: int, // 0 when ok=false
367
+ // day_index: int, // 0 = pre-ramp; > length = post-ramp
368
+ // reason: string, // 'ok' | 'pre_ramp' | 'post_ramp' |
369
+ // // 'paused' | 'archived' | 'over_cap' |
370
+ // // 'unknown_schedule'
371
+ // }
372
+ //
373
+ // The caller treats `ok=false` as "defer / use a fallback sender";
374
+ // the `reason` lets operators observe WHY the gate refused.
375
+ async function canSend(input) {
376
+ if (!input || typeof input !== "object") {
377
+ throw new TypeError("emailWarmup.canSend: input object required");
378
+ }
379
+ _slug(input.slug);
380
+ var planned = input.count_planned == null ? 1 : _posInt(input.count_planned, "count_planned");
381
+ var nowMs = input.now == null ? nowFn() : _epochMs(input.now, "now");
382
+
383
+ var row = await _getRow(input.slug);
384
+ if (!row) {
385
+ return {
386
+ ok: false,
387
+ sends_used_today: 0,
388
+ daily_target: 0,
389
+ remaining: 0,
390
+ day_index: 0,
391
+ reason: "unknown_schedule",
392
+ };
393
+ }
394
+ if (row.status === "paused") {
395
+ return {
396
+ ok: false,
397
+ sends_used_today: 0,
398
+ daily_target: 0,
399
+ remaining: 0,
400
+ day_index: 0,
401
+ reason: "paused",
402
+ };
403
+ }
404
+ if (row.status === "archived") {
405
+ return {
406
+ ok: false,
407
+ sends_used_today: 0,
408
+ daily_target: 0,
409
+ remaining: 0,
410
+ day_index: 0,
411
+ reason: "archived",
412
+ };
413
+ }
414
+
415
+ var targets = JSON.parse(row.daily_targets_json);
416
+ var dayIndex = _computeDayIndex(row.start_date, nowMs);
417
+ if (dayIndex <= 0) {
418
+ return {
419
+ ok: false,
420
+ sends_used_today: 0,
421
+ daily_target: 0,
422
+ remaining: 0,
423
+ day_index: dayIndex,
424
+ reason: "pre_ramp",
425
+ };
426
+ }
427
+ if (dayIndex > targets.length) {
428
+ return {
429
+ ok: false,
430
+ sends_used_today: 0,
431
+ daily_target: 0,
432
+ remaining: 0,
433
+ day_index: dayIndex,
434
+ reason: "post_ramp",
435
+ };
436
+ }
437
+
438
+ var target = targets[dayIndex - 1];
439
+ var todayIso = _isoFromMs(nowMs);
440
+ var daily = await _getDailyRow(input.slug, todayIso);
441
+ var used = daily ? Number(daily.sends_count) : 0;
442
+ var remaining = target - used;
443
+ if (remaining < 0) remaining = 0;
444
+
445
+ if (used + planned > target) {
446
+ return {
447
+ ok: false,
448
+ sends_used_today: used,
449
+ daily_target: target,
450
+ remaining: remaining,
451
+ day_index: dayIndex,
452
+ reason: "over_cap",
453
+ };
454
+ }
455
+ return {
456
+ ok: true,
457
+ sends_used_today: used,
458
+ daily_target: target,
459
+ remaining: remaining,
460
+ day_index: dayIndex,
461
+ reason: "ok",
462
+ };
463
+ }
464
+
465
+ // -- recordSends -------------------------------------------------------
466
+
467
+ // Upsert the per-day send count. Refuses on unknown / paused /
468
+ // archived schedule because recording sends against a non-active
469
+ // ramp is a caller bug (the caller should have observed `canSend`
470
+ // returning ok=false and not actually sent). The function is the
471
+ // hot-path bookkeeping companion to canSend — but it still THROWS
472
+ // on bad inputs (config-time / entry-point tier, not observability-
473
+ // sink tier) so a typo'd slug surfaces immediately.
474
+ async function recordSends(input) {
475
+ if (!input || typeof input !== "object") {
476
+ throw new TypeError("emailWarmup.recordSends: input object required");
477
+ }
478
+ _slug(input.slug);
479
+ var count = _posInt(input.count, "count");
480
+ var occurred = input.occurred_at == null ? nowFn() : _epochMs(input.occurred_at, "occurred_at");
481
+
482
+ var row = await _getRow(input.slug);
483
+ if (!row) {
484
+ throw new TypeError("emailWarmup.recordSends: slug " + JSON.stringify(input.slug) + " not found");
485
+ }
486
+ if (row.status !== "active") {
487
+ throw new TypeError(
488
+ "emailWarmup.recordSends: cannot record sends against a schedule in status " +
489
+ JSON.stringify(row.status)
490
+ );
491
+ }
492
+
493
+ var iso = _isoFromMs(occurred);
494
+ var dayIndex = _computeDayIndex(row.start_date, occurred);
495
+ if (dayIndex <= 0) {
496
+ throw new TypeError("emailWarmup.recordSends: occurred_at is before start_date for slug " + JSON.stringify(input.slug));
497
+ }
498
+ var ts = nowFn();
499
+ var existing = await _getDailyRow(input.slug, iso);
500
+ if (existing) {
501
+ await query(
502
+ "UPDATE email_warmup_daily_sends SET sends_count = sends_count + ?1, " +
503
+ "updated_at = ?2 WHERE id = ?3",
504
+ [count, ts, existing.id],
505
+ );
506
+ } else {
507
+ var id = _b().uuid.v7();
508
+ await query(
509
+ "INSERT INTO email_warmup_daily_sends " +
510
+ "(id, schedule_slug, day_index, sends_count, bounce_count, complaint_count, " +
511
+ "occurred_date, created_at, updated_at) " +
512
+ "VALUES (?1, ?2, ?3, ?4, 0, 0, ?5, ?6, ?6)",
513
+ [id, input.slug, dayIndex, count, iso, ts],
514
+ );
515
+ }
516
+ var post = await _getDailyRow(input.slug, iso);
517
+ return {
518
+ slug: input.slug,
519
+ day_index: dayIndex,
520
+ sends_count: Number(post.sends_count),
521
+ occurred_date: iso,
522
+ };
523
+ }
524
+
525
+ // -- recordEngagement --------------------------------------------------
526
+
527
+ // Operator-routed ESP feedback — bumps the per-day bounce or
528
+ // complaint counter that drives metricsForSchedule. Drop-silent on
529
+ // unknown schedule because this runs on the inbound ESP webhook
530
+ // path; throwing here would crash the request that observed the
531
+ // bounce. Bad shape (negative count, bad event_type) still throws —
532
+ // those are operator-bug shapes, not race / unknown-row shapes.
533
+ async function recordEngagement(input) {
534
+ if (!input || typeof input !== "object") {
535
+ throw new TypeError("emailWarmup.recordEngagement: input object required");
536
+ }
537
+ _slug(input.slug);
538
+ if (input.event_type !== "bounce" && input.event_type !== "complaint") {
539
+ throw new TypeError("emailWarmup.recordEngagement: event_type must be one of 'bounce', 'complaint'");
540
+ }
541
+ var count = input.count == null ? 1 : _posInt(input.count, "count");
542
+ var occurred = input.occurred_at == null ? nowFn() : _epochMs(input.occurred_at, "occurred_at");
543
+
544
+ var row = await _getRow(input.slug);
545
+ if (!row) {
546
+ // Drop-silent on unknown schedule — ESP webhook arriving for a
547
+ // schedule that was archived between send and feedback.
548
+ return { recorded: false, reason: "unknown_schedule" };
549
+ }
550
+
551
+ var iso = _isoFromMs(occurred);
552
+ var dayIndex = _computeDayIndex(row.start_date, occurred);
553
+ if (dayIndex <= 0) {
554
+ return { recorded: false, reason: "pre_ramp" };
555
+ }
556
+ var ts = nowFn();
557
+ var col = input.event_type === "bounce" ? "bounce_count" : "complaint_count";
558
+ var existing = await _getDailyRow(input.slug, iso);
559
+ if (existing) {
560
+ await query(
561
+ "UPDATE email_warmup_daily_sends SET " + col + " = " + col + " + ?1, " +
562
+ "updated_at = ?2 WHERE id = ?3",
563
+ [count, ts, existing.id],
564
+ );
565
+ } else {
566
+ var id = _b().uuid.v7();
567
+ var bounce = input.event_type === "bounce" ? count : 0;
568
+ var complaint = input.event_type === "complaint" ? count : 0;
569
+ await query(
570
+ "INSERT INTO email_warmup_daily_sends " +
571
+ "(id, schedule_slug, day_index, sends_count, bounce_count, complaint_count, " +
572
+ "occurred_date, created_at, updated_at) " +
573
+ "VALUES (?1, ?2, ?3, 0, ?4, ?5, ?6, ?7, ?7)",
574
+ [id, input.slug, dayIndex, bounce, complaint, iso, ts],
575
+ );
576
+ }
577
+ return { recorded: true, slug: input.slug, day_index: dayIndex, event_type: input.event_type };
578
+ }
579
+
580
+ // -- currentDay --------------------------------------------------------
581
+
582
+ async function currentDay(slug, atMs) {
583
+ _slug(slug);
584
+ var nowMs = atMs == null ? nowFn() : _epochMs(atMs, "now");
585
+ var row = await _getRow(slug);
586
+ if (!row) {
587
+ throw new TypeError("emailWarmup.currentDay: slug " + JSON.stringify(slug) + " not found");
588
+ }
589
+ var targets = JSON.parse(row.daily_targets_json);
590
+ var dayIndex = _computeDayIndex(row.start_date, nowMs);
591
+ var status;
592
+ if (dayIndex <= 0) status = "pre_ramp";
593
+ else if (dayIndex > targets.length) status = "post_ramp";
594
+ else status = "in_ramp";
595
+ return {
596
+ slug: slug,
597
+ day_index: dayIndex,
598
+ ramp_length: targets.length,
599
+ daily_target: status === "in_ramp" ? targets[dayIndex - 1] : 0,
600
+ status: status,
601
+ };
602
+ }
603
+
604
+ // -- FSM transitions ---------------------------------------------------
605
+
606
+ async function _transition(slug, event, reason) {
607
+ _slug(slug);
608
+ var existing = await _getRow(slug);
609
+ if (!existing) {
610
+ throw new TypeError("emailWarmup." + event + ": slug " + JSON.stringify(slug) + " not found");
611
+ }
612
+ var allowed = TRANSITIONS[existing.status];
613
+ var next = allowed && allowed[event];
614
+ if (!next) {
615
+ throw new TypeError("emailWarmup." + event + ": cannot " + event +
616
+ " a schedule in status " + JSON.stringify(existing.status));
617
+ }
618
+ var ts = nowFn();
619
+ var sets = ["status = ?1", "updated_at = ?2"];
620
+ var params = [next, ts];
621
+ var idx = 3;
622
+ if (next === "paused") {
623
+ sets.push("paused_at = ?" + idx); params.push(ts); idx += 1;
624
+ sets.push("paused_reason = ?" + idx); params.push(reason); idx += 1;
625
+ } else if (next === "active") {
626
+ // Resume clears the paused_at / paused_reason so the columns
627
+ // reflect the most recent pause window only.
628
+ sets.push("paused_at = NULL");
629
+ sets.push("paused_reason = NULL");
630
+ } else if (next === "archived") {
631
+ sets.push("archived_at = ?" + idx); params.push(ts); idx += 1;
632
+ }
633
+ params.push(slug);
634
+ await query(
635
+ "UPDATE email_warmup_schedules SET " + sets.join(", ") + " WHERE slug = ?" + idx,
636
+ params,
637
+ );
638
+ return _hydrateRow(await _getRow(slug));
639
+ }
640
+
641
+ async function pauseSchedule(input) {
642
+ if (!input || typeof input !== "object") {
643
+ throw new TypeError("emailWarmup.pauseSchedule: input object required");
644
+ }
645
+ _slug(input.slug);
646
+ var reason = _reason(input.reason);
647
+ return await _transition(input.slug, "pause", reason);
648
+ }
649
+
650
+ async function resumeSchedule(input) {
651
+ if (!input || typeof input !== "object") {
652
+ throw new TypeError("emailWarmup.resumeSchedule: input object required");
653
+ }
654
+ _slug(input.slug);
655
+ return await _transition(input.slug, "resume", null);
656
+ }
657
+
658
+ async function archiveSchedule(slug) {
659
+ _slug(slug);
660
+ return await _transition(slug, "archive", null);
661
+ }
662
+
663
+ // -- listSchedules / getSchedule ---------------------------------------
664
+
665
+ async function getSchedule(slug) {
666
+ _slug(slug);
667
+ return _hydrateRow(await _getRow(slug));
668
+ }
669
+
670
+ async function listSchedules(listOpts) {
671
+ listOpts = listOpts || {};
672
+ var limit = MAX_LIST_LIMIT;
673
+ if (listOpts.limit != null) {
674
+ limit = _posInt(listOpts.limit, "limit");
675
+ if (limit > MAX_LIST_LIMIT) {
676
+ throw new TypeError("emailWarmup.listSchedules: limit must be <= " + MAX_LIST_LIMIT);
677
+ }
678
+ }
679
+ var sql, params;
680
+ if (listOpts.active_only) {
681
+ sql = "SELECT * FROM email_warmup_schedules WHERE status = 'active' " +
682
+ "ORDER BY created_at DESC, slug ASC LIMIT ?1";
683
+ params = [limit];
684
+ } else {
685
+ sql = "SELECT * FROM email_warmup_schedules " +
686
+ "ORDER BY created_at DESC, slug ASC LIMIT ?1";
687
+ params = [limit];
688
+ }
689
+ var rows = (await query(sql, params)).rows;
690
+ var out = [];
691
+ for (var i = 0; i < rows.length; i += 1) out.push(_hydrateRow(rows[i]));
692
+ return out;
693
+ }
694
+
695
+ // -- metricsForSchedule -----------------------------------------------
696
+
697
+ // Per-day rollup of sends + bounce rate + complaint rate. Bounce
698
+ // rate / complaint rate are basis points (out of 10000), computed
699
+ // from (count / sends_count); a day with zero sends returns rate=0
700
+ // (no divide-by-zero). The `threshold_breached` flag is true when
701
+ // either rate exceeds `status_check_threshold` — the operator
702
+ // dashboard uses this to surface days that warrant review. The
703
+ // primitive doesn't auto-pause; that's operator policy.
704
+ async function metricsForSchedule(slug) {
705
+ _slug(slug);
706
+ var row = await _getRow(slug);
707
+ if (!row) {
708
+ throw new TypeError("emailWarmup.metricsForSchedule: slug " + JSON.stringify(slug) + " not found");
709
+ }
710
+ var rows = (await query(
711
+ "SELECT day_index, sends_count, bounce_count, complaint_count, occurred_date " +
712
+ "FROM email_warmup_daily_sends " +
713
+ "WHERE schedule_slug = ?1 " +
714
+ "ORDER BY day_index ASC, occurred_date ASC",
715
+ [slug],
716
+ )).rows;
717
+ var threshold = Number(row.status_check_threshold);
718
+ var targets = JSON.parse(row.daily_targets_json);
719
+ var days = [];
720
+ var totalSends = 0;
721
+ var totalBounces = 0;
722
+ var totalComplaints = 0;
723
+ for (var i = 0; i < rows.length; i += 1) {
724
+ var d = rows[i];
725
+ var sends = Number(d.sends_count);
726
+ var bounces = Number(d.bounce_count);
727
+ var complaints = Number(d.complaint_count);
728
+ var bounceBps = sends > 0 ? Math.floor((bounces * 10000) / sends) : 0;
729
+ var complaintBps = sends > 0 ? Math.floor((complaints * 10000) / sends) : 0;
730
+ var breached = bounceBps > threshold || complaintBps > threshold;
731
+ var dayIndex = Number(d.day_index);
732
+ var target = dayIndex >= 1 && dayIndex <= targets.length ? targets[dayIndex - 1] : 0;
733
+ days.push({
734
+ day_index: dayIndex,
735
+ occurred_date: d.occurred_date,
736
+ sends_count: sends,
737
+ daily_target: target,
738
+ bounce_count: bounces,
739
+ complaint_count: complaints,
740
+ bounce_rate_bps: bounceBps,
741
+ complaint_rate_bps: complaintBps,
742
+ threshold_breached: breached,
743
+ });
744
+ totalSends += sends;
745
+ totalBounces += bounces;
746
+ totalComplaints += complaints;
747
+ }
748
+ return {
749
+ slug: slug,
750
+ status: row.status,
751
+ sender_domain: row.sender_domain,
752
+ sender_ip: row.sender_ip == null ? null : row.sender_ip,
753
+ start_date: row.start_date,
754
+ ramp_length: targets.length,
755
+ status_check_threshold: threshold,
756
+ total_sends: totalSends,
757
+ total_bounces: totalBounces,
758
+ total_complaints: totalComplaints,
759
+ days: days,
760
+ };
761
+ }
762
+
763
+ return {
764
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
765
+ MAX_DAILY_TARGETS: MAX_DAILY_TARGETS,
766
+ MAX_DAILY_TARGET: MAX_DAILY_TARGET,
767
+ MAX_THRESHOLD_BPS: MAX_THRESHOLD_BPS,
768
+ ALLOWED_STATUSES: ALLOWED_STATUSES,
769
+ TRANSITIONS: TRANSITIONS,
770
+
771
+ defineSchedule: defineSchedule,
772
+ canSend: canSend,
773
+ recordSends: recordSends,
774
+ recordEngagement: recordEngagement,
775
+ currentDay: currentDay,
776
+ pauseSchedule: pauseSchedule,
777
+ resumeSchedule: resumeSchedule,
778
+ archiveSchedule: archiveSchedule,
779
+ listSchedules: listSchedules,
780
+ getSchedule: getSchedule,
781
+ metricsForSchedule: metricsForSchedule,
782
+ };
783
+ }
784
+
785
+ module.exports = {
786
+ create: create,
787
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
788
+ MAX_DAILY_TARGETS: MAX_DAILY_TARGETS,
789
+ MAX_DAILY_TARGET: MAX_DAILY_TARGET,
790
+ MAX_THRESHOLD_BPS: MAX_THRESHOLD_BPS,
791
+ ALLOWED_STATUSES: ALLOWED_STATUSES,
792
+ TRANSITIONS: TRANSITIONS,
793
+ _computeDayIndex: _computeDayIndex, // exposed for tests
794
+ _isoFromMs: _isoFromMs, // exposed for tests
795
+ };