@aglyn/tenant-data-admin 1.0.0-beta.203 → 1.0.0-beta.205
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/package.json +7 -7
- package/src/lib/server/capture-email-check.d.ts +109 -0
- package/src/lib/server/capture-email-check.js +362 -0
- package/src/lib/server/capture-email-check.js.map +1 -0
- package/src/lib/server/card-payment-velocity.d.ts +11 -16
- package/src/lib/server/card-payment-velocity.js +24 -28
- package/src/lib/server/card-payment-velocity.js.map +1 -1
- package/src/lib/server/email-deliverability.d.ts +19 -3
- package/src/lib/server/email-deliverability.js +34 -16
- package/src/lib/server/email-deliverability.js.map +1 -1
- package/src/lib/server/held-page-subject.d.ts +93 -0
- package/src/lib/server/held-page-subject.js +206 -0
- package/src/lib/server/held-page-subject.js.map +1 -0
- package/src/lib/server/host-email-tokens.d.ts +42 -1
- package/src/lib/server/host-email-tokens.js +100 -12
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-visitor-records.js +21 -5
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +16 -5
- package/src/lib/server/hosted-page-review.js +242 -61
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/list-members.js +16 -0
- package/src/lib/server/list-members.js.map +1 -1
- package/src/lib/server/notifications.d.ts +7 -0
- package/src/lib/server/notifications.js +27 -4
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/outbound-send-review.d.ts +22 -0
- package/src/lib/server/outbound-send-review.js +74 -6
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/payment-fraud-signal.d.ts +20 -12
- package/src/lib/server/payment-fraud-signal.js +41 -11
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/payment-risk-record.d.ts +37 -13
- package/src/lib/server/payment-risk-record.js +23 -8
- package/src/lib/server/payment-risk-record.js.map +1 -1
- package/src/lib/server/render-system-email.d.ts +3 -0
- package/src/lib/server/render-system-email.js +39 -11
- package/src/lib/server/render-system-email.js.map +1 -1
- package/src/lib/server/risk-notice.d.ts +327 -0
- package/src/lib/server/risk-notice.js +1240 -0
- package/src/lib/server/risk-notice.js.map +1 -0
- package/src/lib/server/sender-readiness.d.ts +36 -0
- package/src/lib/server/sender-readiness.js +99 -0
- package/src/lib/server/sender-readiness.js.map +1 -0
- package/src/lib/server/staff-alert-email.d.ts +29 -15
- package/src/lib/server/staff-alert-email.js +131 -28
- package/src/lib/server/staff-alert-email.js.map +1 -1
- package/src/lib/server/test-firestore.js +1 -0
- package/src/lib/server/test-firestore.js.map +1 -1
- package/src/lib/server/upsert-contact.js +19 -3
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +2 -0
- package/src/tenancy.js +3 -0
- 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.
|
|
3
|
+
"version": "1.0.0-beta.205",
|
|
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.
|
|
29
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-util-fbserver": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-util-first-touch": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.205",
|
|
29
|
+
"@aglyn/shared-util-email": "1.0.0-beta.205",
|
|
30
|
+
"@aglyn/shared-util-fbserver": "1.0.0-beta.205",
|
|
31
|
+
"@aglyn/shared-util-first-touch": "1.0.0-beta.205",
|
|
32
|
+
"@aglyn/shared-util-http": "1.0.0-beta.205",
|
|
33
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.205",
|
|
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":"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"}
|
|
@@ -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 {
|
|
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 `
|
|
37
|
-
|
|
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
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
notifyManagers: typeof notifyHostManagers;
|
|
68
|
+
notifyRisk: (input: RiskEventInput) => Promise<unknown>;
|
|
74
69
|
}): Promise<{
|
|
75
70
|
reviewId: string;
|
|
76
71
|
first: boolean;
|