@aglyn/shared-util-email 1.0.0-beta.200 → 1.0.0-beta.202

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +3 -3
  2. package/src/index.d.ts +6 -0
  3. package/src/index.js +16 -0
  4. package/src/index.js.map +1 -1
  5. package/src/lib/email-deliverability.d.ts +200 -0
  6. package/src/lib/email-deliverability.js +461 -0
  7. package/src/lib/email-deliverability.js.map +1 -0
  8. package/src/lib/email-delivery-events.d.ts +16 -39
  9. package/src/lib/email-delivery-events.js +28 -12
  10. package/src/lib/email-delivery-events.js.map +1 -1
  11. package/src/lib/email-linkify.d.ts +24 -0
  12. package/src/lib/email-linkify.js +73 -0
  13. package/src/lib/email-linkify.js.map +1 -0
  14. package/src/lib/email-render.js +6 -1
  15. package/src/lib/email-render.js.map +1 -1
  16. package/src/lib/hosted-page-screen.d.ts +60 -0
  17. package/src/lib/hosted-page-screen.js +314 -0
  18. package/src/lib/hosted-page-screen.js.map +1 -0
  19. package/src/lib/mail-bounce.d.ts +42 -0
  20. package/src/lib/mail-bounce.js +108 -0
  21. package/src/lib/mail-bounce.js.map +1 -0
  22. package/src/lib/mail-gateway.d.ts +161 -0
  23. package/src/lib/mail-gateway.js +330 -0
  24. package/src/lib/mail-gateway.js.map +1 -0
  25. package/src/lib/outbound-phishing-screen.d.ts +248 -0
  26. package/src/lib/outbound-phishing-screen.js +769 -0
  27. package/src/lib/outbound-phishing-screen.js.map +1 -0
  28. package/src/lib/outbound-screen-gate.d.ts +95 -0
  29. package/src/lib/outbound-screen-gate.js +109 -0
  30. package/src/lib/outbound-screen-gate.js.map +1 -0
  31. package/src/lib/send-email.d.ts +79 -1
  32. package/src/lib/send-email.js +153 -10
  33. package/src/lib/send-email.js.map +1 -1
  34. package/src/lib/sending-domain.d.ts +22 -1
  35. package/src/lib/sending-domain.js +0 -95
  36. package/src/lib/sending-domain.js.map +1 -1
  37. package/src/lib/system-email-catalog.js +400 -0
  38. package/src/lib/system-email-catalog.js.map +1 -1
  39. package/src/lib/text-email-html.d.ts +38 -0
  40. package/src/lib/text-email-html.js +3 -44
  41. package/src/lib/text-email-html.js.map +1 -1
@@ -0,0 +1,60 @@
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 PhishingScreenSignal } from './outbound-phishing-screen';
18
+ export interface HostedPageScreenInput {
19
+ /**
20
+ * The page as it will be served — the composed tree, or any object that
21
+ * holds nodes (`{ componentId, props }`) at any depth. Walked, never
22
+ * trusted to have one shape: a screen, a layout's chrome, a grafted
23
+ * component and a form all arrive together.
24
+ */
25
+ nodes: unknown;
26
+ /** The workspace's own names, as `screenOutboundEmail` reads them. */
27
+ ownNames?: readonly (string | null | undefined)[];
28
+ /** The workspace's own hosts — a link to one is never "elsewhere". */
29
+ ownDomains?: readonly (string | null | undefined)[];
30
+ }
31
+ export interface HostedPageScreenVerdict {
32
+ signals: PhishingScreenSignal[];
33
+ }
34
+ /**
35
+ * The credential an input asks for, read from the two attributes that decide
36
+ * it in the browser — its `type` and its `autocomplete` token — or null.
37
+ * For code that builds inputs rather than storing them as page props: the
38
+ * marketplace verifier reads a plugin bundle's `{ type: 'password' }` and
39
+ * `setAttribute('autocomplete', 'cc-number')` with this (AGL-3362).
40
+ */
41
+ export declare function credentialForInputAttribute(attribute: string, value: string): 'password' | 'card' | 'otp' | null;
42
+ /** The credential a piece of raw HTML asks for in an input of its own, or null. */
43
+ export declare function credentialInHtml(html: string): 'password' | 'card' | 'otp' | null;
44
+ /**
45
+ * Does a submitted field's NAME ask for a credential — a password, a card
46
+ * number or security code, a one-time code? The same wording the page
47
+ * screen holds a field for, read over a name as a form posts it
48
+ * (`card_number`, `cardNumber`, `one-time-code`).
49
+ *
50
+ * For the one path that does not pass a publish: a public form endpoint
51
+ * takes whatever field names a request carries, so a page the screen never
52
+ * saw (third-party code, a hand-built request) could still post a password
53
+ * to it. The endpoint drops such a field rather than store it.
54
+ */
55
+ export declare function isCredentialFieldName(name: string): boolean;
56
+ /**
57
+ * Screen one page as it will be served. The caller applies the tiers with
58
+ * `signalsThatHold` and decides what a hold does.
59
+ */
60
+ export declare function screenHostedPage(input: HostedPageScreenInput): HostedPageScreenVerdict;
@@ -0,0 +1,314 @@
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 HOSTED PAGE SCREEN (AGL-3362).
18
+ *
19
+ * The outbound phishing screen, read over a PUBLISHED PAGE instead of an
20
+ * email: the same brand list, the same lookalike logic, the same tiers
21
+ * (`signalsThatHold`). A fraud actor who could not mail a lure could still
22
+ * host one, and the incident's sibling site published a page at the
23
+ * document-share path `/reviewfile`.
24
+ *
25
+ * Three shapes, one per requirement:
26
+ *
27
+ * 1. **A lookalike link or embed** (`lookalike-link`, STRONG) — any `href`,
28
+ * `src`, form action or URL in the page's authored content whose host
29
+ * wears a brand without being the brand's domain.
30
+ * 2. **A credential field** (`credential-field`, STRONG) — a field the
31
+ * merchant AUTHORED that asks for a password, a card number or a
32
+ * one-time code: its type, its autocomplete token, or its label,
33
+ * placeholder or name. The platform's own sign-in, account and checkout
34
+ * elements render their inputs in code, not in stored props, so they are
35
+ * never read as a field here — which is exactly the sanctioned/unsanctioned
36
+ * line: what the platform collects through its own components is
37
+ * Aglyn's to protect, and what a page's author collects in a form of
38
+ * their own is not something a site may ask a stranger for.
39
+ * 3. **A brand's call to action** (`brand-action-page`, SOFT) — one element
40
+ * whose text names a brand that is not the workspace's AND asks the reader
41
+ * to sign in, verify, confirm or open a document, on a page that links
42
+ * somewhere that is neither the workspace's, nor a brand's own, nor a
43
+ * common social or maps link. The same element, not the same page: a
44
+ * hotel that says "find us on Booking.com" in one place and "sign in" in
45
+ * another is two ordinary sentences.
46
+ *
47
+ * Pure: the caller hands over the composed node tree and the workspace's own
48
+ * names and hosts, and decides what a hold does.
49
+ *=========================================*/ import { isWorkspaceOwnBrand, linkHostsIn, lookalikeBrandForHost, PHISHING_LURE_PATTERNS, PHISHING_SCREEN_BRANDS, registrableDomain, isAnyOfficialBrandDomain, isCommonLinkDomain, squashScreenText } from "./outbound-phishing-screen.js";
50
+ /** The most a page's text is read to, so a huge page costs a bounded scan. */ const MAX_PAGE_TEXT = 2000000;
51
+ /** The most nodes the walk visits. */ const MAX_NODES = 20000;
52
+ /** Props that describe a field the author defined, rather than content. */ const FIELD_PROP_KEYS = [
53
+ 'fieldType',
54
+ 'fieldName',
55
+ 'inputType',
56
+ 'autoComplete',
57
+ 'autocomplete'
58
+ ];
59
+ /** Autocomplete tokens (WHATWG) that name a credential. */ const CREDENTIAL_AUTOCOMPLETE = {
60
+ 'current-password': 'password',
61
+ 'new-password': 'password',
62
+ 'cc-number': 'card',
63
+ 'cc-csc': 'card',
64
+ 'cc-exp': 'card',
65
+ 'one-time-code': 'otp'
66
+ };
67
+ /** Field wording that asks for a credential, by kind. */ const CREDENTIAL_LABELS = [
68
+ {
69
+ field: 'password',
70
+ pattern: /\bpass\s?(?:word|code|phrase)s?\b|\bpasswd\b|\bpin\s*(?:code|number)?\s*$/i
71
+ },
72
+ {
73
+ field: 'card',
74
+ pattern: /\b(?:credit|debit)\s*card\b|\bcard\s*(?:number|no\.?|#)|\bcardnumber\b|\bcvv2?\b|\bcvc\b|\bcard\s*security\s*code\b|\bcard\s*expir/i
75
+ },
76
+ {
77
+ field: 'otp',
78
+ pattern: /\bone[-\s]?time\s*(?:pass\s?code|password|code|pin)\b|\botp\b|\bverification\s*code\b|\b2fa\b|\btwo[-\s]?factor\b|\bauthenticat(?:ion|or)\s*code\b|\bsms\s*code\b/i
79
+ }
80
+ ];
81
+ /** Raw HTML an author placed that holds a credential input. */ const HTML_CREDENTIAL_INPUTS = [
82
+ {
83
+ field: 'password',
84
+ pattern: /<input\b[^>]*\btype\s*=\s*["']?password/i
85
+ },
86
+ {
87
+ field: 'password',
88
+ pattern: /<input\b[^>]*\b(?:name|autocomplete)\s*=\s*["']?(?:pass(?:word|wd)?|current-password|new-password)\b/i
89
+ },
90
+ {
91
+ field: 'card',
92
+ pattern: /<input\b[^>]*\b(?:name|autocomplete)\s*=\s*["']?(?:card_?number|cc-number|cc-csc|cvv|cvc)\b/i
93
+ },
94
+ {
95
+ field: 'otp',
96
+ pattern: /<input\b[^>]*\b(?:name|autocomplete)\s*=\s*["']?(?:otp|one-time-code|verification_?code)\b/i
97
+ }
98
+ ];
99
+ /**
100
+ * What an element asks its reader to DO with an account or a document —
101
+ * counted only beside a brand in the SAME element. The email lure phrasing
102
+ * counts too.
103
+ */ const PAGE_ACTION_PATTERNS = [
104
+ /\b(?:sign|log)[\s-]?in\b/i,
105
+ /\bverify\b/i,
106
+ /\bconfirm\s+(?:your|the)\b/i,
107
+ /\bunlock\b/i,
108
+ /\benter\s+your\b/i,
109
+ /\b(?:view|review|open|download)\s+(?:the\s+|your\s+)?(?:shared\s+)?(?:file|document|invoice|statement)s?\b/i
110
+ ];
111
+ /** Every node in the tree, breadth-first in document order, bounded. */ function walkNodes(root) {
112
+ const found = [];
113
+ const seen = new Set();
114
+ const stack = [
115
+ root
116
+ ];
117
+ for(let head = 0; head < stack.length && found.length < MAX_NODES; head += 1){
118
+ const value = stack[head];
119
+ if (!value || typeof value !== 'object' || seen.has(value)) continue;
120
+ seen.add(value);
121
+ const record = value;
122
+ if (typeof record['componentId'] === 'string') {
123
+ const props = record['props'];
124
+ found.push({
125
+ componentId: record['componentId'],
126
+ props: props && typeof props === 'object' ? props : {}
127
+ });
128
+ }
129
+ for (const child of Array.isArray(value) ? value : Object.values(record)){
130
+ if (child && typeof child === 'object') stack.push(child);
131
+ }
132
+ }
133
+ return found;
134
+ }
135
+ /** Every string a node's props hold, one level of arrays and objects deep. */ function propStrings(props, skip = new Set()) {
136
+ const out = [];
137
+ const visit = (value, depth)=>{
138
+ if (typeof value === 'string') out.push(value);
139
+ else if (depth < 3 && value && typeof value === 'object') {
140
+ for (const entry of Array.isArray(value) ? value : Object.values(value)){
141
+ visit(entry, depth + 1);
142
+ }
143
+ }
144
+ };
145
+ for (const [key, value] of Object.entries(props)){
146
+ if (key === 'sx' || skip.has(key)) continue;
147
+ visit(value, 0);
148
+ }
149
+ return out;
150
+ }
151
+ /** Does this node describe a field its author defined? */ function isAuthoredField(node) {
152
+ if (/field|input/i.test(node.componentId)) return true;
153
+ return FIELD_PROP_KEYS.some((key)=>node.props[key] !== undefined);
154
+ }
155
+ /** The credential a field asks for, or null. */ function credentialOf(node) {
156
+ const text = (key)=>typeof node.props[key] === 'string' ? node.props[key] : '';
157
+ const type = [
158
+ text('fieldType'),
159
+ text('type'),
160
+ text('inputType')
161
+ ].join(' ').toLowerCase();
162
+ const label = text('label') || text('placeholder') || text('fieldName') || text('name');
163
+ if (/\bpassword\b/.test(type)) return {
164
+ field: 'password',
165
+ label: label || 'password'
166
+ };
167
+ const autocomplete = (text('autoComplete') || text('autocomplete')).toLowerCase().trim();
168
+ const byToken = CREDENTIAL_AUTOCOMPLETE[autocomplete];
169
+ if (byToken) return {
170
+ field: byToken,
171
+ label: label || autocomplete
172
+ };
173
+ const wording = [
174
+ text('label'),
175
+ text('placeholder'),
176
+ text('fieldName'),
177
+ text('name'),
178
+ text('helperText')
179
+ ].filter(Boolean).join(' ');
180
+ for (const { field, pattern } of CREDENTIAL_LABELS){
181
+ if (pattern.test(wording)) return {
182
+ field,
183
+ label: (label || wording).slice(0, 80)
184
+ };
185
+ }
186
+ return null;
187
+ }
188
+ /**
189
+ * The credential an input asks for, read from the two attributes that decide
190
+ * it in the browser — its `type` and its `autocomplete` token — or null.
191
+ * For code that builds inputs rather than storing them as page props: the
192
+ * marketplace verifier reads a plugin bundle's `{ type: 'password' }` and
193
+ * `setAttribute('autocomplete', 'cc-number')` with this (AGL-3362).
194
+ */ export function credentialForInputAttribute(attribute, value) {
195
+ var _CREDENTIAL_AUTOCOMPLETE_token;
196
+ const name = String(attribute != null ? attribute : '').toLowerCase();
197
+ const token = String(value != null ? value : '').trim().toLowerCase();
198
+ if ((name === 'type' || name === 'inputtype') && token === 'password') return 'password';
199
+ if (name === 'autocomplete') return (_CREDENTIAL_AUTOCOMPLETE_token = CREDENTIAL_AUTOCOMPLETE[token]) != null ? _CREDENTIAL_AUTOCOMPLETE_token : null;
200
+ return null;
201
+ }
202
+ /** The credential a piece of raw HTML asks for in an input of its own, or null. */ export function credentialInHtml(html) {
203
+ var _ref;
204
+ var _HTML_CREDENTIAL_INPUTS_find;
205
+ const text = String(html != null ? html : '');
206
+ if (!text.includes('<')) return null;
207
+ return (_ref = (_HTML_CREDENTIAL_INPUTS_find = HTML_CREDENTIAL_INPUTS.find(({ pattern })=>pattern.test(text))) == null ? void 0 : _HTML_CREDENTIAL_INPUTS_find.field) != null ? _ref : null;
208
+ }
209
+ /**
210
+ * Does a submitted field's NAME ask for a credential — a password, a card
211
+ * number or security code, a one-time code? The same wording the page
212
+ * screen holds a field for, read over a name as a form posts it
213
+ * (`card_number`, `cardNumber`, `one-time-code`).
214
+ *
215
+ * For the one path that does not pass a publish: a public form endpoint
216
+ * takes whatever field names a request carries, so a page the screen never
217
+ * saw (third-party code, a hand-built request) could still post a password
218
+ * to it. The endpoint drops such a field rather than store it.
219
+ */ export function isCredentialFieldName(name) {
220
+ const words = String(name != null ? name : '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_\-.[\]]+/g, ' ').trim();
221
+ if (!words) return false;
222
+ if (CREDENTIAL_AUTOCOMPLETE[words.toLowerCase().replace(/\s+/g, '-')]) return true;
223
+ if (/^(?:pass|pwd|ccnum|cc\s?num|cc\s?number|cvv2?|cvc|csc)$/i.test(words)) return true;
224
+ return CREDENTIAL_LABELS.some(({ pattern })=>pattern.test(words));
225
+ }
226
+ /** URLs a page carries in a form Links-by-shape would miss: `//host/…`. */ function protocolRelativeUrls(strings) {
227
+ return strings.filter((value)=>/^\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)+/i.test(value.trim())).map((value)=>`https:${value.trim()}`).join('\n');
228
+ }
229
+ /**
230
+ * Screen one page as it will be served. The caller applies the tiers with
231
+ * `signalsThatHold` and decides what a hold does.
232
+ */ export function screenHostedPage(input) {
233
+ var _input_ownNames, _input_ownDomains;
234
+ const signals = [];
235
+ const nodes = walkNodes(input.nodes);
236
+ const ownNames = squashScreenText(((_input_ownNames = input.ownNames) != null ? _input_ownNames : []).filter(Boolean).join(' '));
237
+ const ownHosts = ((_input_ownDomains = input.ownDomains) != null ? _input_ownDomains : []).map((domain)=>String(domain != null ? domain : '').trim().toLowerCase().replace(/\.+$/, '')).filter((domain)=>domain.includes('.'));
238
+ const ownRegistrables = new Set(ownHosts.map(registrableDomain));
239
+ const isOwnHost = (host)=>ownHosts.some((own)=>host === own || host.endsWith(`.${own}`));
240
+ const nodeStrings = nodes.map((node)=>propStrings(node.props));
241
+ let pageText = nodeStrings.map((strings)=>strings.join('\n')).join('\n');
242
+ pageText = `${pageText}\n${protocolRelativeUrls(nodeStrings.flat())}`.slice(0, MAX_PAGE_TEXT);
243
+ // 1. A lookalike host anywhere the page links, embeds or posts to.
244
+ const linkHosts = linkHostsIn(pageText);
245
+ for (const host of linkHosts){
246
+ const brand = lookalikeBrandForHost(host);
247
+ if (brand) signals.push({
248
+ code: 'lookalike-link',
249
+ brand: brand.id,
250
+ host
251
+ });
252
+ }
253
+ // 2. A credential field the page's author defined.
254
+ const credentialSeen = new Set();
255
+ const noteCredential = (field, label)=>{
256
+ if (credentialSeen.has(field)) return;
257
+ credentialSeen.add(field);
258
+ signals.push({
259
+ code: 'credential-field',
260
+ field,
261
+ label: label.slice(0, 80)
262
+ });
263
+ };
264
+ nodes.forEach((node, index)=>{
265
+ if (isAuthoredField(node)) {
266
+ const credential = credentialOf(node);
267
+ if (credential) noteCredential(credential.field, credential.label);
268
+ }
269
+ for (const html of nodeStrings[index]){
270
+ if (!html.includes('<')) continue;
271
+ for (const { field, pattern } of HTML_CREDENTIAL_INPUTS){
272
+ if (pattern.test(html)) noteCredential(field, 'an input in the page’s own HTML');
273
+ }
274
+ }
275
+ });
276
+ // 3. A brand's call to action, in one element, on a page that links away.
277
+ const foreign = linkHosts.find((host)=>{
278
+ if (isOwnHost(host)) return false;
279
+ const registrable = registrableDomain(host);
280
+ return !isAnyOfficialBrandDomain(registrable) && !isCommonLinkDomain(registrable);
281
+ });
282
+ if (foreign) {
283
+ const actions = [
284
+ ...PAGE_ACTION_PATTERNS,
285
+ ...PHISHING_LURE_PATTERNS
286
+ ];
287
+ const named = new Set();
288
+ for (const strings of nodeStrings){
289
+ const text = strings.join(' ');
290
+ if (!text) continue;
291
+ const action = actions.map((pattern)=>{
292
+ var _text_match;
293
+ return (_text_match = text.match(pattern)) == null ? void 0 : _text_match[0];
294
+ }).find(Boolean);
295
+ if (!action) continue;
296
+ for (const brand of PHISHING_SCREEN_BRANDS){
297
+ if (named.has(brand.id)) continue;
298
+ if (brand.mention.test(text) && !isWorkspaceOwnBrand(brand, ownNames, ownRegistrables)) {
299
+ named.add(brand.id);
300
+ signals.push({
301
+ code: 'brand-action-page',
302
+ brand: brand.id,
303
+ action: action.slice(0, 80)
304
+ });
305
+ }
306
+ }
307
+ }
308
+ }
309
+ return {
310
+ signals
311
+ };
312
+ }
313
+
314
+ //# sourceMappingURL=hosted-page-screen.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/hosted-page-screen.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 HOSTED PAGE SCREEN (AGL-3362).\n *\n * The outbound phishing screen, read over a PUBLISHED PAGE instead of an\n * email: the same brand list, the same lookalike logic, the same tiers\n * (`signalsThatHold`). A fraud actor who could not mail a lure could still\n * host one, and the incident's sibling site published a page at the\n * document-share path `/reviewfile`.\n *\n * Three shapes, one per requirement:\n *\n * 1. **A lookalike link or embed** (`lookalike-link`, STRONG) — any `href`,\n * `src`, form action or URL in the page's authored content whose host\n * wears a brand without being the brand's domain.\n * 2. **A credential field** (`credential-field`, STRONG) — a field the\n * merchant AUTHORED that asks for a password, a card number or a\n * one-time code: its type, its autocomplete token, or its label,\n * placeholder or name. The platform's own sign-in, account and checkout\n * elements render their inputs in code, not in stored props, so they are\n * never read as a field here — which is exactly the sanctioned/unsanctioned\n * line: what the platform collects through its own components is\n * Aglyn's to protect, and what a page's author collects in a form of\n * their own is not something a site may ask a stranger for.\n * 3. **A brand's call to action** (`brand-action-page`, SOFT) — one element\n * whose text names a brand that is not the workspace's AND asks the reader\n * to sign in, verify, confirm or open a document, on a page that links\n * somewhere that is neither the workspace's, nor a brand's own, nor a\n * common social or maps link. The same element, not the same page: a\n * hotel that says \"find us on Booking.com\" in one place and \"sign in\" in\n * another is two ordinary sentences.\n *\n * Pure: the caller hands over the composed node tree and the workspace's own\n * names and hosts, and decides what a hold does.\n *=========================================*/\n\nimport {\n isWorkspaceOwnBrand,\n linkHostsIn,\n lookalikeBrandForHost,\n PHISHING_LURE_PATTERNS,\n PHISHING_SCREEN_BRANDS,\n type PhishingScreenSignal,\n registrableDomain,\n isAnyOfficialBrandDomain,\n isCommonLinkDomain,\n squashScreenText,\n} from './outbound-phishing-screen'\n\nexport interface HostedPageScreenInput {\n /**\n * The page as it will be served — the composed tree, or any object that\n * holds nodes (`{ componentId, props }`) at any depth. Walked, never\n * trusted to have one shape: a screen, a layout's chrome, a grafted\n * component and a form all arrive together.\n */\n nodes: unknown\n /** The workspace's own names, as `screenOutboundEmail` reads them. */\n ownNames?: readonly (string | null | undefined)[]\n /** The workspace's own hosts — a link to one is never \"elsewhere\". */\n ownDomains?: readonly (string | null | undefined)[]\n}\n\nexport interface HostedPageScreenVerdict {\n signals: PhishingScreenSignal[]\n}\n\n/** A node as the walk finds it. */\ninterface WalkedNode {\n componentId: string\n props: Record<string, unknown>\n}\n\n/** The most a page's text is read to, so a huge page costs a bounded scan. */\nconst MAX_PAGE_TEXT = 2_000_000\n/** The most nodes the walk visits. */\nconst MAX_NODES = 20_000\n\n/** Props that describe a field the author defined, rather than content. */\nconst FIELD_PROP_KEYS = ['fieldType', 'fieldName', 'inputType', 'autoComplete', 'autocomplete']\n\n/** Autocomplete tokens (WHATWG) that name a credential. */\nconst CREDENTIAL_AUTOCOMPLETE: Record<string, 'password' | 'card' | 'otp'> = {\n 'current-password': 'password',\n 'new-password': 'password',\n 'cc-number': 'card',\n 'cc-csc': 'card',\n 'cc-exp': 'card',\n 'one-time-code': 'otp',\n}\n\n/** Field wording that asks for a credential, by kind. */\nconst CREDENTIAL_LABELS: ReadonlyArray<{ field: 'password' | 'card' | 'otp'; pattern: RegExp }> = [\n { field: 'password', pattern: /\\bpass\\s?(?:word|code|phrase)s?\\b|\\bpasswd\\b|\\bpin\\s*(?:code|number)?\\s*$/i },\n {\n field: 'card',\n pattern:\n /\\b(?:credit|debit)\\s*card\\b|\\bcard\\s*(?:number|no\\.?|#)|\\bcardnumber\\b|\\bcvv2?\\b|\\bcvc\\b|\\bcard\\s*security\\s*code\\b|\\bcard\\s*expir/i,\n },\n {\n field: 'otp',\n pattern:\n /\\bone[-\\s]?time\\s*(?:pass\\s?code|password|code|pin)\\b|\\botp\\b|\\bverification\\s*code\\b|\\b2fa\\b|\\btwo[-\\s]?factor\\b|\\bauthenticat(?:ion|or)\\s*code\\b|\\bsms\\s*code\\b/i,\n },\n]\n\n/** Raw HTML an author placed that holds a credential input. */\nconst HTML_CREDENTIAL_INPUTS: ReadonlyArray<{ field: 'password' | 'card' | 'otp'; pattern: RegExp }> = [\n { field: 'password', pattern: /<input\\b[^>]*\\btype\\s*=\\s*[\"']?password/i },\n { field: 'password', pattern: /<input\\b[^>]*\\b(?:name|autocomplete)\\s*=\\s*[\"']?(?:pass(?:word|wd)?|current-password|new-password)\\b/i },\n { field: 'card', pattern: /<input\\b[^>]*\\b(?:name|autocomplete)\\s*=\\s*[\"']?(?:card_?number|cc-number|cc-csc|cvv|cvc)\\b/i },\n { field: 'otp', pattern: /<input\\b[^>]*\\b(?:name|autocomplete)\\s*=\\s*[\"']?(?:otp|one-time-code|verification_?code)\\b/i },\n]\n\n/**\n * What an element asks its reader to DO with an account or a document —\n * counted only beside a brand in the SAME element. The email lure phrasing\n * counts too.\n */\nconst PAGE_ACTION_PATTERNS: readonly RegExp[] = [\n /\\b(?:sign|log)[\\s-]?in\\b/i,\n /\\bverify\\b/i,\n /\\bconfirm\\s+(?:your|the)\\b/i,\n /\\bunlock\\b/i,\n /\\benter\\s+your\\b/i,\n /\\b(?:view|review|open|download)\\s+(?:the\\s+|your\\s+)?(?:shared\\s+)?(?:file|document|invoice|statement)s?\\b/i,\n]\n\n/** Every node in the tree, breadth-first in document order, bounded. */\nfunction walkNodes(root: unknown): WalkedNode[] {\n const found: WalkedNode[] = []\n const seen = new Set<unknown>()\n const stack: unknown[] = [root]\n for (let head = 0; head < stack.length && found.length < MAX_NODES; head += 1) {\n const value = stack[head]\n if (!value || typeof value !== 'object' || seen.has(value)) continue\n seen.add(value)\n const record = value as Record<string, unknown>\n if (typeof record['componentId'] === 'string') {\n const props = record['props']\n found.push({\n componentId: record['componentId'] as string,\n props: props && typeof props === 'object' ? (props as Record<string, unknown>) : {},\n })\n }\n for (const child of Array.isArray(value) ? value : Object.values(record)) {\n if (child && typeof child === 'object') stack.push(child)\n }\n }\n return found\n}\n\n/** Every string a node's props hold, one level of arrays and objects deep. */\nfunction propStrings(props: Record<string, unknown>, skip: ReadonlySet<string> = new Set()): string[] {\n const out: string[] = []\n const visit = (value: unknown, depth: number) => {\n if (typeof value === 'string') out.push(value)\n else if (depth < 3 && value && typeof value === 'object') {\n for (const entry of Array.isArray(value) ? value : Object.values(value as Record<string, unknown>)) {\n visit(entry, depth + 1)\n }\n }\n }\n for (const [key, value] of Object.entries(props)) {\n if (key === 'sx' || skip.has(key)) continue\n visit(value, 0)\n }\n return out\n}\n\n/** Does this node describe a field its author defined? */\nfunction isAuthoredField(node: WalkedNode): boolean {\n if (/field|input/i.test(node.componentId)) return true\n return FIELD_PROP_KEYS.some((key) => node.props[key] !== undefined)\n}\n\n/** The credential a field asks for, or null. */\nfunction credentialOf(node: WalkedNode): { field: 'password' | 'card' | 'otp'; label: string } | null {\n const text = (key: string) => (typeof node.props[key] === 'string' ? (node.props[key] as string) : '')\n const type = [text('fieldType'), text('type'), text('inputType')].join(' ').toLowerCase()\n const label = text('label') || text('placeholder') || text('fieldName') || text('name')\n if (/\\bpassword\\b/.test(type)) return { field: 'password', label: label || 'password' }\n const autocomplete = (text('autoComplete') || text('autocomplete')).toLowerCase().trim()\n const byToken = CREDENTIAL_AUTOCOMPLETE[autocomplete]\n if (byToken) return { field: byToken, label: label || autocomplete }\n const wording = [text('label'), text('placeholder'), text('fieldName'), text('name'), text('helperText')]\n .filter(Boolean)\n .join(' ')\n for (const { field, pattern } of CREDENTIAL_LABELS) {\n if (pattern.test(wording)) return { field, label: (label || wording).slice(0, 80) }\n }\n return null\n}\n\n/**\n * The credential an input asks for, read from the two attributes that decide\n * it in the browser — its `type` and its `autocomplete` token — or null.\n * For code that builds inputs rather than storing them as page props: the\n * marketplace verifier reads a plugin bundle's `{ type: 'password' }` and\n * `setAttribute('autocomplete', 'cc-number')` with this (AGL-3362).\n */\nexport function credentialForInputAttribute(\n attribute: string,\n value: string,\n): 'password' | 'card' | 'otp' | null {\n const name = String(attribute ?? '').toLowerCase()\n const token = String(value ?? '').trim().toLowerCase()\n if ((name === 'type' || name === 'inputtype') && token === 'password') return 'password'\n if (name === 'autocomplete') return CREDENTIAL_AUTOCOMPLETE[token] ?? null\n return null\n}\n\n/** The credential a piece of raw HTML asks for in an input of its own, or null. */\nexport function credentialInHtml(html: string): 'password' | 'card' | 'otp' | null {\n const text = String(html ?? '')\n if (!text.includes('<')) return null\n return HTML_CREDENTIAL_INPUTS.find(({ pattern }) => pattern.test(text))?.field ?? null\n}\n\n/**\n * Does a submitted field's NAME ask for a credential — a password, a card\n * number or security code, a one-time code? The same wording the page\n * screen holds a field for, read over a name as a form posts it\n * (`card_number`, `cardNumber`, `one-time-code`).\n *\n * For the one path that does not pass a publish: a public form endpoint\n * takes whatever field names a request carries, so a page the screen never\n * saw (third-party code, a hand-built request) could still post a password\n * to it. The endpoint drops such a field rather than store it.\n */\nexport function isCredentialFieldName(name: string): boolean {\n const words = String(name ?? '')\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2')\n .replace(/[_\\-.[\\]]+/g, ' ')\n .trim()\n if (!words) return false\n if (CREDENTIAL_AUTOCOMPLETE[words.toLowerCase().replace(/\\s+/g, '-')]) return true\n if (/^(?:pass|pwd|ccnum|cc\\s?num|cc\\s?number|cvv2?|cvc|csc)$/i.test(words)) return true\n return CREDENTIAL_LABELS.some(({ pattern }) => pattern.test(words))\n}\n\n/** URLs a page carries in a form Links-by-shape would miss: `//host/…`. */\nfunction protocolRelativeUrls(strings: readonly string[]): string {\n return strings\n .filter((value) => /^\\/\\/[a-z0-9-]+(?:\\.[a-z0-9-]+)+/i.test(value.trim()))\n .map((value) => `https:${value.trim()}`)\n .join('\\n')\n}\n\n/**\n * Screen one page as it will be served. The caller applies the tiers with\n * `signalsThatHold` and decides what a hold does.\n */\nexport function screenHostedPage(input: HostedPageScreenInput): HostedPageScreenVerdict {\n const signals: PhishingScreenSignal[] = []\n const nodes = walkNodes(input.nodes)\n const ownNames = squashScreenText((input.ownNames ?? []).filter(Boolean).join(' '))\n const ownHosts = (input.ownDomains ?? [])\n .map((domain) => String(domain ?? '').trim().toLowerCase().replace(/\\.+$/, ''))\n .filter((domain) => domain.includes('.'))\n const ownRegistrables = new Set(ownHosts.map(registrableDomain))\n const isOwnHost = (host: string) => ownHosts.some((own) => host === own || host.endsWith(`.${own}`))\n\n const nodeStrings = nodes.map((node) => propStrings(node.props))\n let pageText = nodeStrings.map((strings) => strings.join('\\n')).join('\\n')\n pageText = `${pageText}\\n${protocolRelativeUrls(nodeStrings.flat())}`.slice(0, MAX_PAGE_TEXT)\n\n // 1. A lookalike host anywhere the page links, embeds or posts to.\n const linkHosts = linkHostsIn(pageText)\n for (const host of linkHosts) {\n const brand = lookalikeBrandForHost(host)\n if (brand) signals.push({ code: 'lookalike-link', brand: brand.id, host })\n }\n\n // 2. A credential field the page's author defined.\n const credentialSeen = new Set<string>()\n const noteCredential = (field: 'password' | 'card' | 'otp', label: string) => {\n if (credentialSeen.has(field)) return\n credentialSeen.add(field)\n signals.push({ code: 'credential-field', field, label: label.slice(0, 80) })\n }\n nodes.forEach((node, index) => {\n if (isAuthoredField(node)) {\n const credential = credentialOf(node)\n if (credential) noteCredential(credential.field, credential.label)\n }\n for (const html of nodeStrings[index]) {\n if (!html.includes('<')) continue\n for (const { field, pattern } of HTML_CREDENTIAL_INPUTS) {\n if (pattern.test(html)) noteCredential(field, 'an input in the page’s own HTML')\n }\n }\n })\n\n // 3. A brand's call to action, in one element, on a page that links away.\n const foreign = linkHosts.find((host) => {\n if (isOwnHost(host)) return false\n const registrable = registrableDomain(host)\n return !isAnyOfficialBrandDomain(registrable) && !isCommonLinkDomain(registrable)\n })\n if (foreign) {\n const actions = [...PAGE_ACTION_PATTERNS, ...PHISHING_LURE_PATTERNS]\n const named = new Set<string>()\n for (const strings of nodeStrings) {\n const text = strings.join(' ')\n if (!text) continue\n const action = actions.map((pattern) => text.match(pattern)?.[0]).find(Boolean)\n if (!action) continue\n for (const brand of PHISHING_SCREEN_BRANDS) {\n if (named.has(brand.id)) continue\n if (brand.mention.test(text) && !isWorkspaceOwnBrand(brand, ownNames, ownRegistrables)) {\n named.add(brand.id)\n signals.push({ code: 'brand-action-page', brand: brand.id, action: action.slice(0, 80) })\n }\n }\n }\n }\n\n return { signals }\n}\n"],"names":["isWorkspaceOwnBrand","linkHostsIn","lookalikeBrandForHost","PHISHING_LURE_PATTERNS","PHISHING_SCREEN_BRANDS","registrableDomain","isAnyOfficialBrandDomain","isCommonLinkDomain","squashScreenText","MAX_PAGE_TEXT","MAX_NODES","FIELD_PROP_KEYS","CREDENTIAL_AUTOCOMPLETE","CREDENTIAL_LABELS","field","pattern","HTML_CREDENTIAL_INPUTS","PAGE_ACTION_PATTERNS","walkNodes","root","found","seen","Set","stack","head","length","value","has","add","record","props","push","componentId","child","Array","isArray","Object","values","propStrings","skip","out","visit","depth","entry","key","entries","isAuthoredField","node","test","some","undefined","credentialOf","text","type","join","toLowerCase","label","autocomplete","trim","byToken","wording","filter","Boolean","slice","credentialForInputAttribute","attribute","name","String","token","credentialInHtml","html","includes","find","isCredentialFieldName","words","replace","protocolRelativeUrls","strings","map","screenHostedPage","input","signals","nodes","ownNames","ownHosts","ownDomains","domain","ownRegistrables","isOwnHost","host","own","endsWith","nodeStrings","pageText","flat","linkHosts","brand","code","id","credentialSeen","noteCredential","forEach","index","credential","foreign","registrable","actions","named","action","match","mention"],"mappings":"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"}
@@ -0,0 +1,42 @@
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 MailGateway } from './mail-gateway';
18
+ /** What a bounce says, as the classifier reads it. */
19
+ export interface MailBounceReading {
20
+ /** The enhanced status code, `5.7.1`, when the report carried one. */
21
+ status: string | null;
22
+ /** The server's diagnostic line, when it gave one. */
23
+ diagnostic: string | null;
24
+ }
25
+ /** Whether a hard bounce reads as the domain's gateway refusing the sender — see the module note. */
26
+ export declare function isMailGatewayBlock(bounce: MailBounceReading | null | undefined): boolean;
27
+ /** What a free-text bounce says, in the fields a DSN would have carried. */
28
+ export interface MailBounceText extends MailBounceReading {
29
+ /** The receiving server the text names, lowercased, when it names one. */
30
+ remoteMta: string | null;
31
+ /** The gateway that server, or the text's own wording, names. */
32
+ gateway: MailGateway | null;
33
+ }
34
+ /**
35
+ * The status, the remote server and the gateway a provider's bounce message
36
+ * names, from its text — the facts a Gmail DSN hands over as fields, which a
37
+ * provider like Resend folds into one sentence. Addresses are scrubbed from
38
+ * the diagnostic before it is kept anywhere.
39
+ */
40
+ export declare function readBounceText(text: unknown): MailBounceText;
41
+ /** A diagnostic with every address in it replaced, trimmed to what a table can show. */
42
+ export declare function scrubBounceDiagnostic(text: unknown): string | null;
@@ -0,0 +1,108 @@
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
+ * A GATEWAY BLOCK IS NOT AN UNKNOWN USER (AGL-3244, AGL-3328).
18
+ *
19
+ * Two hard bounces look alike to a delivery report — failed, a `5.x.x`
20
+ * status — and mean different things. `550 5.1.1 no such user` is a verdict
21
+ * on ONE ADDRESS: the person left, or the address was never right. `550
22
+ * 5.7.1 ... :blocked` from a Barracuda, a Proofpoint or a Mimecast is a
23
+ * verdict on THE SENDER by the whole recipient organization: its gateway
24
+ * refused the mail on policy or reputation, and the next address at that
25
+ * company will bounce the same way.
26
+ *
27
+ * So a hard bounce is read once more, for which of the two it is. The words
28
+ * that name a gateway or a policy make it a block, and so does a bare
29
+ * `5.7.0` / `5.7.1` (RFC 3463's "delivery not authorized" and "message
30
+ * refused"); any wording that says the ADDRESS is the problem — user
31
+ * unknown, mailbox not found, a `5.1.x` or `5.2.x` status — outranks them,
32
+ * because a server that names the address is talking about the address.
33
+ * The tie goes to the address.
34
+ *
35
+ * Both transports read their bounces here: a Gmail DSN's structured fields
36
+ * (Sequences) and a provider's free-text bounce message (Resend), which
37
+ * {@link readBounceText} pulls the same three facts out of.
38
+ *==========================================*/ import { mailGatewayOfHost } from "./mail-gateway.js";
39
+ /** The words a gateway or a policy refusal is written in. */ const GATEWAY_WORDS = /barracuda|proofpoint|mimecast|:blocked\b|blocked using|\bpolicy\b|spamhaus|reputation|\bblock ?list|\bblacklist|\bdenylist|\bbanned\b|\brbl\b|\bdnsbl\b/i;
40
+ /** The words, and the status classes, that say one address is the problem. */ const ADDRESS_WORDS = /no such (user|mailbox|recipient|address|person)|unknown (user|recipient|address|mailbox|account)|(user|mailbox|recipient|address|account|email)\b[^.;\n]{0,40}\b(unknown|not found|does ?n[o']?t exist|doesn't exist|invalid|disabled|unavailable|inactive)|recipient ?not ?found|does not exist|doesn't exist|invalid (recipient|mailbox|address)|mailbox (unavailable|full|disabled)|address rejected|\b5\.[12]\.\d{1,3}\b/i;
41
+ /** Whether a hard bounce reads as the domain's gateway refusing the sender — see the module note. */ export function isMailGatewayBlock(bounce) {
42
+ var _bounce_status, _bounce_diagnostic;
43
+ if (!bounce) return false;
44
+ const text = `${(_bounce_status = bounce.status) != null ? _bounce_status : ''} ${(_bounce_diagnostic = bounce.diagnostic) != null ? _bounce_diagnostic : ''}`.trim();
45
+ if (!text) return false;
46
+ if (ADDRESS_WORDS.test(text)) return false;
47
+ if (GATEWAY_WORDS.test(text)) return true;
48
+ return /\b5\.7\.[01]\b/.test(text);
49
+ }
50
+ /** An enhanced status code, `5.7.1`, anywhere in the text. */ const ENHANCED_STATUS = /\b([245])\.(\d{1,3})\.(\d{1,3})\b/;
51
+ /** A host name the text mentions: two or more labels, ending in letters. */ const HOST_NAME = /\b((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})\b/gi;
52
+ /** Gateway names written as words, for a message that names no host. */ const GATEWAY_BY_WORD = [
53
+ [
54
+ /barracuda/i,
55
+ 'barracuda'
56
+ ],
57
+ [
58
+ /proofpoint|pphosted/i,
59
+ 'proofpoint'
60
+ ],
61
+ [
62
+ /mimecast/i,
63
+ 'mimecast'
64
+ ]
65
+ ];
66
+ /**
67
+ * The status, the remote server and the gateway a provider's bounce message
68
+ * names, from its text — the facts a Gmail DSN hands over as fields, which a
69
+ * provider like Resend folds into one sentence. Addresses are scrubbed from
70
+ * the diagnostic before it is kept anywhere.
71
+ */ export function readBounceText(text) {
72
+ var _ref, _ref1;
73
+ var _raw_match, _GATEWAY_BY_WORD_find;
74
+ const raw = String(text != null ? text : '').trim();
75
+ if (!raw) return {
76
+ status: null,
77
+ diagnostic: null,
78
+ remoteMta: null,
79
+ gateway: null
80
+ };
81
+ const diagnostic = scrubBounceDiagnostic(raw);
82
+ const status = (_ref = (_raw_match = raw.match(ENHANCED_STATUS)) == null ? void 0 : _raw_match[0]) != null ? _ref : null;
83
+ let remoteMta = null;
84
+ let gateway = null;
85
+ for (const match of raw.replace(/[\w.+-]+@[\w.-]+/g, ' ').matchAll(HOST_NAME)){
86
+ const host = match[1].toLowerCase();
87
+ const named = mailGatewayOfHost(host);
88
+ if (named) {
89
+ remoteMta = host;
90
+ gateway = named;
91
+ break;
92
+ }
93
+ if (!remoteMta && /^(mx|mail|smtp|mta|inbound|relay)[\w-]*\./.test(host)) remoteMta = host;
94
+ }
95
+ if (!gateway) gateway = (_ref1 = (_GATEWAY_BY_WORD_find = GATEWAY_BY_WORD.find(([pattern])=>pattern.test(raw))) == null ? void 0 : _GATEWAY_BY_WORD_find[1]) != null ? _ref1 : null;
96
+ return {
97
+ status,
98
+ diagnostic,
99
+ remoteMta,
100
+ gateway
101
+ };
102
+ }
103
+ /** A diagnostic with every address in it replaced, trimmed to what a table can show. */ export function scrubBounceDiagnostic(text) {
104
+ const said = String(text != null ? text : '').replace(/[\w.+-]+@[\w.-]+/g, '<address>').replace(/\s+/g, ' ').trim();
105
+ return said ? said.slice(0, 300) : null;
106
+ }
107
+
108
+ //# sourceMappingURL=mail-bounce.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/mail-bounce.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 * A GATEWAY BLOCK IS NOT AN UNKNOWN USER (AGL-3244, AGL-3328).\n *\n * Two hard bounces look alike to a delivery report — failed, a `5.x.x`\n * status — and mean different things. `550 5.1.1 no such user` is a verdict\n * on ONE ADDRESS: the person left, or the address was never right. `550\n * 5.7.1 ... :blocked` from a Barracuda, a Proofpoint or a Mimecast is a\n * verdict on THE SENDER by the whole recipient organization: its gateway\n * refused the mail on policy or reputation, and the next address at that\n * company will bounce the same way.\n *\n * So a hard bounce is read once more, for which of the two it is. The words\n * that name a gateway or a policy make it a block, and so does a bare\n * `5.7.0` / `5.7.1` (RFC 3463's \"delivery not authorized\" and \"message\n * refused\"); any wording that says the ADDRESS is the problem — user\n * unknown, mailbox not found, a `5.1.x` or `5.2.x` status — outranks them,\n * because a server that names the address is talking about the address.\n * The tie goes to the address.\n *\n * Both transports read their bounces here: a Gmail DSN's structured fields\n * (Sequences) and a provider's free-text bounce message (Resend), which\n * {@link readBounceText} pulls the same three facts out of.\n *==========================================*/\n\nimport { mailGatewayOfHost, type MailGateway } from './mail-gateway'\n\n/** What a bounce says, as the classifier reads it. */\nexport interface MailBounceReading {\n /** The enhanced status code, `5.7.1`, when the report carried one. */\n status: string | null\n /** The server's diagnostic line, when it gave one. */\n diagnostic: string | null\n}\n\n/** The words a gateway or a policy refusal is written in. */\nconst GATEWAY_WORDS =\n /barracuda|proofpoint|mimecast|:blocked\\b|blocked using|\\bpolicy\\b|spamhaus|reputation|\\bblock ?list|\\bblacklist|\\bdenylist|\\bbanned\\b|\\brbl\\b|\\bdnsbl\\b/i\n\n/** The words, and the status classes, that say one address is the problem. */\nconst ADDRESS_WORDS =\n /no such (user|mailbox|recipient|address|person)|unknown (user|recipient|address|mailbox|account)|(user|mailbox|recipient|address|account|email)\\b[^.;\\n]{0,40}\\b(unknown|not found|does ?n[o']?t exist|doesn't exist|invalid|disabled|unavailable|inactive)|recipient ?not ?found|does not exist|doesn't exist|invalid (recipient|mailbox|address)|mailbox (unavailable|full|disabled)|address rejected|\\b5\\.[12]\\.\\d{1,3}\\b/i\n\n/** Whether a hard bounce reads as the domain's gateway refusing the sender — see the module note. */\nexport function isMailGatewayBlock(bounce: MailBounceReading | null | undefined): boolean {\n if (!bounce) return false\n const text = `${bounce.status ?? ''} ${bounce.diagnostic ?? ''}`.trim()\n if (!text) return false\n if (ADDRESS_WORDS.test(text)) return false\n if (GATEWAY_WORDS.test(text)) return true\n return /\\b5\\.7\\.[01]\\b/.test(text)\n}\n\n/** What a free-text bounce says, in the fields a DSN would have carried. */\nexport interface MailBounceText extends MailBounceReading {\n /** The receiving server the text names, lowercased, when it names one. */\n remoteMta: string | null\n /** The gateway that server, or the text's own wording, names. */\n gateway: MailGateway | null\n}\n\n/** An enhanced status code, `5.7.1`, anywhere in the text. */\nconst ENHANCED_STATUS = /\\b([245])\\.(\\d{1,3})\\.(\\d{1,3})\\b/\n\n/** A host name the text mentions: two or more labels, ending in letters. */\nconst HOST_NAME = /\\b((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63})\\b/gi\n\n/** Gateway names written as words, for a message that names no host. */\nconst GATEWAY_BY_WORD: ReadonlyArray<readonly [RegExp, MailGateway]> = [\n [/barracuda/i, 'barracuda'],\n [/proofpoint|pphosted/i, 'proofpoint'],\n [/mimecast/i, 'mimecast'],\n]\n\n/**\n * The status, the remote server and the gateway a provider's bounce message\n * names, from its text — the facts a Gmail DSN hands over as fields, which a\n * provider like Resend folds into one sentence. Addresses are scrubbed from\n * the diagnostic before it is kept anywhere.\n */\nexport function readBounceText(text: unknown): MailBounceText {\n const raw = String(text ?? '').trim()\n if (!raw) return { status: null, diagnostic: null, remoteMta: null, gateway: null }\n const diagnostic = scrubBounceDiagnostic(raw)\n const status = raw.match(ENHANCED_STATUS)?.[0] ?? null\n let remoteMta: string | null = null\n let gateway: MailGateway | null = null\n for (const match of raw.replace(/[\\w.+-]+@[\\w.-]+/g, ' ').matchAll(HOST_NAME)) {\n const host = match[1].toLowerCase()\n const named = mailGatewayOfHost(host)\n if (named) {\n remoteMta = host\n gateway = named\n break\n }\n if (!remoteMta && /^(mx|mail|smtp|mta|inbound|relay)[\\w-]*\\./.test(host)) remoteMta = host\n }\n if (!gateway) gateway = GATEWAY_BY_WORD.find(([pattern]) => pattern.test(raw))?.[1] ?? null\n return { status, diagnostic, remoteMta, gateway }\n}\n\n/** A diagnostic with every address in it replaced, trimmed to what a table can show. */\nexport function scrubBounceDiagnostic(text: unknown): string | null {\n const said = String(text ?? '')\n .replace(/[\\w.+-]+@[\\w.-]+/g, '<address>')\n .replace(/\\s+/g, ' ')\n .trim()\n return said ? said.slice(0, 300) : null\n}\n"],"names":["mailGatewayOfHost","GATEWAY_WORDS","ADDRESS_WORDS","isMailGatewayBlock","bounce","text","status","diagnostic","trim","test","ENHANCED_STATUS","HOST_NAME","GATEWAY_BY_WORD","readBounceText","raw","String","remoteMta","gateway","scrubBounceDiagnostic","match","replace","matchAll","host","toLowerCase","named","find","pattern","said","slice"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;4CAsB4C,GAE5C,SAASA,iBAAiB,QAA0B,oBAAgB;AAUpE,2DAA2D,GAC3D,MAAMC,gBACJ;AAEF,4EAA4E,GAC5E,MAAMC,gBACJ;AAEF,mGAAmG,GACnG,OAAO,SAASC,mBAAmBC,MAA4C;QAE7DA,gBAAuBA;IADvC,IAAI,CAACA,QAAQ,OAAO;IACpB,MAAMC,OAAO,IAAGD,iBAAAA,OAAOE,MAAM,YAAbF,iBAAiB,GAAG,CAAC,GAAEA,qBAAAA,OAAOG,UAAU,YAAjBH,qBAAqB,IAAI,CAACI,IAAI;IACrE,IAAI,CAACH,MAAM,OAAO;IAClB,IAAIH,cAAcO,IAAI,CAACJ,OAAO,OAAO;IACrC,IAAIJ,cAAcQ,IAAI,CAACJ,OAAO,OAAO;IACrC,OAAO,iBAAiBI,IAAI,CAACJ;AAC/B;AAUA,4DAA4D,GAC5D,MAAMK,kBAAkB;AAExB,0EAA0E,GAC1E,MAAMC,YAAY;AAElB,sEAAsE,GACtE,MAAMC,kBAAiE;IACrE;QAAC;QAAc;KAAY;IAC3B;QAAC;QAAwB;KAAa;IACtC;QAAC;QAAa;KAAW;CAC1B;AAED;;;;;CAKC,GACD,OAAO,SAASC,eAAeR,IAAa;;QAI3BS,YAaSF;IAhBxB,MAAME,MAAMC,OAAOV,eAAAA,OAAQ,IAAIG,IAAI;IACnC,IAAI,CAACM,KAAK,OAAO;QAAER,QAAQ;QAAMC,YAAY;QAAMS,WAAW;QAAMC,SAAS;IAAK;IAClF,MAAMV,aAAaW,sBAAsBJ;IACzC,MAAMR,kBAASQ,aAAAA,IAAIK,KAAK,CAACT,qCAAVI,UAA4B,CAAC,EAAE,mBAAI;IAClD,IAAIE,YAA2B;IAC/B,IAAIC,UAA8B;IAClC,KAAK,MAAME,SAASL,IAAIM,OAAO,CAAC,qBAAqB,KAAKC,QAAQ,CAACV,WAAY;QAC7E,MAAMW,OAAOH,KAAK,CAAC,EAAE,CAACI,WAAW;QACjC,MAAMC,QAAQxB,kBAAkBsB;QAChC,IAAIE,OAAO;YACTR,YAAYM;YACZL,UAAUO;YACV;QACF;QACA,IAAI,CAACR,aAAa,4CAA4CP,IAAI,CAACa,OAAON,YAAYM;IACxF;IACA,IAAI,CAACL,SAASA,oBAAUL,wBAAAA,gBAAgBa,IAAI,CAAC,CAAC,CAACC,QAAQ,GAAKA,QAAQjB,IAAI,CAACK,0BAAjDF,qBAAwD,CAAC,EAAE,oBAAI;IACvF,OAAO;QAAEN;QAAQC;QAAYS;QAAWC;IAAQ;AAClD;AAEA,sFAAsF,GACtF,OAAO,SAASC,sBAAsBb,IAAa;IACjD,MAAMsB,OAAOZ,OAAOV,eAAAA,OAAQ,IACzBe,OAAO,CAAC,qBAAqB,aAC7BA,OAAO,CAAC,QAAQ,KAChBZ,IAAI;IACP,OAAOmB,OAAOA,KAAKC,KAAK,CAAC,GAAG,OAAO;AACrC"}