@blamejs/blamejs-shop 0.0.53 → 0.0.56

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,579 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.emailSuppressions
4
+ * @title Email suppression list — opt-out / bounce / complaint gate
5
+ *
6
+ * @intro
7
+ * The storefront sends transactional mail (order receipt, ship
8
+ * notification, refund) and marketing mail (newsletter, wishlist-
9
+ * discount, abandoned-cart, review-request). Every send-time caller
10
+ * asks this primitive whether the recipient is suppressed before
11
+ * composing the message, so addresses that hard-bounced, filed an
12
+ * ISP complaint, opted out of marketing, or were manually shut off
13
+ * by the operator never see another delivery attempt.
14
+ *
15
+ * Storage shape:
16
+ * - `email_hash` (PK) — `b.crypto.namespaceHash("email-
17
+ * suppression", normalised_email)`. Re-occurrence collapses
18
+ * onto the same row via `INSERT OR REPLACE`, incrementing
19
+ * `occurrences` + bumping `last_seen_at` + refreshing `reason`.
20
+ * - `email_normalized` — lowercased + trimmed plaintext. The raw
21
+ * input (mixed case, surrounding whitespace) never persists.
22
+ * Operators authoring application-layer AEAD can wrap this
23
+ * primitive via `b.vault.seal`; D1's at-rest AEAD covers the
24
+ * disk layer by default.
25
+ * - `suppression_type` — `unsubscribe` / `hard-bounce` /
26
+ * `soft-bounce` / `complaint` / `operator-manual` /
27
+ * `rate-limit-block`. Operator-debuggable cause.
28
+ * - `scope` — `all` blocks every outbound mail; `marketing`
29
+ * blocks newsletter / wishlist-discount / abandoned-cart /
30
+ * review-request; `transactional` blocks order receipt / ship
31
+ * notification / refund.
32
+ * - `expires_at` — NULL for permanent suppressions. `soft-bounce`
33
+ * and `rate-limit-block` default to operator-supplied windows
34
+ * (caller supplies; primitive doesn't pick a default).
35
+ *
36
+ * Composition:
37
+ * var sup = bShop.emailSuppressions.create({ query: q });
38
+ * await sup.add({
39
+ * email: "alice@example.com",
40
+ * suppression_type: "hard-bounce",
41
+ * reason: "550 5.1.1 mailbox does not exist",
42
+ * source: "sendgrid",
43
+ * });
44
+ * var view = await sup.isSuppressed({
45
+ * email: "alice@example.com",
46
+ * scope: "transactional",
47
+ * });
48
+ * // view.suppressed === true; respect it before composing the
49
+ * // order receipt.
50
+ *
51
+ * Scope hierarchy:
52
+ * A row with `scope='all'` suppresses every outbound regardless
53
+ * of the caller's requested scope. A row with `scope='marketing'`
54
+ * suppresses only marketing sends; transactional still ships. A
55
+ * row with `scope='transactional'` is the rare case — usually
56
+ * only after a hard-bounce when the operator decides not to
57
+ * retry — and suppresses only transactional sends.
58
+ *
59
+ * Pagination:
60
+ * `list({ cursor })` returns an HMAC-tagged cursor signed by
61
+ * `opts.cursorSecret` so an operator can't hand-craft one to
62
+ * skip past a hidden row. The secret defaults to a dev-only
63
+ * placeholder under `NODE_ENV != 'production'`; the deployment
64
+ * supplies a derived value (typically
65
+ * `b.crypto.namespaceHash("email-suppressions-cursor",
66
+ * D1_BRIDGE_SECRET)`).
67
+ *
68
+ * @primitive emailSuppressions
69
+ * @related b.guardEmail, b.crypto.namespaceHash, b.pagination
70
+ */
71
+
72
+ var EMAIL_NAMESPACE = "email-suppression";
73
+ var SUPPRESSION_TYPES = [
74
+ "unsubscribe", "hard-bounce", "soft-bounce",
75
+ "complaint", "operator-manual", "rate-limit-block",
76
+ ];
77
+ var SCOPES = ["transactional", "marketing", "all"];
78
+ var MAX_REASON_LEN = 1024;
79
+ var MAX_SOURCE_LEN = 64;
80
+ var SOURCE_RE = /^[a-z0-9][a-z0-9._-]{0,62}[a-z0-9]$|^[a-z0-9]$/;
81
+ var MAX_LIST_LIMIT = 200;
82
+ var DEFAULT_LIST_LIMIT = 25;
83
+ var LIST_ORDER_KEY = ["last_seen_at:desc", "email_hash:desc"];
84
+
85
+ // Lazy framework handle — matches the pattern used by the rest of
86
+ // the shop primitives; avoids the require cycle that would arise
87
+ // from importing `./index` at module-eval time.
88
+ var bShop;
89
+ function _b() {
90
+ if (!bShop) bShop = require("./index");
91
+ return bShop.framework;
92
+ }
93
+
94
+ // ---- validators ---------------------------------------------------------
95
+
96
+ function _normalizeEmail(input) {
97
+ if (typeof input !== "string" || !input.length) {
98
+ throw new TypeError("emailSuppressions: email must be a non-empty string");
99
+ }
100
+ var guardEmail = _b().guardEmail;
101
+ // validate first so a critical-severity refusal surfaces with the
102
+ // exact issue; sanitize then folds bidi / zero-width / control
103
+ // codepoints per the strict profile. Lowercase + trim is applied
104
+ // before hashing so two casings collide onto a single row.
105
+ var report;
106
+ try {
107
+ report = guardEmail.validate(input, { profile: "strict" });
108
+ } catch (e) {
109
+ throw new TypeError("emailSuppressions: email — " + (e && e.message || "invalid email"));
110
+ }
111
+ if (!report || report.ok === false) {
112
+ var first = (report && report.issues && report.issues[0]) || {};
113
+ throw new TypeError("emailSuppressions: email — " + (first.snippet || first.ruleId || "refused at strict profile"));
114
+ }
115
+ var canonical;
116
+ try {
117
+ canonical = guardEmail.sanitize(input, { profile: "strict" });
118
+ } catch (e) {
119
+ throw new TypeError("emailSuppressions: email — " + (e && e.message || "refused"));
120
+ }
121
+ return canonical.trim().toLowerCase();
122
+ }
123
+
124
+ function _validateType(t) {
125
+ if (typeof t !== "string" || SUPPRESSION_TYPES.indexOf(t) === -1) {
126
+ throw new TypeError(
127
+ "emailSuppressions: suppression_type must be one of " +
128
+ SUPPRESSION_TYPES.join(", ")
129
+ );
130
+ }
131
+ return t;
132
+ }
133
+
134
+ function _validateScope(s, label) {
135
+ if (typeof s !== "string" || SCOPES.indexOf(s) === -1) {
136
+ throw new TypeError(
137
+ "emailSuppressions: " + label + " must be one of " + SCOPES.join(", ")
138
+ );
139
+ }
140
+ return s;
141
+ }
142
+
143
+ function _defaultScopeFor(type) {
144
+ // hard-bounce / soft-bounce / complaint default to transactional —
145
+ // the recipient is failing deliveries, the operator decides not to
146
+ // retry. unsubscribe defaults to marketing — the user opted out of
147
+ // broadcast volume, not transactional receipts. operator-manual
148
+ // and rate-limit-block default to all — the operator's intent is
149
+ // shut-off-completely.
150
+ if (type === "hard-bounce" || type === "soft-bounce" || type === "complaint") {
151
+ return "transactional";
152
+ }
153
+ if (type === "unsubscribe") {
154
+ return "marketing";
155
+ }
156
+ return "all";
157
+ }
158
+
159
+ function _validateReason(r) {
160
+ if (r == null) return "";
161
+ if (typeof r !== "string") {
162
+ throw new TypeError("emailSuppressions: reason must be a string or null");
163
+ }
164
+ if (r.length > MAX_REASON_LEN) {
165
+ throw new TypeError(
166
+ "emailSuppressions: reason must be <= " + MAX_REASON_LEN + " chars"
167
+ );
168
+ }
169
+ // Refuse CR / LF / NUL so a bounce-message echo can't smuggle a
170
+ // header-injection or log-injection vector into operator-visible
171
+ // surfaces.
172
+ if (/[\r\n\0]/.test(r)) {
173
+ throw new TypeError("emailSuppressions: reason must not contain CR / LF / NUL");
174
+ }
175
+ return r;
176
+ }
177
+
178
+ function _validateSource(s) {
179
+ if (s == null || s === "") return "";
180
+ if (typeof s !== "string") {
181
+ throw new TypeError("emailSuppressions: source must be a string");
182
+ }
183
+ var clean = s.toLowerCase().trim();
184
+ if (clean.length > MAX_SOURCE_LEN) {
185
+ throw new TypeError(
186
+ "emailSuppressions: source must be <= " + MAX_SOURCE_LEN + " chars"
187
+ );
188
+ }
189
+ if (!SOURCE_RE.test(clean)) {
190
+ throw new TypeError(
191
+ "emailSuppressions: source must match /[a-z0-9][a-z0-9._-]*[a-z0-9]/"
192
+ );
193
+ }
194
+ return clean;
195
+ }
196
+
197
+ function _validateExpiresAt(ts) {
198
+ if (ts == null) return null;
199
+ if (typeof ts !== "number" || !Number.isInteger(ts) || ts <= 0) {
200
+ throw new TypeError(
201
+ "emailSuppressions: expires_at must be a positive integer epoch-ms or null"
202
+ );
203
+ }
204
+ return ts;
205
+ }
206
+
207
+ function _validateEmailHash(h) {
208
+ if (typeof h !== "string" || !h.length) {
209
+ throw new TypeError("emailSuppressions: email_hash must be a non-empty string");
210
+ }
211
+ // `b.crypto.namespaceHash` returns a hex-encoded SHA3-512 — 128
212
+ // hex characters. Refuse anything outside that shape so a
213
+ // hand-crafted lookup can't smuggle SQL through the parameter.
214
+ if (!/^[0-9a-f]{128}$/.test(h)) {
215
+ throw new TypeError(
216
+ "emailSuppressions: email_hash must be 128 lowercase hex characters"
217
+ );
218
+ }
219
+ return h;
220
+ }
221
+
222
+ function _validateLimit(n) {
223
+ if (n == null) return DEFAULT_LIST_LIMIT;
224
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
225
+ throw new TypeError(
226
+ "emailSuppressions: limit must be 1..." + MAX_LIST_LIMIT
227
+ );
228
+ }
229
+ return n;
230
+ }
231
+
232
+ function _validateRange(from, to) {
233
+ if (from != null && (typeof from !== "number" || !Number.isInteger(from) || from < 0)) {
234
+ throw new TypeError("emailSuppressions: from must be a non-negative integer epoch-ms or null");
235
+ }
236
+ if (to != null && (typeof to !== "number" || !Number.isInteger(to) || to < 0)) {
237
+ throw new TypeError("emailSuppressions: to must be a non-negative integer epoch-ms or null");
238
+ }
239
+ if (from != null && to != null && from > to) {
240
+ throw new TypeError("emailSuppressions: from must be <= to");
241
+ }
242
+ }
243
+
244
+ // ---- scope filter --------------------------------------------------------
245
+
246
+ // Build the WHERE clause that selects rows whose scope blocks the
247
+ // caller's requested scope. The hierarchy is:
248
+ // caller asks scope='transactional' → blocked by row.scope in ('all', 'transactional')
249
+ // caller asks scope='marketing' → blocked by row.scope in ('all', 'marketing')
250
+ // caller asks scope='all' → blocked by any row (all three)
251
+ // Returns `{ sql, params }` with positional placeholders starting at
252
+ // `nextIdx` so the caller can slot the fragment into a larger query.
253
+ function _scopeFilterScopes(scope) {
254
+ if (scope === "transactional") return ["all", "transactional"];
255
+ if (scope === "marketing") return ["all", "marketing"];
256
+ return ["all", "marketing", "transactional"];
257
+ }
258
+
259
+ // ---- factory ------------------------------------------------------------
260
+
261
+ function create(opts) {
262
+ opts = opts || {};
263
+ var query = opts.query;
264
+ if (!query) {
265
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
266
+ }
267
+ // Pagination cursors are HMAC-tagged so an operator can't
268
+ // hand-craft one to skip past a hidden row. Dev default keeps the
269
+ // primitive bootable in tests; the deployment supplies a derived
270
+ // secret (typically
271
+ // `b.crypto.namespaceHash("email-suppressions-cursor",
272
+ // D1_BRIDGE_SECRET)`).
273
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
274
+ if (process.env.NODE_ENV === "production") {
275
+ throw new Error(
276
+ "emailSuppressions.create: opts.cursorSecret is required in production"
277
+ );
278
+ }
279
+ opts.cursorSecret = "email-suppressions-cursor-dev-only";
280
+ }
281
+ var cursorSecret = opts.cursorSecret;
282
+
283
+ return {
284
+ EMAIL_NAMESPACE: EMAIL_NAMESPACE,
285
+ SUPPRESSION_TYPES: SUPPRESSION_TYPES,
286
+ SCOPES: SCOPES,
287
+
288
+ // Insert or upsert a suppression. Re-occurrence (same email_hash)
289
+ // bumps `occurrences` + `last_seen_at`, refreshes `reason` /
290
+ // `source` / `expires_at`, and keeps `first_seen_at` sticky.
291
+ // Returns `{ email_hash, email_normalized, suppression_type,
292
+ // scope, occurrences, status: 'new' | 'updated' }`.
293
+ add: async function (input) {
294
+ if (!input || typeof input !== "object") {
295
+ throw new TypeError("emailSuppressions.add: input object required");
296
+ }
297
+ var emailNormalized = _normalizeEmail(input.email);
298
+ var type = _validateType(input.suppression_type);
299
+ var scope = input.scope == null
300
+ ? _defaultScopeFor(type)
301
+ : _validateScope(input.scope, "scope");
302
+ var reason = _validateReason(input.reason);
303
+ var source = _validateSource(input.source);
304
+ var expiresAt = _validateExpiresAt(input.expires_at);
305
+ var emailHash = _b().crypto.namespaceHash(EMAIL_NAMESPACE, emailNormalized);
306
+ var now = Date.now();
307
+
308
+ var existing = (await query(
309
+ "SELECT email_hash, first_seen_at, occurrences " +
310
+ "FROM email_suppressions WHERE email_hash = ?1 LIMIT 1",
311
+ [emailHash],
312
+ )).rows[0];
313
+
314
+ if (existing) {
315
+ var nextCount = Number(existing.occurrences || 0) + 1;
316
+ await query(
317
+ "UPDATE email_suppressions SET " +
318
+ "email_normalized = ?1, suppression_type = ?2, scope = ?3, " +
319
+ "reason = ?4, source = ?5, last_seen_at = ?6, expires_at = ?7, " +
320
+ "occurrences = ?8 " +
321
+ "WHERE email_hash = ?9",
322
+ [
323
+ emailNormalized, type, scope, reason, source, now, expiresAt,
324
+ nextCount, emailHash,
325
+ ],
326
+ );
327
+ return {
328
+ email_hash: emailHash,
329
+ email_normalized: emailNormalized,
330
+ suppression_type: type,
331
+ scope: scope,
332
+ occurrences: nextCount,
333
+ status: "updated",
334
+ };
335
+ }
336
+
337
+ await query(
338
+ "INSERT INTO email_suppressions " +
339
+ "(email_hash, email_normalized, suppression_type, scope, " +
340
+ "reason, source, first_seen_at, last_seen_at, expires_at, " +
341
+ "occurrences) " +
342
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?8, 1)",
343
+ [
344
+ emailHash, emailNormalized, type, scope, reason, source,
345
+ now, expiresAt,
346
+ ],
347
+ );
348
+ return {
349
+ email_hash: emailHash,
350
+ email_normalized: emailNormalized,
351
+ suppression_type: type,
352
+ scope: scope,
353
+ occurrences: 1,
354
+ status: "new",
355
+ };
356
+ },
357
+
358
+ // Returns `{ suppressed: boolean, suppression_type?, scope?,
359
+ // reason?, expires_at? }`. Scope defaults to 'all' — the most
360
+ // restrictive view, which surfaces any active row. Expired
361
+ // soft-bounces (expires_at < now) don't suppress.
362
+ isSuppressed: async function (input) {
363
+ if (!input || typeof input !== "object") {
364
+ throw new TypeError("emailSuppressions.isSuppressed: input object required");
365
+ }
366
+ var emailNormalized = _normalizeEmail(input.email);
367
+ var scope = input.scope == null
368
+ ? "all"
369
+ : _validateScope(input.scope, "scope");
370
+ var emailHash = _b().crypto.namespaceHash(EMAIL_NAMESPACE, emailNormalized);
371
+ var now = Date.now();
372
+ var allowedScopes = _scopeFilterScopes(scope);
373
+ // SQLite parameter slots — bind scope list dynamically. Two-or-
374
+ // three element list, so a small inline expansion is safer than
375
+ // a generic IN-clause builder.
376
+ var sql, params;
377
+ if (allowedScopes.length === 3) {
378
+ sql = "SELECT suppression_type, scope, reason, expires_at " +
379
+ "FROM email_suppressions " +
380
+ "WHERE email_hash = ?1 AND scope IN (?2, ?3, ?4) " +
381
+ "AND (expires_at IS NULL OR expires_at > ?5) " +
382
+ "LIMIT 1";
383
+ params = [emailHash, allowedScopes[0], allowedScopes[1], allowedScopes[2], now];
384
+ } else {
385
+ sql = "SELECT suppression_type, scope, reason, expires_at " +
386
+ "FROM email_suppressions " +
387
+ "WHERE email_hash = ?1 AND scope IN (?2, ?3) " +
388
+ "AND (expires_at IS NULL OR expires_at > ?4) " +
389
+ "LIMIT 1";
390
+ params = [emailHash, allowedScopes[0], allowedScopes[1], now];
391
+ }
392
+ var row = (await query(sql, params)).rows[0];
393
+ if (!row) return { suppressed: false };
394
+ return {
395
+ suppressed: true,
396
+ suppression_type: row.suppression_type,
397
+ scope: row.scope,
398
+ reason: row.reason,
399
+ expires_at: row.expires_at == null ? null : Number(row.expires_at),
400
+ };
401
+ },
402
+
403
+ // Operator manual override — removes the suppression row. Useful
404
+ // when a customer complaint was a mistake or a hard-bounce
405
+ // re-resolved (the recipient fixed their MX). The `reason`
406
+ // parameter is mandatory so the operator can't accidentally drop
407
+ // rows without recording the rationale; the deletion event is
408
+ // not persisted by this primitive (operators wire an audit sink
409
+ // at the route layer if they need it).
410
+ remove: async function (emailHash, opts2) {
411
+ _validateEmailHash(emailHash);
412
+ if (!opts2 || typeof opts2 !== "object") {
413
+ throw new TypeError("emailSuppressions.remove: opts.reason required");
414
+ }
415
+ if (typeof opts2.reason !== "string" || !opts2.reason.length) {
416
+ throw new TypeError("emailSuppressions.remove: opts.reason must be a non-empty string");
417
+ }
418
+ _validateReason(opts2.reason);
419
+ var r = await query(
420
+ "DELETE FROM email_suppressions WHERE email_hash = ?1",
421
+ [emailHash],
422
+ );
423
+ return { removed: r.rowCount > 0, email_hash: emailHash };
424
+ },
425
+
426
+ // Operator lookup by hash — used by the dashboard when a support
427
+ // ticket includes the address but the agent needs the full row
428
+ // (reason, source, occurrences, first_seen_at, expires_at).
429
+ byHash: async function (emailHash) {
430
+ _validateEmailHash(emailHash);
431
+ var r = await query(
432
+ "SELECT email_hash, email_normalized, suppression_type, scope, " +
433
+ "reason, source, first_seen_at, last_seen_at, expires_at, " +
434
+ "occurrences " +
435
+ "FROM email_suppressions WHERE email_hash = ?1 LIMIT 1",
436
+ [emailHash],
437
+ );
438
+ return r.rows[0] || null;
439
+ },
440
+
441
+ // Operator dashboard surface. Sorted (last_seen_at DESC, email_hash
442
+ // DESC) so the most-recent activity surfaces first. The cursor is
443
+ // HMAC-tagged via b.pagination — same shape as catalog.products.list
444
+ // and order.listForCustomer.
445
+ list: async function (listOpts) {
446
+ listOpts = listOpts || {};
447
+ var limit = _validateLimit(listOpts.limit);
448
+ var type = null;
449
+ if (listOpts.suppression_type != null) {
450
+ type = _validateType(listOpts.suppression_type);
451
+ }
452
+ var scope = null;
453
+ if (listOpts.scope != null) {
454
+ scope = _validateScope(listOpts.scope, "scope");
455
+ }
456
+ var cursorVals = null;
457
+ if (listOpts.cursor != null) {
458
+ if (typeof listOpts.cursor !== "string") {
459
+ throw new TypeError("emailSuppressions.list: cursor must be an opaque string or null");
460
+ }
461
+ try {
462
+ var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
463
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(LIST_ORDER_KEY)) {
464
+ throw new TypeError("emailSuppressions.list: cursor orderKey mismatch");
465
+ }
466
+ cursorVals = state.vals;
467
+ } catch (e) {
468
+ if (e instanceof TypeError) throw e;
469
+ throw new TypeError("emailSuppressions.list: cursor — " + (e && e.message || "malformed"));
470
+ }
471
+ }
472
+ // Build the query incrementally — base columns + sort, then
473
+ // append filters and the cursor tuple comparison.
474
+ var where = [];
475
+ var params = [];
476
+ var p = 1;
477
+ if (type) {
478
+ where.push("suppression_type = ?" + p);
479
+ params.push(type);
480
+ p += 1;
481
+ }
482
+ if (scope) {
483
+ where.push("scope = ?" + p);
484
+ params.push(scope);
485
+ p += 1;
486
+ }
487
+ if (cursorVals) {
488
+ where.push("(last_seen_at < ?" + p + " OR (last_seen_at = ?" + p + " AND email_hash < ?" + (p + 1) + "))");
489
+ params.push(cursorVals[0]);
490
+ params.push(cursorVals[1]);
491
+ p += 2;
492
+ }
493
+ var sql = "SELECT email_hash, email_normalized, suppression_type, scope, " +
494
+ "reason, source, first_seen_at, last_seen_at, expires_at, " +
495
+ "occurrences FROM email_suppressions" +
496
+ (where.length ? " WHERE " + where.join(" AND ") : "") +
497
+ " ORDER BY last_seen_at DESC, email_hash DESC LIMIT ?" + p;
498
+ params.push(limit);
499
+
500
+ var rows = (await query(sql, params)).rows;
501
+ var last = rows[rows.length - 1];
502
+ var next = null;
503
+ if (last && rows.length === limit) {
504
+ next = _b().pagination.encodeCursor({
505
+ orderKey: LIST_ORDER_KEY,
506
+ vals: [last.last_seen_at, last.email_hash],
507
+ forward: true,
508
+ }, cursorSecret);
509
+ }
510
+ return { rows: rows, next_cursor: next };
511
+ },
512
+
513
+ // Purge rows whose expires_at < ts (default: now). Returns the
514
+ // number of rows removed. Soft-bounces and rate-limit-blocks are
515
+ // the typical inhabitants; permanent rows (expires_at IS NULL)
516
+ // are never touched.
517
+ cleanupExpired: async function (ts) {
518
+ var cutoff;
519
+ if (ts == null) {
520
+ cutoff = Date.now();
521
+ } else {
522
+ if (typeof ts !== "number" || !Number.isInteger(ts) || ts < 0) {
523
+ throw new TypeError("emailSuppressions.cleanupExpired: ts must be a non-negative integer epoch-ms or null");
524
+ }
525
+ cutoff = ts;
526
+ }
527
+ var r = await query(
528
+ "DELETE FROM email_suppressions WHERE expires_at IS NOT NULL AND expires_at < ?1",
529
+ [cutoff],
530
+ );
531
+ return { removed: r.rowCount };
532
+ },
533
+
534
+ // Aggregate counts grouped by suppression_type, optionally
535
+ // bounded by [from, to] against last_seen_at. Operator
536
+ // dashboards render the breakdown so the bounce-rate trend is
537
+ // visible without a separate analytics primitive.
538
+ stats: async function (opts3) {
539
+ opts3 = opts3 || {};
540
+ _validateRange(opts3.from, opts3.to);
541
+ var where = [];
542
+ var params = [];
543
+ var p = 1;
544
+ if (opts3.from != null) {
545
+ where.push("last_seen_at >= ?" + p);
546
+ params.push(opts3.from);
547
+ p += 1;
548
+ }
549
+ if (opts3.to != null) {
550
+ where.push("last_seen_at <= ?" + p);
551
+ params.push(opts3.to);
552
+ p += 1;
553
+ }
554
+ var sql = "SELECT suppression_type, COUNT(*) AS n " +
555
+ "FROM email_suppressions" +
556
+ (where.length ? " WHERE " + where.join(" AND ") : "") +
557
+ " GROUP BY suppression_type";
558
+ var rows = (await query(sql, params)).rows;
559
+ // Surface the full type set so an operator dashboard renders
560
+ // zeros instead of "missing key" — easier to spot a trend
561
+ // reversal when the row is present at 0 than when it's absent.
562
+ var out = {};
563
+ for (var i = 0; i < SUPPRESSION_TYPES.length; i += 1) {
564
+ out[SUPPRESSION_TYPES[i]] = 0;
565
+ }
566
+ for (var j = 0; j < rows.length; j += 1) {
567
+ out[rows[j].suppression_type] = Number(rows[j].n || 0);
568
+ }
569
+ return out;
570
+ },
571
+ };
572
+ }
573
+
574
+ module.exports = {
575
+ create: create,
576
+ EMAIL_NAMESPACE: EMAIL_NAMESPACE,
577
+ SUPPRESSION_TYPES: SUPPRESSION_TYPES,
578
+ SCOPES: SCOPES,
579
+ };