@aglyn/shared-util-email 1.0.0-beta.199 → 1.0.0-beta.201

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,330 @@
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 MX RECORD PREDICTS THE GATEWAY, AND THE GATEWAY'S HISTORY WITH A
18
+ * SENDING DOMAIN PREDICTS THE BLOCK (AGL-3326, AGL-3328).
19
+ *
20
+ * A security gateway refuses a SENDER, not an address: a Barracuda that
21
+ * refused `aglyn.io` on reputation refuses it for every company behind a
22
+ * Barracuda, and the next address there bounces the same way. So the signal
23
+ * is cheap and it is available BEFORE a send: the recipient domain's MX
24
+ * names the gateway in front of it, and what that gateway did with this
25
+ * sending domain's mail lately says what it will do next.
26
+ *
27
+ * Pure, for the reason every module in this library is: the barrel reaches
28
+ * the browser, so the DNS lookup and the ledger's store are handed in by
29
+ * the server layer that owns them.
30
+ *
31
+ * ## The kinds
32
+ *
33
+ * A gateway is named by the MX host's suffix. `barracuda`, `proofpoint`
34
+ * and `mimecast` are the security gateways that refuse a sender on policy
35
+ * and reputation; `google` and `microsoft` are the two hosted providers,
36
+ * which judge mail one message at a time; `other` is every self-hosted or
37
+ * unrecognized exchange; `none` is a domain that takes no mail at all.
38
+ *
39
+ * ## The ledger and the hold
40
+ *
41
+ * Every send, every delivery and every refusal is counted per SENDING DOMAIN
42
+ * per gateway, by UTC day. A gateway that REFUSED a sending domain twice in
43
+ * the last {@link MAIL_GATEWAY_WINDOW_DAYS} days without delivering once
44
+ * holds the next bulk send from that domain into it. `other` never holds,
45
+ * because it is a bucket of unrelated exchanges rather than one gateway;
46
+ * `none` refuses instead. Which sends a hold may touch is the caller's
47
+ * question — see `email-deliverability.ts`: mail the recipient asked for is
48
+ * never held.
49
+ *==========================================*/ import { _ as _extends } from "@swc/helpers/_/_extends";
50
+ export const MAIL_GATEWAYS = [
51
+ 'barracuda',
52
+ 'proofpoint',
53
+ 'mimecast',
54
+ 'google',
55
+ 'microsoft',
56
+ 'other',
57
+ 'none'
58
+ ];
59
+ /** The gateway as a chip, a table row or a sentence names it. */ export const MAIL_GATEWAY_LABELS = {
60
+ barracuda: 'Barracuda',
61
+ proofpoint: 'Proofpoint',
62
+ mimecast: 'Mimecast',
63
+ google: 'Google Workspace',
64
+ microsoft: 'Microsoft 365',
65
+ other: 'Other mail gateway',
66
+ none: 'No MX record'
67
+ };
68
+ /**
69
+ * The security gateways that stand in front of a whole company's mail and
70
+ * refuse a sender for everyone behind them.
71
+ */ export const MAIL_GATEWAYS_FRONTED = [
72
+ 'barracuda',
73
+ 'proofpoint',
74
+ 'mimecast'
75
+ ];
76
+ /** Refusals in the window, with no delivery beside them, that hold the next bulk send. */ export const MAIL_GATEWAY_HOLD_BLOCKS = 2;
77
+ /** The days the hold is judged over. */ export const MAIL_GATEWAY_WINDOW_DAYS = 30;
78
+ /** The days a gateway chip counts. */ export const MAIL_GATEWAY_CHIP_DAYS = 7;
79
+ /** How long a domain's MX answer is trusted before it is looked up again. */ export const MAIL_DOMAIN_INTEL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
80
+ /**
81
+ * How long an answer that the domain takes NO mail is trusted: a day, so a
82
+ * domain whose owner is still setting up its mail is looked at again soon.
83
+ */ export const MAIL_DOMAIN_NEGATIVE_TTL_MS = 24 * 60 * 60 * 1000;
84
+ /** A send with no bounce this long after it counts as delivered, where no delivery event is reported. */ export const MAIL_GATEWAY_DELIVERED_AFTER_MS = 24 * 60 * 60 * 1000;
85
+ /**
86
+ * The deliveries one ledger counts per UTC day at most.
87
+ *
88
+ * The hold asks whether a gateway delivered ANYTHING in the window, and the
89
+ * chip reads a week of counts; neither needs the ten-thousandth delivery of
90
+ * a campaign to Gmail, and a ledger document written once per delivery
91
+ * event would be the hottest document in the database. A day that reached
92
+ * the cap reads "at least".
93
+ */ export const MAIL_GATEWAY_DAILY_DELIVERY_CAP = 50;
94
+ /** The MX host suffixes each named gateway answers as, lowercase, no trailing dot. */ const GATEWAY_HOST_SUFFIXES = [
95
+ [
96
+ 'barracuda',
97
+ [
98
+ '.ess.barracudanetworks.com',
99
+ '.barracudanetworks.com'
100
+ ]
101
+ ],
102
+ [
103
+ 'proofpoint',
104
+ [
105
+ '.pphosted.com',
106
+ '.ppe-hosted.com',
107
+ '.proofpoint.com'
108
+ ]
109
+ ],
110
+ [
111
+ 'mimecast',
112
+ [
113
+ '.mimecast.com',
114
+ '.mimecast.co.za',
115
+ '.mimecast-offshore.com'
116
+ ]
117
+ ],
118
+ [
119
+ 'google',
120
+ [
121
+ '.google.com',
122
+ '.googlemail.com',
123
+ '.gmail.com'
124
+ ]
125
+ ],
126
+ [
127
+ 'microsoft',
128
+ [
129
+ '.mail.protection.outlook.com',
130
+ '.outlook.com',
131
+ '.protection.outlook.com'
132
+ ]
133
+ ]
134
+ ];
135
+ /** A host as the suffix table reads it: lowercase, trimmed, no trailing dot. */ export function normalizeMailHost(host) {
136
+ return String(host != null ? host : '').trim().toLowerCase().replace(/\.$/, '');
137
+ }
138
+ /** Whether a value names one of the gateways. */ export function isMailGateway(value) {
139
+ return MAIL_GATEWAYS.includes(value);
140
+ }
141
+ /**
142
+ * The gateway ONE host names, or `null` when it names none of the known
143
+ * ones — a Remote-MTA in a bounce, or one MX exchange.
144
+ */ export function mailGatewayOfHost(host) {
145
+ const name = normalizeMailHost(host);
146
+ if (!name) return null;
147
+ for (const [gateway, suffixes] of GATEWAY_HOST_SUFFIXES){
148
+ if (suffixes.some((suffix)=>name === suffix.slice(1) || name.endsWith(suffix))) return gateway;
149
+ }
150
+ return null;
151
+ }
152
+ /**
153
+ * The gateway a domain's MX records name. A domain whose exchanges name more
154
+ * than one is read as the first named one in MX order (the list is handed
155
+ * over lowest-preference-first); one that names none is `other`; an empty
156
+ * list, or RFC 7505's lone ".", is `none`.
157
+ */ export function classifyMailGateway(mxHosts) {
158
+ const hosts = mxHosts.map(normalizeMailHost).filter(Boolean);
159
+ if (!hosts.length) return 'none';
160
+ for (const host of hosts){
161
+ const gateway = mailGatewayOfHost(host);
162
+ if (gateway) return gateway;
163
+ }
164
+ return 'other';
165
+ }
166
+ /** Whether the gateway is one of the security gateways that refuse a sender for a whole company. */ export function isMailGatewayFronted(gateway) {
167
+ return gateway !== null && gateway !== undefined && MAIL_GATEWAYS_FRONTED.includes(gateway);
168
+ }
169
+ /** `YYYY-MM-DD` in UTC: the day a ledger entry is filed under. */ export function mailGatewayDay(atMs) {
170
+ return new Date(atMs).toISOString().slice(0, 10);
171
+ }
172
+ const count = (value)=>{
173
+ const number = Number(value);
174
+ return Number.isFinite(number) ? Math.max(0, Math.floor(number)) : 0;
175
+ };
176
+ /** The ledger's counts summed over the last `windowDays` UTC days, today included. */ export function mailGatewayWindow(days, nowMs, windowDays) {
177
+ const summed = {
178
+ sent: 0,
179
+ delivered: 0,
180
+ blocked: 0
181
+ };
182
+ if (!days) return summed;
183
+ const oldest = mailGatewayDay(nowMs - (windowDays - 1) * 86400000);
184
+ const newest = mailGatewayDay(nowMs);
185
+ for (const [day, counts] of Object.entries(days)){
186
+ if (day < oldest || day > newest) continue;
187
+ summed.sent += count(counts == null ? void 0 : counts.sent);
188
+ summed.delivered += count(counts == null ? void 0 : counts.delivered);
189
+ summed.blocked += count(counts == null ? void 0 : counts.blocked);
190
+ }
191
+ return summed;
192
+ }
193
+ /** The ledger's days with everything older than `keepDays` dropped. */ export function pruneMailGatewayDays(days, nowMs, keepDays) {
194
+ const oldest = mailGatewayDay(nowMs - (keepDays - 1) * 86400000);
195
+ return Object.fromEntries(Object.entries(days != null ? days : {}).filter(([day])=>day >= oldest));
196
+ }
197
+ /** A sending domain as the ledger spells it: lowercase, no trailing dot, `null` for none. */ export function normalizeLedgerSendingDomain(value) {
198
+ const raw = String(value != null ? value : '').trim().toLowerCase();
199
+ const domain = (raw.includes('@') ? raw.slice(raw.lastIndexOf('@') + 1) : raw).replace(/[>\s]+$/, '').replace(/\.$/, '');
200
+ return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(domain) ? domain : null;
201
+ }
202
+ /**
203
+ * The document id a ledger is stored under: `aglyn.io~barracuda`. A `~`
204
+ * because a domain never holds one and a Firestore id may.
205
+ */ export function mailGatewayLedgerKey(sendingDomain, gateway) {
206
+ return `${sendingDomain}~${gateway}`;
207
+ }
208
+ /** A stored ledger in its model shape, or `null` for no document or an unreadable one. */ export function readStoredMailGatewayLedger(id, data) {
209
+ var _data_sendingDomain, _ms;
210
+ if (!data) return null;
211
+ const [idDomain, idGateway] = String(id != null ? id : '').split('~');
212
+ const gateway = isMailGateway(data['gateway']) ? data['gateway'] : isMailGateway(idGateway) ? idGateway : null;
213
+ const sendingDomain = normalizeLedgerSendingDomain((_data_sendingDomain = data['sendingDomain']) != null ? _data_sendingDomain : idDomain);
214
+ if (!gateway || !sendingDomain) return null;
215
+ const days = {};
216
+ const raw = data['days'];
217
+ if (raw && typeof raw === 'object') {
218
+ for (const [day, counts] of Object.entries(raw)){
219
+ const entry = counts && typeof counts === 'object' ? counts : {};
220
+ days[day] = {
221
+ sent: count(entry['sent']),
222
+ delivered: count(entry['delivered']),
223
+ blocked: count(entry['blocked'])
224
+ };
225
+ }
226
+ }
227
+ const ms = (value)=>typeof value === 'number' && Number.isFinite(value) ? value : null;
228
+ return {
229
+ sendingDomain,
230
+ gateway,
231
+ sent: count(data['sent']),
232
+ delivered: count(data['delivered']),
233
+ blocked: count(data['blocked']),
234
+ lastBlockedAtMs: ms(data['lastBlockedAtMs']),
235
+ lastBlockedDetail: typeof data['lastBlockedDetail'] === 'string' ? data['lastBlockedDetail'] : null,
236
+ days,
237
+ updatedAtMs: (_ms = ms(data['updatedAtMs'])) != null ? _ms : 0
238
+ };
239
+ }
240
+ /**
241
+ * The ledger after one more outcome — the pure half of the write, which the
242
+ * store runs inside its transaction. Deliveries stop counting for the day at
243
+ * {@link MAIL_GATEWAY_DAILY_DELIVERY_CAP}; answers `null` when the write
244
+ * would change nothing, so the store can skip it.
245
+ */ export function applyMailGatewayOutcome(ledger, input) {
246
+ var _days_day, _input_count, _ref, _ref1, _ref2, _ref3, _ref4, _input_detail, _ref5, _ref6;
247
+ const day = mailGatewayDay(input.atMs);
248
+ const days = pruneMailGatewayDays(ledger == null ? void 0 : ledger.days, input.atMs, MAIL_GATEWAY_WINDOW_DAYS);
249
+ const today = (_days_day = days[day]) != null ? _days_day : {
250
+ sent: 0,
251
+ delivered: 0,
252
+ blocked: 0
253
+ };
254
+ let by = count((_input_count = input.count) != null ? _input_count : 1);
255
+ if (input.outcome === 'delivered') by = Math.min(by, Math.max(0, MAIL_GATEWAY_DAILY_DELIVERY_CAP - today.delivered));
256
+ if (!by) return null;
257
+ days[day] = _extends({}, today, {
258
+ [input.outcome]: today[input.outcome] + by
259
+ });
260
+ const blocked = input.outcome === 'blocked';
261
+ return {
262
+ sendingDomain: input.sendingDomain,
263
+ gateway: input.gateway,
264
+ sent: ((_ref = ledger == null ? void 0 : ledger.sent) != null ? _ref : 0) + (input.outcome === 'sent' ? by : 0),
265
+ delivered: ((_ref1 = ledger == null ? void 0 : ledger.delivered) != null ? _ref1 : 0) + (input.outcome === 'delivered' ? by : 0),
266
+ blocked: ((_ref2 = ledger == null ? void 0 : ledger.blocked) != null ? _ref2 : 0) + (blocked ? by : 0),
267
+ lastBlockedAtMs: blocked ? Math.max((_ref3 = ledger == null ? void 0 : ledger.lastBlockedAtMs) != null ? _ref3 : 0, input.atMs) : (_ref4 = ledger == null ? void 0 : ledger.lastBlockedAtMs) != null ? _ref4 : null,
268
+ lastBlockedDetail: blocked ? String((_input_detail = input.detail) != null ? _input_detail : '').trim().slice(0, 300) || ((_ref5 = ledger == null ? void 0 : ledger.lastBlockedDetail) != null ? _ref5 : null) : (_ref6 = ledger == null ? void 0 : ledger.lastBlockedDetail) != null ? _ref6 : null,
269
+ days,
270
+ updatedAtMs: input.atMs
271
+ };
272
+ }
273
+ /** A gateway's standing with a sending domain, read off its ledger (or none yet). */ export function mailGatewayStanding(gateway, ledger, nowMs) {
274
+ const week = mailGatewayWindow(ledger == null ? void 0 : ledger.days, nowMs, MAIL_GATEWAY_CHIP_DAYS);
275
+ const month = mailGatewayWindow(ledger == null ? void 0 : ledger.days, nowMs, MAIL_GATEWAY_WINDOW_DAYS);
276
+ return {
277
+ gateway,
278
+ blocked7: week.blocked,
279
+ delivered7: week.delivered,
280
+ blocked30: month.blocked,
281
+ delivered30: month.delivered
282
+ };
283
+ }
284
+ /**
285
+ * Whether the gateway holds the next bulk send — see the module note. A
286
+ * named gateway only: `other` is many exchanges, and `none` refuses instead.
287
+ */ export function mailGatewayHolds(standing) {
288
+ if (standing.gateway === 'other' || standing.gateway === 'none') return false;
289
+ return count(standing.blocked30) >= MAIL_GATEWAY_HOLD_BLOCKS && count(standing.delivered30) === 0;
290
+ }
291
+ /** Why a gateway holds, in one sentence a surface or a send record can carry. */ export function mailGatewayHoldSentence(gateway, blocked30) {
292
+ const name = MAIL_GATEWAY_LABELS[gateway];
293
+ return `${name} refused this sender ${blocked30 === 2 ? 'twice' : `${blocked30} times`} in the last ` + `${MAIL_GATEWAY_WINDOW_DAYS} days and delivered nothing`;
294
+ }
295
+ /**
296
+ * The chip for one address, or `null` when there is nothing worth a chip: an
297
+ * unrecognized exchange with no verdict this week. Red for a gateway that
298
+ * refused this week, green for one that delivered, neutral for the hosted
299
+ * providers and the security gateways with no verdict yet, and blocked for a
300
+ * domain with no MX.
301
+ */ export function mailGatewayChip(standing) {
302
+ if (!standing) return null;
303
+ const name = MAIL_GATEWAY_LABELS[standing.gateway];
304
+ if (standing.gateway === 'none') return {
305
+ label: 'No MX record — cannot receive mail',
306
+ tone: 'blocked'
307
+ };
308
+ const blocked = count(standing.blocked7);
309
+ const delivered = count(standing.delivered7);
310
+ const settled = blocked + delivered;
311
+ if (blocked > 0) {
312
+ return {
313
+ label: `${name} · ${blocked} of ${settled} ${settled === 1 ? 'send' : 'sends'} refused this week`,
314
+ tone: 'refused'
315
+ };
316
+ }
317
+ if (delivered > 0) {
318
+ return {
319
+ label: `${name} · ${delivered} of ${settled} delivered this week`,
320
+ tone: 'delivered'
321
+ };
322
+ }
323
+ if (standing.gateway === 'other') return null;
324
+ return {
325
+ label: name,
326
+ tone: 'neutral'
327
+ };
328
+ }
329
+
330
+ //# sourceMappingURL=mail-gateway.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/mail-gateway.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 MX RECORD PREDICTS THE GATEWAY, AND THE GATEWAY'S HISTORY WITH A\n * SENDING DOMAIN PREDICTS THE BLOCK (AGL-3326, AGL-3328).\n *\n * A security gateway refuses a SENDER, not an address: a Barracuda that\n * refused `aglyn.io` on reputation refuses it for every company behind a\n * Barracuda, and the next address there bounces the same way. So the signal\n * is cheap and it is available BEFORE a send: the recipient domain's MX\n * names the gateway in front of it, and what that gateway did with this\n * sending domain's mail lately says what it will do next.\n *\n * Pure, for the reason every module in this library is: the barrel reaches\n * the browser, so the DNS lookup and the ledger's store are handed in by\n * the server layer that owns them.\n *\n * ## The kinds\n *\n * A gateway is named by the MX host's suffix. `barracuda`, `proofpoint`\n * and `mimecast` are the security gateways that refuse a sender on policy\n * and reputation; `google` and `microsoft` are the two hosted providers,\n * which judge mail one message at a time; `other` is every self-hosted or\n * unrecognized exchange; `none` is a domain that takes no mail at all.\n *\n * ## The ledger and the hold\n *\n * Every send, every delivery and every refusal is counted per SENDING DOMAIN\n * per gateway, by UTC day. A gateway that REFUSED a sending domain twice in\n * the last {@link MAIL_GATEWAY_WINDOW_DAYS} days without delivering once\n * holds the next bulk send from that domain into it. `other` never holds,\n * because it is a bucket of unrelated exchanges rather than one gateway;\n * `none` refuses instead. Which sends a hold may touch is the caller's\n * question — see `email-deliverability.ts`: mail the recipient asked for is\n * never held.\n *==========================================*/\n\nexport type MailGateway =\n | 'barracuda'\n | 'proofpoint'\n | 'mimecast'\n | 'google'\n | 'microsoft'\n | 'other'\n | 'none'\n\nexport const MAIL_GATEWAYS: readonly MailGateway[] = [\n 'barracuda',\n 'proofpoint',\n 'mimecast',\n 'google',\n 'microsoft',\n 'other',\n 'none',\n]\n\n/** The gateway as a chip, a table row or a sentence names it. */\nexport const MAIL_GATEWAY_LABELS: Record<MailGateway, string> = {\n barracuda: 'Barracuda',\n proofpoint: 'Proofpoint',\n mimecast: 'Mimecast',\n google: 'Google Workspace',\n microsoft: 'Microsoft 365',\n other: 'Other mail gateway',\n none: 'No MX record',\n}\n\n/**\n * The security gateways that stand in front of a whole company's mail and\n * refuse a sender for everyone behind them.\n */\nexport const MAIL_GATEWAYS_FRONTED: readonly MailGateway[] = ['barracuda', 'proofpoint', 'mimecast']\n\n/** Refusals in the window, with no delivery beside them, that hold the next bulk send. */\nexport const MAIL_GATEWAY_HOLD_BLOCKS = 2\n\n/** The days the hold is judged over. */\nexport const MAIL_GATEWAY_WINDOW_DAYS = 30\n\n/** The days a gateway chip counts. */\nexport const MAIL_GATEWAY_CHIP_DAYS = 7\n\n/** How long a domain's MX answer is trusted before it is looked up again. */\nexport const MAIL_DOMAIN_INTEL_TTL_MS = 7 * 24 * 60 * 60 * 1000\n\n/**\n * How long an answer that the domain takes NO mail is trusted: a day, so a\n * domain whose owner is still setting up its mail is looked at again soon.\n */\nexport const MAIL_DOMAIN_NEGATIVE_TTL_MS = 24 * 60 * 60 * 1000\n\n/** A send with no bounce this long after it counts as delivered, where no delivery event is reported. */\nexport const MAIL_GATEWAY_DELIVERED_AFTER_MS = 24 * 60 * 60 * 1000\n\n/**\n * The deliveries one ledger counts per UTC day at most.\n *\n * The hold asks whether a gateway delivered ANYTHING in the window, and the\n * chip reads a week of counts; neither needs the ten-thousandth delivery of\n * a campaign to Gmail, and a ledger document written once per delivery\n * event would be the hottest document in the database. A day that reached\n * the cap reads \"at least\".\n */\nexport const MAIL_GATEWAY_DAILY_DELIVERY_CAP = 50\n\n/** The MX host suffixes each named gateway answers as, lowercase, no trailing dot. */\nconst GATEWAY_HOST_SUFFIXES: ReadonlyArray<readonly [MailGateway, readonly string[]]> = [\n ['barracuda', ['.ess.barracudanetworks.com', '.barracudanetworks.com']],\n ['proofpoint', ['.pphosted.com', '.ppe-hosted.com', '.proofpoint.com']],\n ['mimecast', ['.mimecast.com', '.mimecast.co.za', '.mimecast-offshore.com']],\n ['google', ['.google.com', '.googlemail.com', '.gmail.com']],\n ['microsoft', ['.mail.protection.outlook.com', '.outlook.com', '.protection.outlook.com']],\n]\n\n/** A host as the suffix table reads it: lowercase, trimmed, no trailing dot. */\nexport function normalizeMailHost(host: unknown): string {\n return String(host ?? '')\n .trim()\n .toLowerCase()\n .replace(/\\.$/, '')\n}\n\n/** Whether a value names one of the gateways. */\nexport function isMailGateway(value: unknown): value is MailGateway {\n return (MAIL_GATEWAYS as readonly unknown[]).includes(value)\n}\n\n/**\n * The gateway ONE host names, or `null` when it names none of the known\n * ones — a Remote-MTA in a bounce, or one MX exchange.\n */\nexport function mailGatewayOfHost(host: unknown): MailGateway | null {\n const name = normalizeMailHost(host)\n if (!name) return null\n for (const [gateway, suffixes] of GATEWAY_HOST_SUFFIXES) {\n if (suffixes.some((suffix) => name === suffix.slice(1) || name.endsWith(suffix))) return gateway\n }\n return null\n}\n\n/**\n * The gateway a domain's MX records name. A domain whose exchanges name more\n * than one is read as the first named one in MX order (the list is handed\n * over lowest-preference-first); one that names none is `other`; an empty\n * list, or RFC 7505's lone \".\", is `none`.\n */\nexport function classifyMailGateway(mxHosts: readonly string[]): MailGateway {\n const hosts = mxHosts.map(normalizeMailHost).filter(Boolean)\n if (!hosts.length) return 'none'\n for (const host of hosts) {\n const gateway = mailGatewayOfHost(host)\n if (gateway) return gateway\n }\n return 'other'\n}\n\n/** Whether the gateway is one of the security gateways that refuse a sender for a whole company. */\nexport function isMailGatewayFronted(gateway: MailGateway | null | undefined): boolean {\n return gateway !== null && gateway !== undefined && MAIL_GATEWAYS_FRONTED.includes(gateway)\n}\n\n/** The counts one UTC day holds on a gateway's ledger. */\nexport interface MailGatewayDayCounts {\n sent: number\n delivered: number\n blocked: number\n}\n\n/** What a send met at a gateway, as the ledger counts it. */\nexport type MailGatewayOutcome = keyof MailGatewayDayCounts\n\n/** `YYYY-MM-DD` in UTC: the day a ledger entry is filed under. */\nexport function mailGatewayDay(atMs: number): string {\n return new Date(atMs).toISOString().slice(0, 10)\n}\n\nconst count = (value: unknown): number => {\n const number = Number(value)\n return Number.isFinite(number) ? Math.max(0, Math.floor(number)) : 0\n}\n\n/** The ledger's counts summed over the last `windowDays` UTC days, today included. */\nexport function mailGatewayWindow(\n days: Record<string, Partial<MailGatewayDayCounts>> | null | undefined,\n nowMs: number,\n windowDays: number,\n): MailGatewayDayCounts {\n const summed: MailGatewayDayCounts = { sent: 0, delivered: 0, blocked: 0 }\n if (!days) return summed\n const oldest = mailGatewayDay(nowMs - (windowDays - 1) * 86_400_000)\n const newest = mailGatewayDay(nowMs)\n for (const [day, counts] of Object.entries(days)) {\n if (day < oldest || day > newest) continue\n summed.sent += count(counts?.sent)\n summed.delivered += count(counts?.delivered)\n summed.blocked += count(counts?.blocked)\n }\n return summed\n}\n\n/** The ledger's days with everything older than `keepDays` dropped. */\nexport function pruneMailGatewayDays<T>(\n days: Record<string, T> | null | undefined,\n nowMs: number,\n keepDays: number,\n): Record<string, T> {\n const oldest = mailGatewayDay(nowMs - (keepDays - 1) * 86_400_000)\n return Object.fromEntries(Object.entries(days ?? {}).filter(([day]) => day >= oldest))\n}\n\n/*==========================================\n * THE LEDGER, keyed SENDING DOMAIN × GATEWAY.\n *==========================================*/\n\n/**\n * One sending domain's record with one gateway: totals, and the same counts\n * by UTC day for the windows the hold and the chip read. Stored by the\n * server layer under {@link mailGatewayLedgerKey}.\n */\nexport interface MailGatewayLedger {\n /** The domain the mail left from — the `From:` domain, or a mailbox's. */\n sendingDomain: string\n gateway: MailGateway\n sent: number\n delivered: number\n blocked: number\n lastBlockedAtMs: number | null\n /** The last refusal's diagnostic, scrubbed of addresses, for the staff table. */\n lastBlockedDetail: string | null\n /** `YYYY-MM-DD` (UTC) → that day's counts, the last thirty days kept. */\n days: Record<string, MailGatewayDayCounts>\n updatedAtMs: number\n}\n\n/** A sending domain as the ledger spells it: lowercase, no trailing dot, `null` for none. */\nexport function normalizeLedgerSendingDomain(value: unknown): string | null {\n const raw = String(value ?? '')\n .trim()\n .toLowerCase()\n const domain = (raw.includes('@') ? raw.slice(raw.lastIndexOf('@') + 1) : raw).replace(/[>\\s]+$/, '').replace(/\\.$/, '')\n return /^[a-z0-9-]+(\\.[a-z0-9-]+)+$/.test(domain) ? domain : null\n}\n\n/**\n * The document id a ledger is stored under: `aglyn.io~barracuda`. A `~`\n * because a domain never holds one and a Firestore id may.\n */\nexport function mailGatewayLedgerKey(sendingDomain: string, gateway: MailGateway): string {\n return `${sendingDomain}~${gateway}`\n}\n\n/** A stored ledger in its model shape, or `null` for no document or an unreadable one. */\nexport function readStoredMailGatewayLedger(\n id: string,\n data: Record<string, unknown> | null | undefined,\n): MailGatewayLedger | null {\n if (!data) return null\n const [idDomain, idGateway] = String(id ?? '').split('~')\n const gateway = isMailGateway(data['gateway']) ? data['gateway'] : isMailGateway(idGateway) ? idGateway : null\n const sendingDomain = normalizeLedgerSendingDomain(data['sendingDomain'] ?? idDomain)\n if (!gateway || !sendingDomain) return null\n const days: MailGatewayLedger['days'] = {}\n const raw = data['days']\n if (raw && typeof raw === 'object') {\n for (const [day, counts] of Object.entries(raw as Record<string, unknown>)) {\n const entry = (counts && typeof counts === 'object' ? counts : {}) as Record<string, unknown>\n days[day] = { sent: count(entry['sent']), delivered: count(entry['delivered']), blocked: count(entry['blocked']) }\n }\n }\n const ms = (value: unknown): number | null => (typeof value === 'number' && Number.isFinite(value) ? value : null)\n return {\n sendingDomain,\n gateway,\n sent: count(data['sent']),\n delivered: count(data['delivered']),\n blocked: count(data['blocked']),\n lastBlockedAtMs: ms(data['lastBlockedAtMs']),\n lastBlockedDetail: typeof data['lastBlockedDetail'] === 'string' ? data['lastBlockedDetail'] : null,\n days,\n updatedAtMs: ms(data['updatedAtMs']) ?? 0,\n }\n}\n\n/**\n * The ledger after one more outcome — the pure half of the write, which the\n * store runs inside its transaction. Deliveries stop counting for the day at\n * {@link MAIL_GATEWAY_DAILY_DELIVERY_CAP}; answers `null` when the write\n * would change nothing, so the store can skip it.\n */\nexport function applyMailGatewayOutcome(\n ledger: MailGatewayLedger | null,\n input: {\n sendingDomain: string\n gateway: MailGateway\n outcome: MailGatewayOutcome\n count?: number\n atMs: number\n detail?: string | null\n },\n): MailGatewayLedger | null {\n const day = mailGatewayDay(input.atMs)\n const days = pruneMailGatewayDays(ledger?.days, input.atMs, MAIL_GATEWAY_WINDOW_DAYS)\n const today = days[day] ?? { sent: 0, delivered: 0, blocked: 0 }\n let by = count(input.count ?? 1)\n if (input.outcome === 'delivered') by = Math.min(by, Math.max(0, MAIL_GATEWAY_DAILY_DELIVERY_CAP - today.delivered))\n if (!by) return null\n days[day] = { ...today, [input.outcome]: today[input.outcome] + by }\n const blocked = input.outcome === 'blocked'\n return {\n sendingDomain: input.sendingDomain,\n gateway: input.gateway,\n sent: (ledger?.sent ?? 0) + (input.outcome === 'sent' ? by : 0),\n delivered: (ledger?.delivered ?? 0) + (input.outcome === 'delivered' ? by : 0),\n blocked: (ledger?.blocked ?? 0) + (blocked ? by : 0),\n lastBlockedAtMs: blocked ? Math.max(ledger?.lastBlockedAtMs ?? 0, input.atMs) : (ledger?.lastBlockedAtMs ?? null),\n lastBlockedDetail: blocked\n ? String(input.detail ?? '').trim().slice(0, 300) || (ledger?.lastBlockedDetail ?? null)\n : (ledger?.lastBlockedDetail ?? null),\n days,\n updatedAtMs: input.atMs,\n }\n}\n\n/**\n * What a surface knows about the gateway in front of one address: which it\n * is, and what it did with this sending domain's mail over the chip's week\n * and the hold's month.\n */\nexport interface MailGatewayStanding {\n gateway: MailGateway\n /** Refusals and deliveries in the last {@link MAIL_GATEWAY_CHIP_DAYS} days. */\n blocked7: number\n delivered7: number\n /** Refusals and deliveries in the last {@link MAIL_GATEWAY_WINDOW_DAYS} days. */\n blocked30: number\n delivered30: number\n}\n\n/** A gateway's standing with a sending domain, read off its ledger (or none yet). */\nexport function mailGatewayStanding(\n gateway: MailGateway,\n ledger: Pick<MailGatewayLedger, 'days'> | null | undefined,\n nowMs: number,\n): MailGatewayStanding {\n const week = mailGatewayWindow(ledger?.days, nowMs, MAIL_GATEWAY_CHIP_DAYS)\n const month = mailGatewayWindow(ledger?.days, nowMs, MAIL_GATEWAY_WINDOW_DAYS)\n return {\n gateway,\n blocked7: week.blocked,\n delivered7: week.delivered,\n blocked30: month.blocked,\n delivered30: month.delivered,\n }\n}\n\n/**\n * Whether the gateway holds the next bulk send — see the module note. A\n * named gateway only: `other` is many exchanges, and `none` refuses instead.\n */\nexport function mailGatewayHolds(standing: Pick<MailGatewayStanding, 'gateway' | 'blocked30' | 'delivered30'>): boolean {\n if (standing.gateway === 'other' || standing.gateway === 'none') return false\n return count(standing.blocked30) >= MAIL_GATEWAY_HOLD_BLOCKS && count(standing.delivered30) === 0\n}\n\n/** Why a gateway holds, in one sentence a surface or a send record can carry. */\nexport function mailGatewayHoldSentence(gateway: MailGateway, blocked30: number): string {\n const name = MAIL_GATEWAY_LABELS[gateway]\n return (\n `${name} refused this sender ${blocked30 === 2 ? 'twice' : `${blocked30} times`} in the last ` +\n `${MAIL_GATEWAY_WINDOW_DAYS} days and delivered nothing`\n )\n}\n\n/** The chip's tone: what color a surface gives it. */\nexport type MailGatewayChipTone = 'refused' | 'delivered' | 'neutral' | 'blocked'\n\nexport interface MailGatewayChip {\n label: string\n tone: MailGatewayChipTone\n}\n\n/**\n * The chip for one address, or `null` when there is nothing worth a chip: an\n * unrecognized exchange with no verdict this week. Red for a gateway that\n * refused this week, green for one that delivered, neutral for the hosted\n * providers and the security gateways with no verdict yet, and blocked for a\n * domain with no MX.\n */\nexport function mailGatewayChip(standing: MailGatewayStanding | null | undefined): MailGatewayChip | null {\n if (!standing) return null\n const name = MAIL_GATEWAY_LABELS[standing.gateway]\n if (standing.gateway === 'none') return { label: 'No MX record — cannot receive mail', tone: 'blocked' }\n const blocked = count(standing.blocked7)\n const delivered = count(standing.delivered7)\n const settled = blocked + delivered\n if (blocked > 0) {\n return {\n label: `${name} · ${blocked} of ${settled} ${settled === 1 ? 'send' : 'sends'} refused this week`,\n tone: 'refused',\n }\n }\n if (delivered > 0) {\n return { label: `${name} · ${delivered} of ${settled} delivered this week`, tone: 'delivered' }\n }\n if (standing.gateway === 'other') return null\n return { label: name, tone: 'neutral' }\n}\n"],"names":["MAIL_GATEWAYS","MAIL_GATEWAY_LABELS","barracuda","proofpoint","mimecast","google","microsoft","other","none","MAIL_GATEWAYS_FRONTED","MAIL_GATEWAY_HOLD_BLOCKS","MAIL_GATEWAY_WINDOW_DAYS","MAIL_GATEWAY_CHIP_DAYS","MAIL_DOMAIN_INTEL_TTL_MS","MAIL_DOMAIN_NEGATIVE_TTL_MS","MAIL_GATEWAY_DELIVERED_AFTER_MS","MAIL_GATEWAY_DAILY_DELIVERY_CAP","GATEWAY_HOST_SUFFIXES","normalizeMailHost","host","String","trim","toLowerCase","replace","isMailGateway","value","includes","mailGatewayOfHost","name","gateway","suffixes","some","suffix","slice","endsWith","classifyMailGateway","mxHosts","hosts","map","filter","Boolean","length","isMailGatewayFronted","undefined","mailGatewayDay","atMs","Date","toISOString","count","number","Number","isFinite","Math","max","floor","mailGatewayWindow","days","nowMs","windowDays","summed","sent","delivered","blocked","oldest","newest","day","counts","Object","entries","pruneMailGatewayDays","keepDays","fromEntries","normalizeLedgerSendingDomain","raw","domain","lastIndexOf","test","mailGatewayLedgerKey","sendingDomain","readStoredMailGatewayLedger","id","data","ms","idDomain","idGateway","split","entry","lastBlockedAtMs","lastBlockedDetail","updatedAtMs","applyMailGatewayOutcome","ledger","input","today","by","outcome","min","detail","mailGatewayStanding","week","month","blocked7","delivered7","blocked30","delivered30","mailGatewayHolds","standing","mailGatewayHoldSentence","mailGatewayChip","label","tone","settled"],"mappings":"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"}
@@ -0,0 +1,220 @@
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
+ /** One brand the screen recognizes. */
18
+ export interface PhishingScreenBrand {
19
+ /** Stable id, as stored on a hold. */
20
+ id: string;
21
+ /** How staff read it. */
22
+ label: string;
23
+ /** Copy that names the brand — subject, body, sender. Case-insensitive. */
24
+ mention: RegExp;
25
+ /**
26
+ * Host labels that name the brand. Matched against each dot-separated
27
+ * label of a link's host, whole or hyphen-joined, after leetspeak is
28
+ * folded — never as a bare substring, which is how `amazonaws` or
29
+ * `bookingengine` would read as a brand.
30
+ */
31
+ hostTokens: readonly string[];
32
+ /**
33
+ * Registrable domains the brand genuinely links from. The FIRST is its
34
+ * primary domain, the one a kit embeds in a longer host.
35
+ */
36
+ officialDomains: readonly string[];
37
+ /**
38
+ * The brand's name is an ordinary word (`apple`, `amazon`), so a host
39
+ * label names the brand only when it IS the word — `apple.example.top` —
40
+ * and never hyphen-joined or leet-folded, which `apple-orchard-farm.com`
41
+ * would otherwise be.
42
+ */
43
+ wordToken?: boolean;
44
+ /**
45
+ * Whether `<brand>.<country code>` (and `<brand>.co.<cc>`) is the brand's
46
+ * too — true for the marketplaces that run a site per country.
47
+ */
48
+ countryDomains?: boolean;
49
+ }
50
+ export declare const PHISHING_SCREEN_BRANDS: readonly PhishingScreenBrand[];
51
+ /**
52
+ * Account-action and credential phrasing — the half of a lure that asks the
53
+ * reader to DO something with an account. Only ever counted beside a brand
54
+ * AND a foreign link; alone, several of these are ordinary transactional copy.
55
+ */
56
+ export declare const PHISHING_LURE_PATTERNS: readonly RegExp[];
57
+ /** A signal the screen found. */
58
+ export type PhishingScreenSignal = {
59
+ code: 'lookalike-link';
60
+ brand: string;
61
+ host: string;
62
+ } | {
63
+ code: 'brand-sender';
64
+ brand: string;
65
+ fromName: string;
66
+ } | {
67
+ code: 'brand-lure-link';
68
+ brand: string;
69
+ lure: string;
70
+ host: string;
71
+ }
72
+ /**
73
+ * A published page asks for a password, a card number or a one-time code
74
+ * in a field that is not one of the platform's own sign-in or checkout
75
+ * elements (`hosted-page-screen.ts`). Strong.
76
+ */
77
+ | {
78
+ code: 'credential-field';
79
+ field: 'password' | 'card' | 'otp';
80
+ label: string;
81
+ }
82
+ /**
83
+ * A published page names a brand that is not the workspace's beside a
84
+ * sign-in, verify or payment call to action (`hosted-page-screen.ts`).
85
+ * Soft.
86
+ */
87
+ | {
88
+ code: 'brand-action-page';
89
+ brand: string;
90
+ action: string;
91
+ };
92
+ export interface PhishingScreenInput {
93
+ subject?: string | null;
94
+ /** The display name in front of the sending address. */
95
+ fromName?: string | null;
96
+ /**
97
+ * The ADDRESS the message leaves from (AGL-3362) — a `From:` domain is a
98
+ * link the recipient never has to click, so its host is read by the
99
+ * lookalike rule exactly like a link's: mail from `support@paypa1.com` is
100
+ * the disguise whoever sends it. Not an own domain for the lure's "is this
101
+ * elsewhere" test either way; it only ever adds a lookalike signal.
102
+ */
103
+ fromAddress?: string | null;
104
+ replyTo?: string | readonly string[] | null;
105
+ preheader?: string | null;
106
+ /**
107
+ * Everything else the recipient reads or clicks: the body, the plain-text
108
+ * part, a design's nodes serialized — any string. Links are found by
109
+ * shape, so a JSON-encoded design is as readable here as HTML.
110
+ */
111
+ bodies?: readonly (string | null | undefined)[];
112
+ /**
113
+ * The workspace's own names — org, site, subdomain. A brand in one of them
114
+ * is the workspace's own, and `brand-sender` / `brand-lure-link` do not
115
+ * fire for it. The lookalike check does not consult names: a workspace
116
+ * NAMED "Poshmark" linking to `poshmark.id63835663.shop` is the incident.
117
+ */
118
+ ownNames?: readonly (string | null | undefined)[];
119
+ /**
120
+ * The workspace's own hosts — its subdomain on the tenant apex, custom
121
+ * domain, sending domain. A link to one of them, or below one, is never
122
+ * the "somewhere else" of a lure. Matched by host, never by registrable
123
+ * domain: every site's subdomain shares the tenant apex, and a link to a
124
+ * NEIGHBOR's site is somewhere else.
125
+ *
126
+ * Not an exemption from the lookalike check. A host shaped like
127
+ * `paypal-secure.com` is a lookalike whoever attached it, and a workspace
128
+ * that attached it has attached the disguise.
129
+ */
130
+ ownDomains?: readonly (string | null | undefined)[];
131
+ }
132
+ export interface PhishingScreenVerdict {
133
+ /** True when the message should be held for staff review. */
134
+ hold: boolean;
135
+ signals: PhishingScreenSignal[];
136
+ }
137
+ /** The registrable domain of a host: `a.b.evil.co.uk` → `evil.co.uk`. */
138
+ export declare function registrableDomain(host: string): string;
139
+ /** Every brand's real domains, for "is this link just the brand itself". */
140
+ declare function isAnyOfficialDomain(registrable: string): boolean;
141
+ /** The brand a host impersonates, or null. */
142
+ export declare function lookalikeBrandForHost(host: string): PhishingScreenBrand | null;
143
+ /** The hosts every link in the text points at, lowercased. */
144
+ export declare function linkHostsIn(text: string): string[];
145
+ /** Lowercased alphanumerics only, so "Wells-Fargo Advisors" contains `wellsfargo`. */
146
+ declare function squash(text: string): string;
147
+ /** Is this brand the workspace's own, by name or by a domain it holds? */
148
+ declare function isOwnBrand(brand: PhishingScreenBrand, ownNames: string, ownRegistrables: ReadonlySet<string>): boolean;
149
+ /** `squash`: lowercased alphanumerics, the form own-name matching reads. */
150
+ export declare const squashScreenText: typeof squash;
151
+ /** Is this brand the workspace's own, by name (squashed) or by a domain it holds? */
152
+ export declare const isWorkspaceOwnBrand: typeof isOwnBrand;
153
+ /** Is this registrable domain one of any listed brand's own? */
154
+ export declare const isAnyOfficialBrandDomain: typeof isAnyOfficialDomain;
155
+ /** Is this registrable domain a social, maps or review link merchants ordinarily carry? */
156
+ export declare function isCommonLinkDomain(registrable: string): boolean;
157
+ /**
158
+ * Screen one outbound message. See the module header for what holds and why.
159
+ */
160
+ export declare function screenOutboundEmail(input: PhishingScreenInput): PhishingScreenVerdict;
161
+ /** The brand's label for a stored id, for the staff surface. */
162
+ export declare function phishingScreenBrandLabel(id: string): string;
163
+ /** One line per signal, in words staff read on the review row. */
164
+ export declare function describePhishingScreenSignals(signals: readonly PhishingScreenSignal[]): string[];
165
+ /** A workspace younger than this many days has the soft rules applied. */
166
+ export declare const OUTBOUND_REVIEW_YOUNG_DAYS = 14;
167
+ /** Which tier a signal belongs to. */
168
+ export type PhishingSignalTier = 'strong' | 'soft';
169
+ /** The signal codes that hold for every workspace. */
170
+ export declare const STRONG_PHISHING_SIGNAL_CODES: ReadonlySet<string>;
171
+ export declare function phishingSignalTier(signal: {
172
+ code: string;
173
+ }): PhishingSignalTier;
174
+ /** Is a workspace of this age (days, `null` = unreadable) screened by the soft rules? */
175
+ export declare function isYoungWorkspaceAge(ageDays: number | null | undefined): boolean;
176
+ export interface OutboundScreenPolicy {
177
+ /**
178
+ * The workspace's age in days, or `null` when its creation date cannot be
179
+ * read — an org that predates the field, which is an EXISTING customer and
180
+ * is not young.
181
+ */
182
+ ageDays: number | null;
183
+ /**
184
+ * The recipient's own act made this message owed: a receipt for their
185
+ * order, a confirmation of their booking, a reset of their password. The
186
+ * soft rules never hold it. The lookalike rule still does — a receipt that
187
+ * links to a Poshmark lookalike is the disguise, not the receipt.
188
+ */
189
+ owed?: boolean;
190
+ }
191
+ /** The signals that HOLD under the tiers, for this workspace and message. */
192
+ export declare function signalsThatHold<T extends {
193
+ code: string;
194
+ }>(signals: readonly T[], policy: OutboundScreenPolicy): T[];
195
+ /**
196
+ * The same brand, lookalike and tier logic applied to a HOST NAME a workspace
197
+ * asks for — a subdomain on the tenant apex, or a custom domain it attaches.
198
+ *
199
+ * Words a brand's impersonator hyphen-joins to its name. A subdomain that
200
+ * joins a brand's word to one of these (`booking-review`, `apple-support`,
201
+ * `amazon-account`) is the incident's shape even where the brand's word is
202
+ * an ordinary one — which the lookalike rule alone deliberately lets pass,
203
+ * so a salon can be `tanyas-booking` and a bakery `apple-pie-co`.
204
+ */
205
+ export declare const BRAND_IMPERSONATION_WORDS: ReadonlySet<string>;
206
+ /**
207
+ * The brand a requested subdomain label wears, or null.
208
+ *
209
+ * Refused for EVERY workspace, at creation and at rename — the tier is
210
+ * strong, because nothing is lost by asking a real bakery to pick
211
+ * `apple-pie-co` instead of `apple-support`, and a brand's name on our apex
212
+ * is a disguise that outlives every later screen. Two shapes:
213
+ *
214
+ * 1. the label is a lookalike host label ({@link lookalikeBrandForHost} on
215
+ * `<label>.<apex>`): `poshmark`, `paypal-secure`, `paypa1`, `appleid`;
216
+ * 2. a brand's word hyphen-joined to an impersonation word
217
+ * ({@link BRAND_IMPERSONATION_WORDS}): `booking-review`, `apple-support`.
218
+ */
219
+ export declare function brandForSubdomainLabel(label: string, apex?: string): PhishingScreenBrand | null;
220
+ export {};