@aglyn/tenant-data-admin 1.0.0-beta.203 → 1.0.0-beta.204

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.
Files changed (48) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/capture-email-check.d.ts +109 -0
  3. package/src/lib/server/capture-email-check.js +362 -0
  4. package/src/lib/server/capture-email-check.js.map +1 -0
  5. package/src/lib/server/card-payment-velocity.d.ts +11 -16
  6. package/src/lib/server/card-payment-velocity.js +24 -28
  7. package/src/lib/server/card-payment-velocity.js.map +1 -1
  8. package/src/lib/server/email-deliverability.d.ts +19 -3
  9. package/src/lib/server/email-deliverability.js +34 -16
  10. package/src/lib/server/email-deliverability.js.map +1 -1
  11. package/src/lib/server/held-page-subject.d.ts +93 -0
  12. package/src/lib/server/held-page-subject.js +206 -0
  13. package/src/lib/server/held-page-subject.js.map +1 -0
  14. package/src/lib/server/host-email-tokens.d.ts +42 -1
  15. package/src/lib/server/host-email-tokens.js +100 -12
  16. package/src/lib/server/host-email-tokens.js.map +1 -1
  17. package/src/lib/server/host-visitor-records.js +21 -5
  18. package/src/lib/server/host-visitor-records.js.map +1 -1
  19. package/src/lib/server/hosted-page-review.d.ts +16 -5
  20. package/src/lib/server/hosted-page-review.js +242 -61
  21. package/src/lib/server/hosted-page-review.js.map +1 -1
  22. package/src/lib/server/list-members.js +16 -0
  23. package/src/lib/server/list-members.js.map +1 -1
  24. package/src/lib/server/outbound-send-review.d.ts +22 -0
  25. package/src/lib/server/outbound-send-review.js +74 -6
  26. package/src/lib/server/outbound-send-review.js.map +1 -1
  27. package/src/lib/server/payment-fraud-signal.d.ts +20 -12
  28. package/src/lib/server/payment-fraud-signal.js +41 -11
  29. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  30. package/src/lib/server/payment-risk-record.d.ts +37 -13
  31. package/src/lib/server/payment-risk-record.js +23 -8
  32. package/src/lib/server/payment-risk-record.js.map +1 -1
  33. package/src/lib/server/render-system-email.d.ts +3 -0
  34. package/src/lib/server/render-system-email.js +39 -11
  35. package/src/lib/server/render-system-email.js.map +1 -1
  36. package/src/lib/server/risk-notice.d.ts +327 -0
  37. package/src/lib/server/risk-notice.js +1240 -0
  38. package/src/lib/server/risk-notice.js.map +1 -0
  39. package/src/lib/server/sender-readiness.d.ts +36 -0
  40. package/src/lib/server/sender-readiness.js +99 -0
  41. package/src/lib/server/sender-readiness.js.map +1 -0
  42. package/src/lib/server/test-firestore.js +1 -0
  43. package/src/lib/server/test-firestore.js.map +1 -1
  44. package/src/lib/server/upsert-contact.js +19 -3
  45. package/src/lib/server/upsert-contact.js.map +1 -1
  46. package/src/tenancy.d.ts +2 -0
  47. package/src/tenancy.js +3 -0
  48. package/src/tenancy.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-data-admin",
3
- "version": "1.0.0-beta.203",
3
+ "version": "1.0.0-beta.204",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,12 +25,12 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.203",
29
- "@aglyn/shared-util-email": "1.0.0-beta.203",
30
- "@aglyn/shared-util-fbserver": "1.0.0-beta.203",
31
- "@aglyn/shared-util-first-touch": "1.0.0-beta.203",
32
- "@aglyn/shared-util-http": "1.0.0-beta.203",
33
- "@aglyn/shared-util-tools": "1.0.0-beta.203",
28
+ "@aglyn/aglyn": "1.0.0-beta.204",
29
+ "@aglyn/shared-util-email": "1.0.0-beta.204",
30
+ "@aglyn/shared-util-fbserver": "1.0.0-beta.204",
31
+ "@aglyn/shared-util-first-touch": "1.0.0-beta.204",
32
+ "@aglyn/shared-util-http": "1.0.0-beta.204",
33
+ "@aglyn/shared-util-tools": "1.0.0-beta.204",
34
34
  "@msgpack/msgpack": "^3.1.3",
35
35
  "@swc/helpers": "0.5.23",
36
36
  "sharp": "^0.35.3",
@@ -0,0 +1,109 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type EmailDeliverabilityCode, type MailGateway, type MailGatewayChip } from '@aglyn/shared-util-email';
18
+ import { type MailDeliverabilityDeps } from './email-deliverability';
19
+ /** One captured address, and whose records it lands on. */
20
+ export interface CapturedEmail {
21
+ /** The organization whose records carry it; or `hostId`, which the writer reads the organization off. */
22
+ orgId?: string | null;
23
+ hostId?: string | null;
24
+ email: string;
25
+ /**
26
+ * The record already holds the check's own `undeliverable` verdict: a
27
+ * domain that answers now withdraws it, and one that still takes no mail
28
+ * writes nothing again.
29
+ */
30
+ predicted?: boolean;
31
+ }
32
+ /** What one run of the check did. */
33
+ export interface CapturedEmailCheckReport {
34
+ /** Addresses whose domain was answered for. */
35
+ checked: number;
36
+ /** Addresses whose domain takes no mail. */
37
+ undeliverable: number;
38
+ /** Records the seam moved to "Would bounce". */
39
+ stamped: number;
40
+ /** Records whose "Would bounce" was withdrawn. */
41
+ withdrawn: number;
42
+ }
43
+ /**
44
+ * How long an import waits, past its last write, for its addresses' mail
45
+ * servers before it answers without the count (see `settleWithin`).
46
+ */
47
+ export declare const IMPORT_MAIL_CHECK_GRACE_MS = 5000;
48
+ /**
49
+ * Checks the addresses and writes what was found onto their records: "Would
50
+ * bounce" for a domain that takes no mail, the withdrawal of a standing
51
+ * "Would bounce" for one that does again. Never throws — the capture has
52
+ * already happened, and a record left unstamped is what the backfill is for.
53
+ */
54
+ export declare function checkCapturedEmails(items: readonly CapturedEmail[], deps?: MailDeliverabilityDeps): Promise<CapturedEmailCheckReport>;
55
+ /** Runs whatever is queued. Exported for the spec; production reaches it through `after()`. */
56
+ export declare function flushCapturedEmailChecks(deps?: MailDeliverabilityDeps): Promise<CapturedEmailCheckReport>;
57
+ /**
58
+ * Queues one captured address for the check, which runs once the response
59
+ * has been sent — see the module note. Returns at once, never throws, and
60
+ * costs the caller nothing but the push.
61
+ */
62
+ export declare function scheduleCapturedEmailCheck(item: CapturedEmail): void;
63
+ /** Test seam: drop anything queued. */
64
+ export declare function resetCapturedEmailChecksForTests(): void;
65
+ /** What is queued, for the spec. */
66
+ export declare function queuedCapturedEmailsForTests(): readonly CapturedEmail[];
67
+ /**
68
+ * Which of these addresses (normalized) have a domain that takes no mail,
69
+ * from the same cache the check writes — what an import's result counts.
70
+ * Never rejects: `null` when the answer could not be had, and the import
71
+ * says nothing rather than something untrue.
72
+ */
73
+ export declare function findUndeliverableEmails(emails: readonly string[], deps?: MailDeliverabilityDeps): Promise<Set<string> | null>;
74
+ /**
75
+ * `promise`'s answer, or `null` when it has not arrived within `ms`. An
76
+ * import starts its lookups with its first row and waits this long past its
77
+ * last write for them; a slower answer still stamps the records, through the
78
+ * check the writes queued, and only the count is left unsaid.
79
+ */
80
+ export declare function settleWithin<T>(promise: Promise<T>, ms: number): Promise<T | null>;
81
+ /** How many of `emails` a {@link findUndeliverableEmails} answer names; `undefined` without an answer. */
82
+ export declare function countUndeliverableEmails(emails: readonly string[], undeliverable: ReadonlySet<string> | null): number | undefined;
83
+ /** What a surface shows for one address before somebody writes to it. */
84
+ export interface AddressDeliverability {
85
+ /** The address, normalized; `null` when it is not one. */
86
+ email: string | null;
87
+ /** Whether the domain's MX was read; `false` means nothing is known, and nothing is warned. */
88
+ checked: boolean;
89
+ /** The finding a composer warns with — a domain with no mail server, or a gateway holding this sender. */
90
+ code: Extract<EmailDeliverabilityCode, 'no_mx' | 'null_mx' | 'gateway_held'> | null;
91
+ /** That finding in a sentence, as the engine words it. */
92
+ message: string | null;
93
+ /** The gateway in front of the address, when its MX names one. */
94
+ gateway: MailGateway | null;
95
+ /** The chip beside the address: the gateway, and what it did with this sender's mail this week. */
96
+ chip: MailGatewayChip | null;
97
+ }
98
+ /**
99
+ * One address's standing for the site mail would leave from: the platform
100
+ * MX cache (looked up, server side, when cold) and the sending domain's
101
+ * gateway ledger. Read, never written onto a record — a composer asks it
102
+ * before a send and a record page before it draws the address. `from` is
103
+ * the sending identity's address; without one, no ledger is read and
104
+ * nothing is held.
105
+ */
106
+ export declare function readAddressDeliverability(input: {
107
+ email: string;
108
+ from?: string | null;
109
+ }, deps?: MailDeliverabilityDeps): Promise<AddressDeliverability>;
@@ -0,0 +1,362 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /*==========================================
17
+ * THE DELIVERABILITY CHECK AT CAPTURE (AGL-3328).
18
+ *
19
+ * A person arrives — a form, an import, the New lead drawer, `POST
20
+ * /v1/contacts`, a list add — and the address they gave is looked at
21
+ * before anybody writes to it: the domain's MX, from the platform cache
22
+ * `email-deliverability.ts` keeps. A domain that takes no mail (no MX, or
23
+ * RFC 7505's null MX) puts `undeliverable` — "Would bounce" — on every
24
+ * record the address is, through the core's record email-state seam, so the
25
+ * record a person opens says so and the lists' `emailStatus` filter finds
26
+ * it.
27
+ *
28
+ * ## Never on the write's time
29
+ *
30
+ * The writers that call this — `addHostLead`, `upsertHostContact`,
31
+ * `enrollListMember`, the API's contact create — have finished their own
32
+ * write when they do, and they do not wait: {@link scheduleCapturedEmailCheck}
33
+ * queues the address and runs the queue in `after()`, once the response
34
+ * has gone. One flush per invocation, however many rows an import wrote,
35
+ * so a file of five hundred people from forty domains asks forty domains.
36
+ *
37
+ * `after()` rather than a bare promise (AGL-2327): a serverless invocation
38
+ * is frozen the moment its response is sent, and work scheduled any other
39
+ * way does not run. Outside a request — a script, a spec — there is nothing
40
+ * to defer to, and nothing is checked; the backfill covers what those
41
+ * write.
42
+ *
43
+ * ## The check's own verdict, and only that, is taken back
44
+ *
45
+ * `undeliverable` is the weakest refusal (`email-state.ts`): any real bounce
46
+ * or block replaces it. The check withdraws it when the domain takes mail
47
+ * again — a typo'd domain somebody since registered, a company that set up
48
+ * its mail — and only when the record's standing verdict is that one; a
49
+ * bounce is never cleared by DNS. A writer that knows the record held it
50
+ * says so (`predicted`), which is what lets every other capture skip the
51
+ * read a withdrawal costs.
52
+ *==========================================*/ import { _ as _extends } from "@swc/helpers/_/_extends";
53
+ import { isPublicMailboxDomain } from "@aglyn/aglyn/app-utils/crm";
54
+ import { stampRecordEmailState } from "@aglyn/aglyn/plugin-manager/plugin-record-email-state";
55
+ import { bareSenderAddress, decidingDeliverabilityFinding, mailDomainRefusesMail, mailGatewayChip, mailGatewayStanding, normalizeDeliverabilityEmail } from "@aglyn/shared-util-email";
56
+ import { isGatewayHeldVerdict, isNoMailServerVerdict, readMailDeliverability } from "./email-deliverability.js";
57
+ /**
58
+ * How long an import waits, past its last write, for its addresses' mail
59
+ * servers before it answers without the count (see `settleWithin`).
60
+ */ export const IMPORT_MAIL_CHECK_GRACE_MS = 5000;
61
+ /** The seam's writes in flight at once. */ const STAMP_CONCURRENCY = 8;
62
+ /** Addresses one invocation may hold for its flush; past it, the backfill catches up. */ const QUEUE_MAX = 20000;
63
+ /** Runs `work` over `items`, at most `limit` at a time. */ async function eachLimited(items, limit, work) {
64
+ let next = 0;
65
+ const runners = Array.from({
66
+ length: Math.min(limit, items.length)
67
+ }, async ()=>{
68
+ while(next < items.length){
69
+ const item = items[next];
70
+ next += 1;
71
+ await work(item);
72
+ }
73
+ });
74
+ await Promise.all(runners);
75
+ }
76
+ /**
77
+ * The detail a "Would bounce" carries. A missing mail server is what the
78
+ * chip already says; a null MX is the domain saying it outright, which is
79
+ * worth the words.
80
+ */ function undeliverableDetail(verdict) {
81
+ var _verdict_intel;
82
+ return ((_verdict_intel = verdict.intel) == null ? void 0 : _verdict_intel.status) === 'null_mx' ? `${verdict.domain} publishes a null MX record: it says it accepts no email.` : null;
83
+ }
84
+ /** The address a verdict answers for, as the seam keys it. */ function stampTarget(item) {
85
+ var _item_orgId, _item_hostId;
86
+ const orgId = String((_item_orgId = item.orgId) != null ? _item_orgId : '').trim();
87
+ if (orgId) return {
88
+ orgId
89
+ };
90
+ const hostId = String((_item_hostId = item.hostId) != null ? _item_hostId : '').trim();
91
+ return hostId ? {
92
+ hostId
93
+ } : null;
94
+ }
95
+ /**
96
+ * Checks the addresses and writes what was found onto their records: "Would
97
+ * bounce" for a domain that takes no mail, the withdrawal of a standing
98
+ * "Would bounce" for one that does again. Never throws — the capture has
99
+ * already happened, and a record left unstamped is what the backfill is for.
100
+ */ export async function checkCapturedEmails(items, deps = {}) {
101
+ var _deps_nowMs;
102
+ const report = {
103
+ checked: 0,
104
+ undeliverable: 0,
105
+ stamped: 0,
106
+ withdrawn: 0
107
+ };
108
+ // One entry per record set: the same address captured twice for one
109
+ // organization in one flush is asked about once, and a `predicted` on
110
+ // either capture stands.
111
+ const unique = new Map();
112
+ for (const item of items){
113
+ var _target_orgId;
114
+ const email = normalizeDeliverabilityEmail(item == null ? void 0 : item.email);
115
+ const target = item ? stampTarget(item) : null;
116
+ if (!email || !target) continue;
117
+ const key = `${(_target_orgId = target.orgId) != null ? _target_orgId : `host:${target.hostId}`}|${email}`;
118
+ const held = unique.get(key);
119
+ unique.set(key, _extends({}, target, {
120
+ email,
121
+ predicted: Boolean((held == null ? void 0 : held.predicted) || item.predicted)
122
+ }));
123
+ }
124
+ if (!unique.size) return report;
125
+ const nowMs = (_deps_nowMs = deps.nowMs) != null ? _deps_nowMs : Date.now();
126
+ let verdicts;
127
+ try {
128
+ verdicts = await readMailDeliverability({
129
+ emails: [
130
+ ...new Set([
131
+ ...unique.values()
132
+ ].map((item)=>item.email))
133
+ ],
134
+ purpose: 'bulk',
135
+ sendingDomain: null,
136
+ scope: null
137
+ }, _extends({}, deps, {
138
+ nowMs
139
+ }));
140
+ } catch (error) {
141
+ console.error('[deliverability] captured addresses could not be checked', error);
142
+ return report;
143
+ }
144
+ const writes = [];
145
+ for (const item of unique.values()){
146
+ var _item_hostId;
147
+ const verdict = verdicts.get(item.email);
148
+ if (!verdict || verdict.outcome === 'unchecked') continue;
149
+ report.checked += 1;
150
+ const target = _extends({}, item.orgId ? {
151
+ orgId: item.orgId
152
+ } : {
153
+ hostId: (_item_hostId = item.hostId) != null ? _item_hostId : ''
154
+ });
155
+ if (isNoMailServerVerdict(verdict)) {
156
+ report.undeliverable += 1;
157
+ if (item.predicted) continue;
158
+ const state = {
159
+ status: 'undeliverable',
160
+ atMs: nowMs,
161
+ source: 'check',
162
+ detail: undeliverableDetail(verdict)
163
+ };
164
+ writes.push(async ()=>{
165
+ var _ref;
166
+ const stamped = await stampRecordEmailState(_extends({}, target, {
167
+ email: item.email,
168
+ state
169
+ }));
170
+ report.stamped += (_ref = stamped == null ? void 0 : stamped.records) != null ? _ref : 0;
171
+ });
172
+ } else if (item.predicted && verdict.intel && !mailDomainRefusesMail(verdict.intel.status)) {
173
+ const state = {
174
+ status: 'undeliverable',
175
+ atMs: nowMs,
176
+ source: 'check',
177
+ detail: null
178
+ };
179
+ writes.push(async ()=>{
180
+ var _ref;
181
+ const withdrawn = await stampRecordEmailState(_extends({}, target, {
182
+ email: item.email,
183
+ state,
184
+ withdraw: true
185
+ }));
186
+ report.withdrawn += (_ref = withdrawn == null ? void 0 : withdrawn.records) != null ? _ref : 0;
187
+ });
188
+ }
189
+ }
190
+ await eachLimited(writes, STAMP_CONCURRENCY, (write)=>write());
191
+ return report;
192
+ }
193
+ /*==========================================
194
+ * THE QUEUE, FLUSHED AFTER THE RESPONSE
195
+ *==========================================*/ const queued = [];
196
+ let flushScheduled = false;
197
+ /**
198
+ * Next's `after()`, required when first asked for rather than imported:
199
+ * `next/server` evaluates web `Request` classes at load, which a jsdom spec
200
+ * reaching one of the writers cannot, and this module rides every writer's
201
+ * import. `null` where it cannot be loaded.
202
+ */ function afterResponse() {
203
+ try {
204
+ const loaded = require('next/server');
205
+ return typeof (loaded == null ? void 0 : loaded.after) === 'function' ? loaded.after : null;
206
+ } catch (unused) {
207
+ return null;
208
+ }
209
+ }
210
+ /** Runs whatever is queued. Exported for the spec; production reaches it through `after()`. */ export async function flushCapturedEmailChecks(deps = {}) {
211
+ flushScheduled = false;
212
+ const batch = queued.splice(0);
213
+ if (!batch.length) return {
214
+ checked: 0,
215
+ undeliverable: 0,
216
+ stamped: 0,
217
+ withdrawn: 0
218
+ };
219
+ return checkCapturedEmails(batch, deps);
220
+ }
221
+ /**
222
+ * Queues one captured address for the check, which runs once the response
223
+ * has been sent — see the module note. Returns at once, never throws, and
224
+ * costs the caller nothing but the push.
225
+ */ export function scheduleCapturedEmailCheck(item) {
226
+ try {
227
+ if (!item || !normalizeDeliverabilityEmail(item.email) || !stampTarget(item)) return;
228
+ if (queued.length >= QUEUE_MAX) return;
229
+ queued.push(_extends({}, item));
230
+ if (flushScheduled) return;
231
+ try {
232
+ const after = afterResponse();
233
+ if (!after) throw new Error('no request to run after');
234
+ after(()=>flushCapturedEmailChecks().then(()=>undefined));
235
+ flushScheduled = true;
236
+ } catch (unused) {
237
+ // No request to run after: a script or a spec. Nothing is checked.
238
+ queued.length = 0;
239
+ }
240
+ } catch (error) {
241
+ console.error('[deliverability] a captured address could not be queued', error);
242
+ }
243
+ }
244
+ /** Test seam: drop anything queued. */ export function resetCapturedEmailChecksForTests() {
245
+ queued.length = 0;
246
+ flushScheduled = false;
247
+ }
248
+ /** What is queued, for the spec. */ export function queuedCapturedEmailsForTests() {
249
+ return queued;
250
+ }
251
+ /*==========================================
252
+ * WHAT AN IMPORT REPORTS
253
+ *==========================================*/ /**
254
+ * Which of these addresses (normalized) have a domain that takes no mail,
255
+ * from the same cache the check writes — what an import's result counts.
256
+ * Never rejects: `null` when the answer could not be had, and the import
257
+ * says nothing rather than something untrue.
258
+ */ export async function findUndeliverableEmails(emails, deps = {}) {
259
+ const wanted = [
260
+ ...new Set(emails.map((email)=>normalizeDeliverabilityEmail(email)).filter((email)=>Boolean(email)))
261
+ ];
262
+ if (!wanted.length) return new Set();
263
+ try {
264
+ const verdicts = await readMailDeliverability({
265
+ emails: wanted,
266
+ purpose: 'bulk',
267
+ sendingDomain: null,
268
+ scope: null
269
+ }, deps);
270
+ const found = new Set();
271
+ for (const [email, verdict] of verdicts)if (isNoMailServerVerdict(verdict)) found.add(email);
272
+ return found;
273
+ } catch (error) {
274
+ console.error('[deliverability] an import’s addresses could not be checked', error);
275
+ return null;
276
+ }
277
+ }
278
+ /**
279
+ * `promise`'s answer, or `null` when it has not arrived within `ms`. An
280
+ * import starts its lookups with its first row and waits this long past its
281
+ * last write for them; a slower answer still stamps the records, through the
282
+ * check the writes queued, and only the count is left unsaid.
283
+ */ export async function settleWithin(promise, ms) {
284
+ let timer;
285
+ const late = new Promise((resolve)=>{
286
+ timer = setTimeout(()=>resolve(null), Math.max(0, ms));
287
+ });
288
+ try {
289
+ return await Promise.race([
290
+ promise,
291
+ late
292
+ ]);
293
+ } finally{
294
+ if (timer) clearTimeout(timer);
295
+ }
296
+ }
297
+ /** How many of `emails` a {@link findUndeliverableEmails} answer names; `undefined` without an answer. */ export function countUndeliverableEmails(emails, undeliverable) {
298
+ if (!undeliverable) return undefined;
299
+ let count = 0;
300
+ for (const email of new Set(emails.map((entry)=>normalizeDeliverabilityEmail(entry)))){
301
+ if (email && undeliverable.has(email)) count += 1;
302
+ }
303
+ return count;
304
+ }
305
+ /**
306
+ * One address's standing for the site mail would leave from: the platform
307
+ * MX cache (looked up, server side, when cold) and the sending domain's
308
+ * gateway ledger. Read, never written onto a record — a composer asks it
309
+ * before a send and a record page before it draws the address. `from` is
310
+ * the sending identity's address; without one, no ledger is read and
311
+ * nothing is held.
312
+ */ export async function readAddressDeliverability(input, deps = {}) {
313
+ var _input_from, _deps_nowMs, _verdict_gateway;
314
+ const email = normalizeDeliverabilityEmail(input.email);
315
+ const none = {
316
+ email,
317
+ checked: false,
318
+ code: null,
319
+ message: null,
320
+ gateway: null,
321
+ chip: null
322
+ };
323
+ if (!email) return none;
324
+ const sender = bareSenderAddress((_input_from = input.from) != null ? _input_from : null);
325
+ const sendingDomain = sender ? sender.slice(sender.lastIndexOf('@') + 1) : null;
326
+ const nowMs = (_deps_nowMs = deps.nowMs) != null ? _deps_nowMs : Date.now();
327
+ let verdict;
328
+ try {
329
+ verdict = (await readMailDeliverability({
330
+ emails: [
331
+ email
332
+ ],
333
+ purpose: 'bulk',
334
+ sendingDomain,
335
+ scope: null
336
+ }, _extends({}, deps, {
337
+ nowMs
338
+ }))).get(email);
339
+ } catch (error) {
340
+ console.error('[deliverability] an address could not be checked', error);
341
+ return none;
342
+ }
343
+ if (!(verdict == null ? void 0 : verdict.intel)) return none;
344
+ const deciding = decidingDeliverabilityFinding(verdict);
345
+ const warns = isNoMailServerVerdict(verdict) || isGatewayHeldVerdict(verdict);
346
+ const standing = (_verdict_gateway = verdict.gateway) != null ? _verdict_gateway : mailGatewayStanding(verdict.intel.gateway, null, nowMs);
347
+ let chip = mailGatewayChip(standing);
348
+ // A public mailbox provider's MX is the provider's, not a gateway the
349
+ // person's company chose: "Google Workspace" beside a gmail.com address
350
+ // says something untrue. It keeps a chip only for a verdict this week.
351
+ if ((chip == null ? void 0 : chip.tone) === 'neutral' && isPublicMailboxDomain(verdict.domain)) chip = null;
352
+ return {
353
+ email,
354
+ checked: true,
355
+ code: warns && deciding ? deciding.code : null,
356
+ message: warns && deciding ? deciding.message : null,
357
+ gateway: verdict.intel.gateway === 'none' ? null : verdict.intel.gateway,
358
+ chip
359
+ };
360
+ }
361
+
362
+ //# sourceMappingURL=capture-email-check.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/capture-email-check.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * THE DELIVERABILITY CHECK AT CAPTURE (AGL-3328).\n *\n * A person arrives — a form, an import, the New lead drawer, `POST\n * /v1/contacts`, a list add — and the address they gave is looked at\n * before anybody writes to it: the domain's MX, from the platform cache\n * `email-deliverability.ts` keeps. A domain that takes no mail (no MX, or\n * RFC 7505's null MX) puts `undeliverable` — \"Would bounce\" — on every\n * record the address is, through the core's record email-state seam, so the\n * record a person opens says so and the lists' `emailStatus` filter finds\n * it.\n *\n * ## Never on the write's time\n *\n * The writers that call this — `addHostLead`, `upsertHostContact`,\n * `enrollListMember`, the API's contact create — have finished their own\n * write when they do, and they do not wait: {@link scheduleCapturedEmailCheck}\n * queues the address and runs the queue in `after()`, once the response\n * has gone. One flush per invocation, however many rows an import wrote,\n * so a file of five hundred people from forty domains asks forty domains.\n *\n * `after()` rather than a bare promise (AGL-2327): a serverless invocation\n * is frozen the moment its response is sent, and work scheduled any other\n * way does not run. Outside a request — a script, a spec — there is nothing\n * to defer to, and nothing is checked; the backfill covers what those\n * write.\n *\n * ## The check's own verdict, and only that, is taken back\n *\n * `undeliverable` is the weakest refusal (`email-state.ts`): any real bounce\n * or block replaces it. The check withdraws it when the domain takes mail\n * again — a typo'd domain somebody since registered, a company that set up\n * its mail — and only when the record's standing verdict is that one; a\n * bounce is never cleared by DNS. A writer that knows the record held it\n * says so (`predicted`), which is what lets every other capture skip the\n * read a withdrawal costs.\n *==========================================*/\n\nimport type { EmailState } from '@aglyn/aglyn/app-utils/email-state'\nimport { isPublicMailboxDomain } from '@aglyn/aglyn/app-utils/crm'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport {\n bareSenderAddress,\n decidingDeliverabilityFinding,\n type EmailDeliverabilityCode,\n type EmailDeliverabilityVerdict,\n type MailGateway,\n type MailGatewayChip,\n mailDomainRefusesMail,\n mailGatewayChip,\n mailGatewayStanding,\n normalizeDeliverabilityEmail,\n} from '@aglyn/shared-util-email'\nimport {\n isGatewayHeldVerdict,\n isNoMailServerVerdict,\n type MailDeliverabilityDeps,\n readMailDeliverability,\n} from './email-deliverability'\n\n/** One captured address, and whose records it lands on. */\nexport interface CapturedEmail {\n /** The organization whose records carry it; or `hostId`, which the writer reads the organization off. */\n orgId?: string | null\n hostId?: string | null\n email: string\n /**\n * The record already holds the check's own `undeliverable` verdict: a\n * domain that answers now withdraws it, and one that still takes no mail\n * writes nothing again.\n */\n predicted?: boolean\n}\n\n/** What one run of the check did. */\nexport interface CapturedEmailCheckReport {\n /** Addresses whose domain was answered for. */\n checked: number\n /** Addresses whose domain takes no mail. */\n undeliverable: number\n /** Records the seam moved to \"Would bounce\". */\n stamped: number\n /** Records whose \"Would bounce\" was withdrawn. */\n withdrawn: number\n}\n\n/**\n * How long an import waits, past its last write, for its addresses' mail\n * servers before it answers without the count (see `settleWithin`).\n */\nexport const IMPORT_MAIL_CHECK_GRACE_MS = 5_000\n\n/** The seam's writes in flight at once. */\nconst STAMP_CONCURRENCY = 8\n\n/** Addresses one invocation may hold for its flush; past it, the backfill catches up. */\nconst QUEUE_MAX = 20_000\n\n/** Runs `work` over `items`, at most `limit` at a time. */\nasync function eachLimited<T>(items: readonly T[], limit: number, work: (item: T) => Promise<void>): Promise<void> {\n let next = 0\n const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {\n while (next < items.length) {\n const item = items[next]\n next += 1\n await work(item)\n }\n })\n await Promise.all(runners)\n}\n\n/**\n * The detail a \"Would bounce\" carries. A missing mail server is what the\n * chip already says; a null MX is the domain saying it outright, which is\n * worth the words.\n */\nfunction undeliverableDetail(verdict: EmailDeliverabilityVerdict): string | null {\n return verdict.intel?.status === 'null_mx'\n ? `${verdict.domain} publishes a null MX record: it says it accepts no email.`\n : null\n}\n\n/** The address a verdict answers for, as the seam keys it. */\nfunction stampTarget(item: CapturedEmail): { orgId?: string; hostId?: string } | null {\n const orgId = String(item.orgId ?? '').trim()\n if (orgId) return { orgId }\n const hostId = String(item.hostId ?? '').trim()\n return hostId ? { hostId } : null\n}\n\n/**\n * Checks the addresses and writes what was found onto their records: \"Would\n * bounce\" for a domain that takes no mail, the withdrawal of a standing\n * \"Would bounce\" for one that does again. Never throws — the capture has\n * already happened, and a record left unstamped is what the backfill is for.\n */\nexport async function checkCapturedEmails(\n items: readonly CapturedEmail[],\n deps: MailDeliverabilityDeps = {},\n): Promise<CapturedEmailCheckReport> {\n const report: CapturedEmailCheckReport = { checked: 0, undeliverable: 0, stamped: 0, withdrawn: 0 }\n // One entry per record set: the same address captured twice for one\n // organization in one flush is asked about once, and a `predicted` on\n // either capture stands.\n const unique = new Map<string, CapturedEmail & { email: string }>()\n for (const item of items) {\n const email = normalizeDeliverabilityEmail(item?.email)\n const target = item ? stampTarget(item) : null\n if (!email || !target) continue\n const key = `${target.orgId ?? `host:${target.hostId}`}|${email}`\n const held = unique.get(key)\n unique.set(key, { ...target, email, predicted: Boolean(held?.predicted || item.predicted) })\n }\n if (!unique.size) return report\n\n const nowMs = deps.nowMs ?? Date.now()\n let verdicts: Map<string, EmailDeliverabilityVerdict>\n try {\n verdicts = await readMailDeliverability(\n {\n emails: [...new Set([...unique.values()].map((item) => item.email))],\n purpose: 'bulk',\n sendingDomain: null,\n scope: null,\n },\n { ...deps, nowMs },\n )\n } catch (error) {\n console.error('[deliverability] captured addresses could not be checked', error)\n return report\n }\n\n const writes: Array<() => Promise<void>> = []\n for (const item of unique.values()) {\n const verdict = verdicts.get(item.email)\n if (!verdict || verdict.outcome === 'unchecked') continue\n report.checked += 1\n const target = { ...(item.orgId ? { orgId: item.orgId } : { hostId: item.hostId ?? '' }) }\n if (isNoMailServerVerdict(verdict)) {\n report.undeliverable += 1\n if (item.predicted) continue\n const state: EmailState = {\n status: 'undeliverable',\n atMs: nowMs,\n source: 'check',\n detail: undeliverableDetail(verdict),\n }\n writes.push(async () => {\n const stamped = await stampRecordEmailState({ ...target, email: item.email, state })\n report.stamped += stamped?.records ?? 0\n })\n } else if (item.predicted && verdict.intel && !mailDomainRefusesMail(verdict.intel.status)) {\n const state: EmailState = { status: 'undeliverable', atMs: nowMs, source: 'check', detail: null }\n writes.push(async () => {\n const withdrawn = await stampRecordEmailState({ ...target, email: item.email, state, withdraw: true })\n report.withdrawn += withdrawn?.records ?? 0\n })\n }\n }\n await eachLimited(writes, STAMP_CONCURRENCY, (write) => write())\n return report\n}\n\n/*==========================================\n * THE QUEUE, FLUSHED AFTER THE RESPONSE\n *==========================================*/\n\nconst queued: CapturedEmail[] = []\nlet flushScheduled = false\n\ntype AfterResponse = (task: () => Promise<void>) => void\n\n/**\n * Next's `after()`, required when first asked for rather than imported:\n * `next/server` evaluates web `Request` classes at load, which a jsdom spec\n * reaching one of the writers cannot, and this module rides every writer's\n * import. `null` where it cannot be loaded.\n */\nfunction afterResponse(): AfterResponse | null {\n try {\n const loaded = require('next/server') as { after?: AfterResponse }\n return typeof loaded?.after === 'function' ? loaded.after : null\n } catch {\n return null\n }\n}\n\n/** Runs whatever is queued. Exported for the spec; production reaches it through `after()`. */\nexport async function flushCapturedEmailChecks(deps: MailDeliverabilityDeps = {}): Promise<CapturedEmailCheckReport> {\n flushScheduled = false\n const batch = queued.splice(0)\n if (!batch.length) return { checked: 0, undeliverable: 0, stamped: 0, withdrawn: 0 }\n return checkCapturedEmails(batch, deps)\n}\n\n/**\n * Queues one captured address for the check, which runs once the response\n * has been sent — see the module note. Returns at once, never throws, and\n * costs the caller nothing but the push.\n */\nexport function scheduleCapturedEmailCheck(item: CapturedEmail): void {\n try {\n if (!item || !normalizeDeliverabilityEmail(item.email) || !stampTarget(item)) return\n if (queued.length >= QUEUE_MAX) return\n queued.push({ ...item })\n if (flushScheduled) return\n try {\n const after = afterResponse()\n if (!after) throw new Error('no request to run after')\n after(() => flushCapturedEmailChecks().then(() => undefined))\n flushScheduled = true\n } catch {\n // No request to run after: a script or a spec. Nothing is checked.\n queued.length = 0\n }\n } catch (error) {\n console.error('[deliverability] a captured address could not be queued', error)\n }\n}\n\n/** Test seam: drop anything queued. */\nexport function resetCapturedEmailChecksForTests(): void {\n queued.length = 0\n flushScheduled = false\n}\n\n/** What is queued, for the spec. */\nexport function queuedCapturedEmailsForTests(): readonly CapturedEmail[] {\n return queued\n}\n\n/*==========================================\n * WHAT AN IMPORT REPORTS\n *==========================================*/\n\n/**\n * Which of these addresses (normalized) have a domain that takes no mail,\n * from the same cache the check writes — what an import's result counts.\n * Never rejects: `null` when the answer could not be had, and the import\n * says nothing rather than something untrue.\n */\nexport async function findUndeliverableEmails(\n emails: readonly string[],\n deps: MailDeliverabilityDeps = {},\n): Promise<Set<string> | null> {\n const wanted = [\n ...new Set(emails.map((email) => normalizeDeliverabilityEmail(email)).filter((email): email is string => Boolean(email))),\n ]\n if (!wanted.length) return new Set()\n try {\n const verdicts = await readMailDeliverability(\n { emails: wanted, purpose: 'bulk', sendingDomain: null, scope: null },\n deps,\n )\n const found = new Set<string>()\n for (const [email, verdict] of verdicts) if (isNoMailServerVerdict(verdict)) found.add(email)\n return found\n } catch (error) {\n console.error('[deliverability] an import’s addresses could not be checked', error)\n return null\n }\n}\n\n/**\n * `promise`'s answer, or `null` when it has not arrived within `ms`. An\n * import starts its lookups with its first row and waits this long past its\n * last write for them; a slower answer still stamps the records, through the\n * check the writes queued, and only the count is left unsaid.\n */\nexport async function settleWithin<T>(promise: Promise<T>, ms: number): Promise<T | null> {\n let timer: ReturnType<typeof setTimeout> | undefined\n const late = new Promise<null>((resolve) => {\n timer = setTimeout(() => resolve(null), Math.max(0, ms))\n })\n try {\n return await Promise.race([promise, late])\n } finally {\n if (timer) clearTimeout(timer)\n }\n}\n\n/** How many of `emails` a {@link findUndeliverableEmails} answer names; `undefined` without an answer. */\nexport function countUndeliverableEmails(\n emails: readonly string[],\n undeliverable: ReadonlySet<string> | null,\n): number | undefined {\n if (!undeliverable) return undefined\n let count = 0\n for (const email of new Set(emails.map((entry) => normalizeDeliverabilityEmail(entry)))) {\n if (email && undeliverable.has(email)) count += 1\n }\n return count\n}\n\n/*==========================================\n * ONE ADDRESS, AS A RECORD PAGE AND A COMPOSER READ IT\n *==========================================*/\n\n/** What a surface shows for one address before somebody writes to it. */\nexport interface AddressDeliverability {\n /** The address, normalized; `null` when it is not one. */\n email: string | null\n /** Whether the domain's MX was read; `false` means nothing is known, and nothing is warned. */\n checked: boolean\n /** The finding a composer warns with — a domain with no mail server, or a gateway holding this sender. */\n code: Extract<EmailDeliverabilityCode, 'no_mx' | 'null_mx' | 'gateway_held'> | null\n /** That finding in a sentence, as the engine words it. */\n message: string | null\n /** The gateway in front of the address, when its MX names one. */\n gateway: MailGateway | null\n /** The chip beside the address: the gateway, and what it did with this sender's mail this week. */\n chip: MailGatewayChip | null\n}\n\n/**\n * One address's standing for the site mail would leave from: the platform\n * MX cache (looked up, server side, when cold) and the sending domain's\n * gateway ledger. Read, never written onto a record — a composer asks it\n * before a send and a record page before it draws the address. `from` is\n * the sending identity's address; without one, no ledger is read and\n * nothing is held.\n */\nexport async function readAddressDeliverability(\n input: { email: string; from?: string | null },\n deps: MailDeliverabilityDeps = {},\n): Promise<AddressDeliverability> {\n const email = normalizeDeliverabilityEmail(input.email)\n const none: AddressDeliverability = { email, checked: false, code: null, message: null, gateway: null, chip: null }\n if (!email) return none\n const sender = bareSenderAddress(input.from ?? null)\n const sendingDomain = sender ? sender.slice(sender.lastIndexOf('@') + 1) : null\n const nowMs = deps.nowMs ?? Date.now()\n let verdict: EmailDeliverabilityVerdict | undefined\n try {\n verdict = (\n await readMailDeliverability(\n { emails: [email], purpose: 'bulk', sendingDomain, scope: null },\n { ...deps, nowMs },\n )\n ).get(email)\n } catch (error) {\n console.error('[deliverability] an address could not be checked', error)\n return none\n }\n if (!verdict?.intel) return none\n const deciding = decidingDeliverabilityFinding(verdict)\n const warns = isNoMailServerVerdict(verdict) || isGatewayHeldVerdict(verdict)\n const standing = verdict.gateway ?? mailGatewayStanding(verdict.intel.gateway, null, nowMs)\n let chip = mailGatewayChip(standing)\n // A public mailbox provider's MX is the provider's, not a gateway the\n // person's company chose: \"Google Workspace\" beside a gmail.com address\n // says something untrue. It keeps a chip only for a verdict this week.\n if (chip?.tone === 'neutral' && isPublicMailboxDomain(verdict.domain)) chip = null\n return {\n email,\n checked: true,\n code: warns && deciding ? (deciding.code as AddressDeliverability['code']) : null,\n message: warns && deciding ? deciding.message : null,\n gateway: verdict.intel.gateway === 'none' ? null : verdict.intel.gateway,\n chip,\n }\n}\n"],"names":["isPublicMailboxDomain","stampRecordEmailState","bareSenderAddress","decidingDeliverabilityFinding","mailDomainRefusesMail","mailGatewayChip","mailGatewayStanding","normalizeDeliverabilityEmail","isGatewayHeldVerdict","isNoMailServerVerdict","readMailDeliverability","IMPORT_MAIL_CHECK_GRACE_MS","STAMP_CONCURRENCY","QUEUE_MAX","eachLimited","items","limit","work","next","runners","Array","from","length","Math","min","item","Promise","all","undeliverableDetail","verdict","intel","status","domain","stampTarget","orgId","String","trim","hostId","checkCapturedEmails","deps","report","checked","undeliverable","stamped","withdrawn","unique","Map","target","email","key","held","get","set","predicted","Boolean","size","nowMs","Date","now","verdicts","emails","Set","values","map","purpose","sendingDomain","scope","error","console","writes","outcome","state","atMs","source","detail","push","records","withdraw","write","queued","flushScheduled","afterResponse","loaded","require","after","flushCapturedEmailChecks","batch","splice","scheduleCapturedEmailCheck","Error","then","undefined","resetCapturedEmailChecksForTests","queuedCapturedEmailsForTests","findUndeliverableEmails","wanted","filter","found","add","settleWithin","promise","ms","timer","late","resolve","setTimeout","max","race","clearTimeout","countUndeliverableEmails","count","entry","has","readAddressDeliverability","input","none","code","message","gateway","chip","sender","slice","lastIndexOf","deciding","warns","standing","tone"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;4CAoC4C;AAG5C,SAASA,qBAAqB,QAAQ,6BAA4B;AAClE,SAASC,qBAAqB,QAAQ,wDAAuD;AAC7F,SACEC,iBAAiB,EACjBC,6BAA6B,EAK7BC,qBAAqB,EACrBC,eAAe,EACfC,mBAAmB,EACnBC,4BAA4B,QACvB,2BAA0B;AACjC,SACEC,oBAAoB,EACpBC,qBAAqB,EAErBC,sBAAsB,QACjB,4BAAwB;AA4B/B;;;CAGC,GACD,OAAO,MAAMC,6BAA6B,KAAK;AAE/C,yCAAyC,GACzC,MAAMC,oBAAoB;AAE1B,uFAAuF,GACvF,MAAMC,YAAY;AAElB,yDAAyD,GACzD,eAAeC,YAAeC,KAAmB,EAAEC,KAAa,EAAEC,IAAgC;IAChG,IAAIC,OAAO;IACX,MAAMC,UAAUC,MAAMC,IAAI,CAAC;QAAEC,QAAQC,KAAKC,GAAG,CAACR,OAAOD,MAAMO,MAAM;IAAE,GAAG;QACpE,MAAOJ,OAAOH,MAAMO,MAAM,CAAE;YAC1B,MAAMG,OAAOV,KAAK,CAACG,KAAK;YACxBA,QAAQ;YACR,MAAMD,KAAKQ;QACb;IACF;IACA,MAAMC,QAAQC,GAAG,CAACR;AACpB;AAEA;;;;CAIC,GACD,SAASS,oBAAoBC,OAAmC;QACvDA;IAAP,OAAOA,EAAAA,iBAAAA,QAAQC,KAAK,qBAAbD,eAAeE,MAAM,MAAK,YAC7B,GAAGF,QAAQG,MAAM,CAAC,yDAAyD,CAAC,GAC5E;AACN;AAEA,4DAA4D,GAC5D,SAASC,YAAYR,IAAmB;QACjBA,aAECA;IAFtB,MAAMS,QAAQC,QAAOV,cAAAA,KAAKS,KAAK,YAAVT,cAAc,IAAIW,IAAI;IAC3C,IAAIF,OAAO,OAAO;QAAEA;IAAM;IAC1B,MAAMG,SAASF,QAAOV,eAAAA,KAAKY,MAAM,YAAXZ,eAAe,IAAIW,IAAI;IAC7C,OAAOC,SAAS;QAAEA;IAAO,IAAI;AAC/B;AAEA;;;;;CAKC,GACD,OAAO,eAAeC,oBACpBvB,KAA+B,EAC/BwB,OAA+B,CAAC,CAAC;QAiBnBA;IAfd,MAAMC,SAAmC;QAAEC,SAAS;QAAGC,eAAe;QAAGC,SAAS;QAAGC,WAAW;IAAE;IAClG,oEAAoE;IACpE,sEAAsE;IACtE,yBAAyB;IACzB,MAAMC,SAAS,IAAIC;IACnB,KAAK,MAAMrB,QAAQV,MAAO;YAITgC;QAHf,MAAMC,QAAQzC,6BAA6BkB,wBAAAA,KAAMuB,KAAK;QACtD,MAAMD,SAAStB,OAAOQ,YAAYR,QAAQ;QAC1C,IAAI,CAACuB,SAAS,CAACD,QAAQ;QACvB,MAAME,MAAM,IAAGF,gBAAAA,OAAOb,KAAK,YAAZa,gBAAgB,CAAC,KAAK,EAAEA,OAAOV,MAAM,EAAE,CAAC,CAAC,EAAEW,OAAO;QACjE,MAAME,OAAOL,OAAOM,GAAG,CAACF;QACxBJ,OAAOO,GAAG,CAACH,KAAK,aAAKF;YAAQC;YAAOK,WAAWC,QAAQJ,CAAAA,wBAAAA,KAAMG,SAAS,KAAI5B,KAAK4B,SAAS;;IAC1F;IACA,IAAI,CAACR,OAAOU,IAAI,EAAE,OAAOf;IAEzB,MAAMgB,SAAQjB,cAAAA,KAAKiB,KAAK,YAAVjB,cAAckB,KAAKC,GAAG;IACpC,IAAIC;IACJ,IAAI;QACFA,WAAW,MAAMjD,uBACf;YACEkD,QAAQ;mBAAI,IAAIC,IAAI;uBAAIhB,OAAOiB,MAAM;iBAAG,CAACC,GAAG,CAAC,CAACtC,OAASA,KAAKuB,KAAK;aAAG;YACpEgB,SAAS;YACTC,eAAe;YACfC,OAAO;QACT,GACA,aAAK3B;YAAMiB;;IAEf,EAAE,OAAOW,OAAO;QACdC,QAAQD,KAAK,CAAC,4DAA4DA;QAC1E,OAAO3B;IACT;IAEA,MAAM6B,SAAqC,EAAE;IAC7C,KAAK,MAAM5C,QAAQoB,OAAOiB,MAAM,GAAI;YAIkCrC;QAHpE,MAAMI,UAAU8B,SAASR,GAAG,CAAC1B,KAAKuB,KAAK;QACvC,IAAI,CAACnB,WAAWA,QAAQyC,OAAO,KAAK,aAAa;QACjD9B,OAAOC,OAAO,IAAI;QAClB,MAAMM,SAAS,aAAMtB,KAAKS,KAAK,GAAG;YAAEA,OAAOT,KAAKS,KAAK;QAAC,IAAI;YAAEG,MAAM,GAAEZ,eAAAA,KAAKY,MAAM,YAAXZ,eAAe;QAAG;QACtF,IAAIhB,sBAAsBoB,UAAU;YAClCW,OAAOE,aAAa,IAAI;YACxB,IAAIjB,KAAK4B,SAAS,EAAE;YACpB,MAAMkB,QAAoB;gBACxBxC,QAAQ;gBACRyC,MAAMhB;gBACNiB,QAAQ;gBACRC,QAAQ9C,oBAAoBC;YAC9B;YACAwC,OAAOM,IAAI,CAAC;;gBACV,MAAMhC,UAAU,MAAM1C,sBAAsB,aAAK8C;oBAAQC,OAAOvB,KAAKuB,KAAK;oBAAEuB;;gBAC5E/B,OAAOG,OAAO,YAAIA,2BAAAA,QAASiC,OAAO,mBAAI;YACxC;QACF,OAAO,IAAInD,KAAK4B,SAAS,IAAIxB,QAAQC,KAAK,IAAI,CAAC1B,sBAAsByB,QAAQC,KAAK,CAACC,MAAM,GAAG;YAC1F,MAAMwC,QAAoB;gBAAExC,QAAQ;gBAAiByC,MAAMhB;gBAAOiB,QAAQ;gBAASC,QAAQ;YAAK;YAChGL,OAAOM,IAAI,CAAC;;gBACV,MAAM/B,YAAY,MAAM3C,sBAAsB,aAAK8C;oBAAQC,OAAOvB,KAAKuB,KAAK;oBAAEuB;oBAAOM,UAAU;;gBAC/FrC,OAAOI,SAAS,YAAIA,6BAAAA,UAAWgC,OAAO,mBAAI;YAC5C;QACF;IACF;IACA,MAAM9D,YAAYuD,QAAQzD,mBAAmB,CAACkE,QAAUA;IACxD,OAAOtC;AACT;AAEA;;4CAE4C,GAE5C,MAAMuC,SAA0B,EAAE;AAClC,IAAIC,iBAAiB;AAIrB;;;;;CAKC,GACD,SAASC;IACP,IAAI;QACF,MAAMC,SAASC,QAAQ;QACvB,OAAO,QAAOD,0BAAAA,OAAQE,KAAK,MAAK,aAAaF,OAAOE,KAAK,GAAG;IAC9D,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA,6FAA6F,GAC7F,OAAO,eAAeC,yBAAyB9C,OAA+B,CAAC,CAAC;IAC9EyC,iBAAiB;IACjB,MAAMM,QAAQP,OAAOQ,MAAM,CAAC;IAC5B,IAAI,CAACD,MAAMhE,MAAM,EAAE,OAAO;QAAEmB,SAAS;QAAGC,eAAe;QAAGC,SAAS;QAAGC,WAAW;IAAE;IACnF,OAAON,oBAAoBgD,OAAO/C;AACpC;AAEA;;;;CAIC,GACD,OAAO,SAASiD,2BAA2B/D,IAAmB;IAC5D,IAAI;QACF,IAAI,CAACA,QAAQ,CAAClB,6BAA6BkB,KAAKuB,KAAK,KAAK,CAACf,YAAYR,OAAO;QAC9E,IAAIsD,OAAOzD,MAAM,IAAIT,WAAW;QAChCkE,OAAOJ,IAAI,CAAC,aAAKlD;QACjB,IAAIuD,gBAAgB;QACpB,IAAI;YACF,MAAMI,QAAQH;YACd,IAAI,CAACG,OAAO,MAAM,IAAIK,MAAM;YAC5BL,MAAM,IAAMC,2BAA2BK,IAAI,CAAC,IAAMC;YAClDX,iBAAiB;QACnB,EAAE,eAAM;YACN,mEAAmE;YACnED,OAAOzD,MAAM,GAAG;QAClB;IACF,EAAE,OAAO6C,OAAO;QACdC,QAAQD,KAAK,CAAC,2DAA2DA;IAC3E;AACF;AAEA,qCAAqC,GACrC,OAAO,SAASyB;IACdb,OAAOzD,MAAM,GAAG;IAChB0D,iBAAiB;AACnB;AAEA,kCAAkC,GAClC,OAAO,SAASa;IACd,OAAOd;AACT;AAEA;;4CAE4C,GAE5C;;;;;CAKC,GACD,OAAO,eAAee,wBACpBlC,MAAyB,EACzBrB,OAA+B,CAAC,CAAC;IAEjC,MAAMwD,SAAS;WACV,IAAIlC,IAAID,OAAOG,GAAG,CAAC,CAACf,QAAUzC,6BAA6ByC,QAAQgD,MAAM,CAAC,CAAChD,QAA2BM,QAAQN;KAClH;IACD,IAAI,CAAC+C,OAAOzE,MAAM,EAAE,OAAO,IAAIuC;IAC/B,IAAI;QACF,MAAMF,WAAW,MAAMjD,uBACrB;YAAEkD,QAAQmC;YAAQ/B,SAAS;YAAQC,eAAe;YAAMC,OAAO;QAAK,GACpE3B;QAEF,MAAM0D,QAAQ,IAAIpC;QAClB,KAAK,MAAM,CAACb,OAAOnB,QAAQ,IAAI8B,SAAU,IAAIlD,sBAAsBoB,UAAUoE,MAAMC,GAAG,CAAClD;QACvF,OAAOiD;IACT,EAAE,OAAO9B,OAAO;QACdC,QAAQD,KAAK,CAAC,+DAA+DA;QAC7E,OAAO;IACT;AACF;AAEA;;;;;CAKC,GACD,OAAO,eAAegC,aAAgBC,OAAmB,EAAEC,EAAU;IACnE,IAAIC;IACJ,MAAMC,OAAO,IAAI7E,QAAc,CAAC8E;QAC9BF,QAAQG,WAAW,IAAMD,QAAQ,OAAOjF,KAAKmF,GAAG,CAAC,GAAGL;IACtD;IACA,IAAI;QACF,OAAO,MAAM3E,QAAQiF,IAAI,CAAC;YAACP;YAASG;SAAK;IAC3C,SAAU;QACR,IAAID,OAAOM,aAAaN;IAC1B;AACF;AAEA,wGAAwG,GACxG,OAAO,SAASO,yBACdjD,MAAyB,EACzBlB,aAAyC;IAEzC,IAAI,CAACA,eAAe,OAAOiD;IAC3B,IAAImB,QAAQ;IACZ,KAAK,MAAM9D,SAAS,IAAIa,IAAID,OAAOG,GAAG,CAAC,CAACgD,QAAUxG,6BAA6BwG,SAAU;QACvF,IAAI/D,SAASN,cAAcsE,GAAG,CAAChE,QAAQ8D,SAAS;IAClD;IACA,OAAOA;AACT;AAsBA;;;;;;;CAOC,GACD,OAAO,eAAeG,0BACpBC,KAA8C,EAC9C3E,OAA+B,CAAC,CAAC;QAKA2E,aAEnB3E,aAgBGV;IArBjB,MAAMmB,QAAQzC,6BAA6B2G,MAAMlE,KAAK;IACtD,MAAMmE,OAA8B;QAAEnE;QAAOP,SAAS;QAAO2E,MAAM;QAAMC,SAAS;QAAMC,SAAS;QAAMC,MAAM;IAAK;IAClH,IAAI,CAACvE,OAAO,OAAOmE;IACnB,MAAMK,SAAStH,mBAAkBgH,cAAAA,MAAM7F,IAAI,YAAV6F,cAAc;IAC/C,MAAMjD,gBAAgBuD,SAASA,OAAOC,KAAK,CAACD,OAAOE,WAAW,CAAC,OAAO,KAAK;IAC3E,MAAMlE,SAAQjB,cAAAA,KAAKiB,KAAK,YAAVjB,cAAckB,KAAKC,GAAG;IACpC,IAAI7B;IACJ,IAAI;QACFA,UAAU,AACR,CAAA,MAAMnB,uBACJ;YAAEkD,QAAQ;gBAACZ;aAAM;YAAEgB,SAAS;YAAQC;YAAeC,OAAO;QAAK,GAC/D,aAAK3B;YAAMiB;WACb,EACAL,GAAG,CAACH;IACR,EAAE,OAAOmB,OAAO;QACdC,QAAQD,KAAK,CAAC,oDAAoDA;QAClE,OAAOgD;IACT;IACA,IAAI,EAACtF,2BAAAA,QAASC,KAAK,GAAE,OAAOqF;IAC5B,MAAMQ,WAAWxH,8BAA8B0B;IAC/C,MAAM+F,QAAQnH,sBAAsBoB,YAAYrB,qBAAqBqB;IACrE,MAAMgG,YAAWhG,mBAAAA,QAAQyF,OAAO,YAAfzF,mBAAmBvB,oBAAoBuB,QAAQC,KAAK,CAACwF,OAAO,EAAE,MAAM9D;IACrF,IAAI+D,OAAOlH,gBAAgBwH;IAC3B,sEAAsE;IACtE,wEAAwE;IACxE,uEAAuE;IACvE,IAAIN,CAAAA,wBAAAA,KAAMO,IAAI,MAAK,aAAa9H,sBAAsB6B,QAAQG,MAAM,GAAGuF,OAAO;IAC9E,OAAO;QACLvE;QACAP,SAAS;QACT2E,MAAMQ,SAASD,WAAYA,SAASP,IAAI,GAAqC;QAC7EC,SAASO,SAASD,WAAWA,SAASN,OAAO,GAAG;QAChDC,SAASzF,QAAQC,KAAK,CAACwF,OAAO,KAAK,SAAS,OAAOzF,QAAQC,KAAK,CAACwF,OAAO;QACxEC;IACF;AACF"}
@@ -14,7 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- import { notifyHostManagers, notifyStaff } from './notifications';
17
+ import { type RiskEventInput } from './risk-notice';
18
18
  export interface CardPaymentVelocityOptions {
19
19
  /** Dispatcher path, for the staff row. */
20
20
  path: string;
@@ -33,29 +33,25 @@ export interface CardPaymentVelocityOptions {
33
33
  nowMs?: number;
34
34
  /** Injectable for tests; defaults to the Admin SDK's Firestore. */
35
35
  firestore?: unknown;
36
- /** Injectable for tests; defaults to `notifyStaff`. */
37
- notify?: typeof notifyStaff;
38
- /** Injectable for tests; defaults to `notifyHostManagers`. */
39
- notifyManagers?: typeof notifyHostManagers;
36
+ /** Injectable for tests; defaults to `notifyRiskEvent` (AGL-3368). */
37
+ notifyRisk?: (input: RiskEventInput) => Promise<unknown>;
40
38
  }
41
39
  /** The abuse-queue row id for a site's alarm on one day. Hex, like every row. */
42
40
  export declare function cardPaymentAlarmReviewId(hostId: string, day: string): string;
43
41
  /**
44
- * Tell staff and the site's managers, once per site per day, that its payment
45
- * doors crossed the card-testing alarm (AGL-3363). The one notifier for this
46
- * kind of signal, so a shared risk-notice seam can take it over whole.
47
- *
48
- * The merchant's sentence names the outcome and what to do, never the rule
49
- * or its numbers: a merchant who is the card tester must not learn how to
50
- * pace under it. Never throws.
42
+ * Tell staff and the site's managers and the workspace's owners that a site
43
+ * crossed the card-payment window (AGL-3363), through the risk notice seam
44
+ * (AGL-3368): the owners read the catalog's `card-testing` words — what was
45
+ * seen and what to do, never the window or its numbers; staff get the row.
46
+ * Never throws.
51
47
  */
52
48
  export declare function notifyCardTestingVelocity(input: {
53
49
  hostId: string;
54
50
  orgId: string | null;
55
51
  reference: string;
52
+ reviewId?: string | null;
56
53
  }, deps: {
57
- notifyStaff: typeof notifyStaff;
58
- notifyManagers: typeof notifyHostManagers;
54
+ notifyRisk: (input: RiskEventInput) => Promise<unknown>;
59
55
  }): Promise<void>;
60
56
  /**
61
57
  * File (or count) the day's alarm row for a site whose payment doors crossed
@@ -69,8 +65,7 @@ export declare function recordCardPaymentAlarm(input: {
69
65
  young: boolean;
70
66
  nowMs: number;
71
67
  firestore: any;
72
- notify: typeof notifyStaff;
73
- notifyManagers: typeof notifyHostManagers;
68
+ notifyRisk: (input: RiskEventInput) => Promise<unknown>;
74
69
  }): Promise<{
75
70
  reviewId: string;
76
71
  first: boolean;