@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.
- package/CHANGELOG.md +6 -0
- package/lib/addresses.js +430 -0
- package/lib/analytics.js +400 -0
- package/lib/cart-abandonment.js +664 -0
- package/lib/currency-display.js +432 -0
- package/lib/email-suppressions.js +579 -0
- package/lib/email.js +264 -0
- package/lib/index.js +14 -0
- package/lib/inventory-receive.js +494 -0
- package/lib/loyalty.js +496 -0
- package/lib/newsletter.js +176 -12
- package/lib/notifications.js +474 -0
- package/lib/order-tracking.js +456 -0
- package/lib/payment.js +193 -13
- package/lib/referrals.js +649 -0
- package/lib/returns.js +627 -0
- package/lib/reviews.js +412 -0
- package/lib/search-suggestions.js +528 -0
- package/lib/tax-exempt.js +519 -0
- package/lib/tax.js +391 -3
- package/lib/vendor/MANIFEST.json +1 -1
- package/lib/webhooks.js +293 -16
- package/lib/wishlist.js +269 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|