@aglyn/shared-util-email 1.0.0-beta.200 → 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,719 @@
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 OUTBOUND PHISHING SCREEN (AGL-3356).
18
+ *
19
+ * A fraud actor signed up, paid with a card that never met 3-D Secure, and
20
+ * used a workspace to send two kinds of mail: a campaign of Booking.com-style
21
+ * "guest feedback regarding your property" lures to hotels, and a workflow
22
+ * that mailed "Poshmark Order — one of the items from your Seller Account has
23
+ * finally sold", linking to `poshmark.id63835663.shop`.
24
+ *
25
+ * This module answers one question about a message a YOUNG workspace is
26
+ * about to send: does its content carry a STRONG phishing signal? The caller
27
+ * decides who is young and what happens to a positive answer — the answer is
28
+ * a hold for staff review, never a drop.
29
+ *
30
+ * ## Conservative by construction
31
+ *
32
+ * A false positive costs a real merchant's first campaign a wait for a
33
+ * human; a miss costs strangers their bank logins. Both are real, so only
34
+ * three shapes hold, and each is one a legitimate merchant essentially never
35
+ * produces:
36
+ *
37
+ * 1. **A lookalike link** — a link or reply address whose host carries a
38
+ * brand's name without being the brand's own domain: the brand as a label
39
+ * of an unrelated registrable domain (`poshmark.id63835663.shop`), hyphen-
40
+ * joined to one (`paypal-secure.com`, `secure-paypal.net`), the brand's
41
+ * real domain embedded in a longer host (`booking.com.guest-review.top`),
42
+ * or leetspeak for it (`paypa1.com`). Holds on its own.
43
+ * 2. **A brand in the sender's display name** that is not the workspace's
44
+ * own name — mail that says it is FROM PayPal.
45
+ * 3. **The three-part lure**: a brand named in the copy, AND credential or
46
+ * account-action phrasing ("verify your account", "has finally sold",
47
+ * "feedback regarding your property"), AND a link to a domain that is
48
+ * neither the brand's, nor the workspace's, nor a common social or maps
49
+ * link. Any two of the three are ordinary marketing — "our book is on
50
+ * Amazon", "sign in to your account", a link to the shop — and pass.
51
+ *
52
+ * Mentioning a brand, alone, never holds.
53
+ *
54
+ * ## The brand list is small on purpose
55
+ *
56
+ * The brands phishing kits most often wear, plus the two in the incident. A
57
+ * brand is added when it has been seen impersonated, not because it is big:
58
+ * every entry is a set of words that can now hold somebody's newsletter.
59
+ * Each carries its REAL domains, so a link to the brand itself is never
60
+ * suspicious, and host tokens chosen so a common word (`booking`, `apple`,
61
+ * `outlook`) does not match an unrelated host by itself.
62
+ *
63
+ * Pure: no DNS, no store, no clock. Everything the caller knows about the
64
+ * workspace arrives as input.
65
+ *=========================================*/ /** One brand the screen recognizes. */ export const PHISHING_SCREEN_BRANDS = [
66
+ {
67
+ id: 'poshmark',
68
+ label: 'Poshmark',
69
+ mention: /\bposhmark\b/i,
70
+ hostTokens: [
71
+ 'poshmark'
72
+ ],
73
+ officialDomains: [
74
+ 'poshmark.com',
75
+ 'posh.mk'
76
+ ]
77
+ },
78
+ {
79
+ id: 'booking',
80
+ label: 'Booking.com',
81
+ // Never the bare word: "booking" is every hotel's and every salon's.
82
+ mention: /\bbooking\.com\b/i,
83
+ hostTokens: [
84
+ 'bookingcom',
85
+ 'booking-com'
86
+ ],
87
+ officialDomains: [
88
+ 'booking.com',
89
+ 'bstatic.com'
90
+ ]
91
+ },
92
+ {
93
+ id: 'airbnb',
94
+ label: 'Airbnb',
95
+ mention: /\bairbnb\b/i,
96
+ hostTokens: [
97
+ 'airbnb'
98
+ ],
99
+ officialDomains: [
100
+ 'airbnb.com',
101
+ 'abnb.me',
102
+ 'muscache.com'
103
+ ],
104
+ countryDomains: true
105
+ },
106
+ {
107
+ id: 'paypal',
108
+ label: 'PayPal',
109
+ mention: /\bpay\s?pal\b/i,
110
+ hostTokens: [
111
+ 'paypal'
112
+ ],
113
+ officialDomains: [
114
+ 'paypal.com',
115
+ 'paypal.me',
116
+ 'paypalobjects.com'
117
+ ],
118
+ countryDomains: true
119
+ },
120
+ {
121
+ id: 'amazon',
122
+ label: 'Amazon',
123
+ mention: /\bamazon\b/i,
124
+ hostTokens: [
125
+ 'amazon',
126
+ 'amzn'
127
+ ],
128
+ wordToken: true,
129
+ officialDomains: [
130
+ 'amazon.com',
131
+ 'amzn.to',
132
+ 'amzn.com',
133
+ 'amazonaws.com',
134
+ 'media-amazon.com',
135
+ 'ssl-images-amazon.com'
136
+ ],
137
+ countryDomains: true
138
+ },
139
+ {
140
+ id: 'apple',
141
+ label: 'Apple',
142
+ // The fruit is on every bakery's menu; the account is what gets phished.
143
+ mention: /\bapple\s?(?:id|support|account)\b|\bicloud\b|\bitunes\b/i,
144
+ hostTokens: [
145
+ 'apple',
146
+ 'appleid',
147
+ 'icloud',
148
+ 'itunes'
149
+ ],
150
+ wordToken: true,
151
+ officialDomains: [
152
+ 'apple.com',
153
+ 'icloud.com',
154
+ 'apple.co',
155
+ 'itunes.com',
156
+ 'me.com',
157
+ 'mzstatic.com'
158
+ ],
159
+ countryDomains: true
160
+ },
161
+ {
162
+ id: 'microsoft',
163
+ label: 'Microsoft',
164
+ mention: /\bmicrosoft\b|\boffice\s?365\b|\bonedrive\b|\bsharepoint\b/i,
165
+ hostTokens: [
166
+ 'microsoft',
167
+ 'office365',
168
+ 'onedrive',
169
+ 'sharepoint',
170
+ 'microsoftonline',
171
+ 'msonline'
172
+ ],
173
+ officialDomains: [
174
+ 'microsoft.com',
175
+ 'office.com',
176
+ 'office365.com',
177
+ 'live.com',
178
+ 'outlook.com',
179
+ 'microsoftonline.com',
180
+ 'sharepoint.com',
181
+ 'onedrive.com',
182
+ '1drv.ms',
183
+ 'aka.ms'
184
+ ]
185
+ },
186
+ {
187
+ id: 'docusign',
188
+ label: 'DocuSign',
189
+ mention: /\bdocu\s?sign\b/i,
190
+ hostTokens: [
191
+ 'docusign'
192
+ ],
193
+ officialDomains: [
194
+ 'docusign.com',
195
+ 'docusign.net'
196
+ ]
197
+ },
198
+ {
199
+ id: 'netflix',
200
+ label: 'Netflix',
201
+ mention: /\bnetflix\b/i,
202
+ hostTokens: [
203
+ 'netflix'
204
+ ],
205
+ officialDomains: [
206
+ 'netflix.com',
207
+ 'nflxext.com',
208
+ 'nflximg.net'
209
+ ]
210
+ },
211
+ {
212
+ id: 'wellsfargo',
213
+ label: 'Wells Fargo',
214
+ mention: /\bwells\s?fargo\b/i,
215
+ hostTokens: [
216
+ 'wellsfargo',
217
+ 'wells-fargo'
218
+ ],
219
+ officialDomains: [
220
+ 'wellsfargo.com'
221
+ ]
222
+ },
223
+ {
224
+ id: 'bankofamerica',
225
+ label: 'Bank of America',
226
+ mention: /\bbank\s+of\s+america\b/i,
227
+ hostTokens: [
228
+ 'bankofamerica',
229
+ 'bofa'
230
+ ],
231
+ officialDomains: [
232
+ 'bankofamerica.com',
233
+ 'bofa.com'
234
+ ]
235
+ },
236
+ {
237
+ id: 'coinbase',
238
+ label: 'Coinbase',
239
+ mention: /\bcoinbase\b/i,
240
+ hostTokens: [
241
+ 'coinbase'
242
+ ],
243
+ officialDomains: [
244
+ 'coinbase.com'
245
+ ]
246
+ },
247
+ {
248
+ id: 'usps',
249
+ label: 'USPS',
250
+ mention: /\busps\b/i,
251
+ hostTokens: [
252
+ 'usps'
253
+ ],
254
+ officialDomains: [
255
+ 'usps.com'
256
+ ]
257
+ },
258
+ {
259
+ id: 'fedex',
260
+ label: 'FedEx',
261
+ mention: /\bfed\s?ex\b/i,
262
+ hostTokens: [
263
+ 'fedex'
264
+ ],
265
+ officialDomains: [
266
+ 'fedex.com'
267
+ ]
268
+ },
269
+ {
270
+ id: 'dhl',
271
+ label: 'DHL',
272
+ mention: /\bdhl\b/i,
273
+ hostTokens: [
274
+ 'dhl'
275
+ ],
276
+ officialDomains: [
277
+ 'dhl.com'
278
+ ],
279
+ countryDomains: true
280
+ }
281
+ ];
282
+ /**
283
+ * Account-action and credential phrasing — the half of a lure that asks the
284
+ * reader to DO something with an account. Only ever counted beside a brand
285
+ * AND a foreign link; alone, several of these are ordinary transactional copy.
286
+ */ export const PHISHING_LURE_PATTERNS = [
287
+ /\bverify\s+your\s+(?:account|identity|information|details|payment|email)\b/i,
288
+ /\bconfirm\s+your\s+(?:account|identity|details|payment|password|billing|information)\b/i,
289
+ /\b(?:account|payment|card)\s+(?:has\s+been\s+|is\s+|was\s+)?(?:suspended|locked|limited|restricted|disabled|on\s+hold)\b/i,
290
+ /\bunusual\s+(?:sign[-\s]?in|login|activity)\b/i,
291
+ /\bupdate\s+your\s+(?:payment|billing|card|account)\s*(?:details|information|method)?\b/i,
292
+ /\b(?:sign|log)\s?in\s+to\s+(?:your|view|confirm|claim|release)\b/i,
293
+ /\b(?:has|have)\s+(?:finally\s+)?sold\b/i,
294
+ /\bseller\s+account\b/i,
295
+ /\b(?:guest|customer)\s+(?:complaint|review|feedback)\b/i,
296
+ /\bfeedback\s+regarding\s+your\s+(?:property|listing|stay|booking|reservation)\b/i,
297
+ /\b(?:document|invoice|agreement)s?\s+(?:is\s+|are\s+)?(?:ready|waiting|pending)\s+(?:for\s+)?(?:your\s+)?(?:review|signature|to\s+sign)\b/i,
298
+ /\bpassword\s+(?:will\s+)?expire/i,
299
+ /\bclaim\s+your\s+(?:refund|reward|prize|package|payment|funds)\b/i,
300
+ /\b(?:delivery|shipment)\s+(?:failed|attempt|suspended|on\s+hold)\b/i,
301
+ /\b(?:package|parcel)\s+(?:is\s+)?(?:on\s+hold|held|awaiting|undeliverable)\b/i,
302
+ /\bsecurity\s+alert\b/i
303
+ ];
304
+ /**
305
+ * Link targets a merchant's mail ordinarily carries that are not theirs:
306
+ * social profiles, maps, review sites, video. Counted as neither foreign nor
307
+ * suspicious in the three-part lure, so "leave us a review on Google" with a
308
+ * brand named elsewhere does not become a hold. Never consulted by the
309
+ * lookalike check, which holds on the host's own shape.
310
+ */ const COMMON_LINK_DOMAINS = new Set([
311
+ 'facebook.com',
312
+ 'fb.com',
313
+ 'instagram.com',
314
+ 'x.com',
315
+ 'twitter.com',
316
+ 'linkedin.com',
317
+ 'youtube.com',
318
+ 'youtu.be',
319
+ 'tiktok.com',
320
+ 'pinterest.com',
321
+ 'google.com',
322
+ 'goo.gl',
323
+ 'g.page',
324
+ 'yelp.com',
325
+ 'tripadvisor.com',
326
+ 'wa.me',
327
+ 'whatsapp.com'
328
+ ]);
329
+ /**
330
+ * Two-label public suffixes, so `evil.co.uk` registers as `evil.co.uk` and
331
+ * not as `co.uk`. Deliberately a short list of the ones mail actually
332
+ * arrives from rather than the whole public suffix list: a suffix missing
333
+ * here makes the registrable domain one label too short, which can only
334
+ * widen what counts as "the same domain" for a link — never invent a brand
335
+ * label that is not in the host.
336
+ */ const TWO_LABEL_SUFFIXES = new Set([
337
+ 'co.uk',
338
+ 'org.uk',
339
+ 'ac.uk',
340
+ 'gov.uk',
341
+ 'me.uk',
342
+ 'com.au',
343
+ 'net.au',
344
+ 'org.au',
345
+ 'co.nz',
346
+ 'co.jp',
347
+ 'co.kr',
348
+ 'co.in',
349
+ 'co.za',
350
+ 'co.il',
351
+ 'co.id',
352
+ 'co.th',
353
+ 'com.br',
354
+ 'com.mx',
355
+ 'com.ar',
356
+ 'com.tr',
357
+ 'com.cn',
358
+ 'com.hk',
359
+ 'com.sg',
360
+ 'com.tw',
361
+ 'com.my',
362
+ 'com.ph',
363
+ 'com.co',
364
+ 'com.pe',
365
+ 'com.sa',
366
+ 'com.eg'
367
+ ]);
368
+ /** The registrable domain of a host: `a.b.evil.co.uk` → `evil.co.uk`. */ export function registrableDomain(host) {
369
+ const labels = String(host != null ? host : '').toLowerCase().replace(/\.+$/, '').split('.').filter(Boolean);
370
+ if (labels.length <= 2) return labels.join('.');
371
+ const lastTwo = labels.slice(-2).join('.');
372
+ return TWO_LABEL_SUFFIXES.has(lastTwo) ? labels.slice(-3).join('.') : lastTwo;
373
+ }
374
+ /** `<label>.<suffix>` of a registrable domain, split once. */ function splitRegistrable(registrable) {
375
+ const dot = registrable.indexOf('.');
376
+ return dot < 0 ? {
377
+ label: registrable,
378
+ suffix: ''
379
+ } : {
380
+ label: registrable.slice(0, dot),
381
+ suffix: registrable.slice(dot + 1)
382
+ };
383
+ }
384
+ function isOfficialDomain(brand, registrable) {
385
+ if (brand.officialDomains.includes(registrable)) return true;
386
+ if (!brand.countryDomains) return false;
387
+ const base = splitRegistrable(brand.officialDomains[0]).label;
388
+ const { label, suffix } = splitRegistrable(registrable);
389
+ return label === base && (/^[a-z]{2}$/.test(suffix) || TWO_LABEL_SUFFIXES.has(suffix));
390
+ }
391
+ /** Every brand's real domains, for "is this link just the brand itself". */ function isAnyOfficialDomain(registrable) {
392
+ return PHISHING_SCREEN_BRANDS.some((brand)=>isOfficialDomain(brand, registrable));
393
+ }
394
+ /** Leetspeak folded, so `paypa1` reads as `paypal`. */ function foldLeet(label) {
395
+ return label.replace(/0/g, 'o').replace(/1/g, 'l').replace(/3/g, 'e').replace(/4/g, 'a').replace(/5/g, 's').replace(/7/g, 't');
396
+ }
397
+ /** Does one host label name the brand — whole, or hyphen-joined? */ function labelNamesBrand(label, token, wordToken) {
398
+ if (wordToken) return label === token;
399
+ for (const candidate of new Set([
400
+ label,
401
+ foldLeet(label)
402
+ ])){
403
+ if (candidate === token) return true;
404
+ const parts = candidate.split('-');
405
+ // A hyphen-joined token (`booking-com`) spans parts, so it is matched
406
+ // as a run of parts rather than one of them.
407
+ const tokenParts = token.split('-');
408
+ for(let at = 0; at + tokenParts.length <= parts.length; at += 1){
409
+ if (tokenParts.every((part, offset)=>parts[at + offset] === part)) {
410
+ return true;
411
+ }
412
+ }
413
+ }
414
+ return false;
415
+ }
416
+ /** The brand a host impersonates, or null. */ export function lookalikeBrandForHost(host) {
417
+ const normalized = String(host != null ? host : '').toLowerCase().replace(/\.+$/, '');
418
+ if (!normalized.includes('.')) return null;
419
+ const registrable = registrableDomain(normalized);
420
+ for (const brand of PHISHING_SCREEN_BRANDS){
421
+ if (isOfficialDomain(brand, registrable)) continue;
422
+ const labels = normalized.split('.');
423
+ if (labels.some((label)=>brand.hostTokens.some((token)=>labelNamesBrand(label, token, brand.wordToken === true)))) {
424
+ return brand;
425
+ }
426
+ // The brand's primary domain embedded in a longer, different one:
427
+ // `booking.com.guest-review.top`, `paypal.com-secure.net`. Only the
428
+ // primary: a short secondary (`me.com`, `aka.ms`) would match too much.
429
+ const primary = brand.officialDomains[0];
430
+ if (normalized.startsWith(`${primary}.`) || normalized.startsWith(`${primary}-`) || normalized.includes(`.${primary}.`) || normalized.includes(`.${primary}-`)) {
431
+ return brand;
432
+ }
433
+ }
434
+ return null;
435
+ }
436
+ /**
437
+ * The few entity forms a phishing kit uses to keep a URL away from a naive
438
+ * scanner (`&#46;` for a dot, `&amp;` inside a query). Decoded before links
439
+ * are read; everything else is left alone.
440
+ */ function decodeEntities(text) {
441
+ return text.replace(/&#x([0-9a-f]{1,6});/gi, (_, hex)=>safeCodePoint(parseInt(hex, 16))).replace(/&#(\d{1,7});/g, (_, dec)=>safeCodePoint(parseInt(dec, 10))).replace(/&amp;/gi, '&');
442
+ }
443
+ function safeCodePoint(value) {
444
+ return Number.isFinite(value) && value > 0 && value <= 0x10ffff ? String.fromCodePoint(value) : '';
445
+ }
446
+ const URL_PATTERN = /\b(?:https?:\/\/|www\.)[^\s"'<>()\\[\]{}]+/gi;
447
+ /** The hosts every link in the text points at, lowercased. */ export function linkHostsIn(text) {
448
+ var _decodeEntities_match;
449
+ const hosts = new Set();
450
+ for (const match of (_decodeEntities_match = decodeEntities(String(text != null ? text : '')).match(URL_PATTERN)) != null ? _decodeEntities_match : []){
451
+ let rest = match.replace(/^https?:\/\//i, '');
452
+ rest = rest.split(/[/?#]/)[0];
453
+ // `https://paypal.com@evil.top/` resolves to evil.top — and the part
454
+ // before the `@` is exactly the disguise, so it is kept as a host too.
455
+ const at = rest.lastIndexOf('@');
456
+ const disguise = at >= 0 ? rest.slice(0, at) : '';
457
+ rest = at >= 0 ? rest.slice(at + 1) : rest;
458
+ for (const candidate of [
459
+ rest,
460
+ disguise
461
+ ]){
462
+ const host = candidate.replace(/:\d+$/, '').replace(/\.+$/, '').toLowerCase();
463
+ // A merge tag inside a URL is not a host anybody can visit yet.
464
+ if (host && host.includes('.') && !host.includes('{') && !host.includes('%7b')) {
465
+ hosts.add(host);
466
+ }
467
+ }
468
+ }
469
+ return [
470
+ ...hosts
471
+ ];
472
+ }
473
+ const ADDRESS_HOST_PATTERN = /[a-z0-9._%+-]+@([a-z0-9-]+(?:\.[a-z0-9-]+)+)/gi;
474
+ /** The host of every email address in the text — a reply-to, a `mailto:`. */ function addressHostsIn(text) {
475
+ const hosts = new Set();
476
+ for (const match of decodeEntities(String(text != null ? text : '')).matchAll(ADDRESS_HOST_PATTERN)){
477
+ hosts.add(match[1].toLowerCase().replace(/\.+$/, ''));
478
+ }
479
+ return [
480
+ ...hosts
481
+ ];
482
+ }
483
+ /** Lowercased alphanumerics only, so "Wells-Fargo Advisors" contains `wellsfargo`. */ function squash(text) {
484
+ return text.toLowerCase().replace(/[^a-z0-9]/g, '');
485
+ }
486
+ /** Is this brand the workspace's own, by name or by a domain it holds? */ function isOwnBrand(brand, ownNames, ownRegistrables) {
487
+ const labels = [
488
+ brand.id,
489
+ squash(brand.label),
490
+ ...brand.hostTokens.map(squash)
491
+ ];
492
+ if (labels.some((label)=>label && ownNames.includes(label))) return true;
493
+ return [
494
+ ...ownRegistrables
495
+ ].some((registrable)=>isOfficialDomain(brand, registrable));
496
+ }
497
+ /*
498
+ * The same judgements, for the other surfaces that read content with this
499
+ * screen's data (`hosted-page-screen.ts`). Exported under these names rather
500
+ * than copied, so a page and an email can never disagree about what a
501
+ * brand's own domain is or whose brand a name is.
502
+ */ /** `squash`: lowercased alphanumerics, the form own-name matching reads. */ export const squashScreenText = squash;
503
+ /** Is this brand the workspace's own, by name (squashed) or by a domain it holds? */ export const isWorkspaceOwnBrand = isOwnBrand;
504
+ /** Is this registrable domain one of any listed brand's own? */ export const isAnyOfficialBrandDomain = isAnyOfficialDomain;
505
+ /** Is this registrable domain a social, maps or review link merchants ordinarily carry? */ export function isCommonLinkDomain(registrable) {
506
+ return COMMON_LINK_DOMAINS.has(registrable);
507
+ }
508
+ /**
509
+ * Screen one outbound message. See the module header for what holds and why.
510
+ */ export function screenOutboundEmail(input) {
511
+ var _input_ownNames, _input_ownDomains, _input_bodies, _input_replyTo, _input_fromAddress, _input_fromName;
512
+ const signals = [];
513
+ const ownNames = squash(((_input_ownNames = input.ownNames) != null ? _input_ownNames : []).filter(Boolean).join(' '));
514
+ const ownHosts = ((_input_ownDomains = input.ownDomains) != null ? _input_ownDomains : []).map((domain)=>String(domain != null ? domain : '').trim().toLowerCase().replace(/\.+$/, '')).filter((domain)=>domain.includes('.'));
515
+ const ownRegistrables = new Set(ownHosts.map(registrableDomain));
516
+ const isOwnHost = (host)=>ownHosts.some((own)=>host === own || host.endsWith(`.${own}`));
517
+ const copy = [
518
+ input.subject,
519
+ input.preheader,
520
+ ...(_input_bodies = input.bodies) != null ? _input_bodies : []
521
+ ].filter((part)=>typeof part === 'string' && part.length > 0).join('\n');
522
+ const decodedCopy = decodeEntities(copy);
523
+ const replyTo = Array.isArray(input.replyTo) ? input.replyTo.join(' ') : String((_input_replyTo = input.replyTo) != null ? _input_replyTo : '');
524
+ const linkHosts = linkHostsIn(copy);
525
+ // 1. A lookalike host, anywhere a recipient could click, write back, or
526
+ // read as the sender.
527
+ const seenLookalike = new Set();
528
+ const fromAddress = String((_input_fromAddress = input.fromAddress) != null ? _input_fromAddress : '');
529
+ for (const host of [
530
+ ...linkHosts,
531
+ ...addressHostsIn(`${copy}\n${replyTo}\n${fromAddress}`)
532
+ ]){
533
+ if (seenLookalike.has(host)) continue;
534
+ const brand = lookalikeBrandForHost(host);
535
+ if (brand) {
536
+ seenLookalike.add(host);
537
+ signals.push({
538
+ code: 'lookalike-link',
539
+ brand: brand.id,
540
+ host
541
+ });
542
+ }
543
+ }
544
+ // 2. A brand in the sender's display name that is not the workspace's.
545
+ const fromName = String((_input_fromName = input.fromName) != null ? _input_fromName : '').trim();
546
+ if (fromName) {
547
+ for (const brand of PHISHING_SCREEN_BRANDS){
548
+ if (brand.mention.test(fromName) && !isOwnBrand(brand, ownNames, ownRegistrables)) {
549
+ signals.push({
550
+ code: 'brand-sender',
551
+ brand: brand.id,
552
+ fromName: fromName.slice(0, 120)
553
+ });
554
+ }
555
+ }
556
+ }
557
+ // 3. Brand + lure + a link to somewhere that is nobody's we know.
558
+ const lure = PHISHING_LURE_PATTERNS.map((pattern)=>{
559
+ var _decodedCopy_match;
560
+ return (_decodedCopy_match = decodedCopy.match(pattern)) == null ? void 0 : _decodedCopy_match[0];
561
+ }).find(Boolean);
562
+ if (lure) {
563
+ const foreign = linkHosts.find((host)=>{
564
+ if (isOwnHost(host)) return false;
565
+ const registrable = registrableDomain(host);
566
+ return !isAnyOfficialDomain(registrable) && !COMMON_LINK_DOMAINS.has(registrable);
567
+ });
568
+ if (foreign) {
569
+ const namedInCopy = `${decodedCopy}\n${fromName}`;
570
+ for (const brand of PHISHING_SCREEN_BRANDS){
571
+ if (brand.mention.test(namedInCopy) && !isOwnBrand(brand, ownNames, ownRegistrables)) {
572
+ signals.push({
573
+ code: 'brand-lure-link',
574
+ brand: brand.id,
575
+ lure: lure.slice(0, 120),
576
+ host: foreign
577
+ });
578
+ break;
579
+ }
580
+ }
581
+ }
582
+ }
583
+ return {
584
+ hold: signals.length > 0,
585
+ signals
586
+ };
587
+ }
588
+ /** The brand's label for a stored id, for the staff surface. */ export function phishingScreenBrandLabel(id) {
589
+ var _ref;
590
+ var _PHISHING_SCREEN_BRANDS_find;
591
+ return (_ref = (_PHISHING_SCREEN_BRANDS_find = PHISHING_SCREEN_BRANDS.find((brand)=>brand.id === id)) == null ? void 0 : _PHISHING_SCREEN_BRANDS_find.label) != null ? _ref : id;
592
+ }
593
+ /** One line per signal, in words staff read on the review row. */ export function describePhishingScreenSignals(signals) {
594
+ return signals.map((signal)=>{
595
+ const brand = 'brand' in signal ? phishingScreenBrandLabel(signal.brand) : '';
596
+ switch(signal.code){
597
+ case 'lookalike-link':
598
+ return `Links to ${signal.host}, which wears the ${brand} name but is not ${brand}'s domain.`;
599
+ case 'brand-sender':
600
+ return `Sends as "${signal.fromName}", naming ${brand}, which is not this workspace.`;
601
+ case 'brand-lure-link':
602
+ return `Names ${brand}, asks the reader to act ("${signal.lure}"), and links to ${signal.host}.`;
603
+ case 'credential-field':
604
+ return `Asks for a ${signal.field === 'card' ? 'card number' : signal.field === 'otp' ? 'one-time code' : 'password'} in its own field ("${signal.label}"), outside the platform's sign-in and checkout elements.`;
605
+ case 'brand-action-page':
606
+ return `Names ${brand} beside a call to action ("${signal.action}"), and this workspace is not ${brand}.`;
607
+ default:
608
+ return 'Phishing signal.';
609
+ }
610
+ });
611
+ }
612
+ /*==========================================
613
+ * RISK TIERS: who a signal holds for (AGL-3356, widened to every surface).
614
+ *
615
+ * The screen above finds signals; this decides which of them hold, the same
616
+ * way on every surface that asks — an email at the send seam, a campaign
617
+ * before it is claimed, a page at publish, a subdomain at creation.
618
+ *
619
+ * - STRONG: a lookalike host. A link to `poshmark.id63835663.shop` or
620
+ * `paypa1.com` is a disguise whoever wears it and however old the
621
+ * workspace is, so it holds for EVERY workspace, on every message and
622
+ * page, transactional mail included. A merchant has no ordinary reason to
623
+ * link to a host shaped like somebody else's brand.
624
+ * - SOFT: a brand in the sender name, the three-part lure, a brand's name
625
+ * beside a sign-in or payment call to action on a page. Each is shaped
626
+ * like phishing but also like a clumsy legitimate message, so they hold
627
+ * only for a workspace in its first {@link OUTBOUND_REVIEW_YOUNG_DAYS}
628
+ * days — the incident's window — and NEVER for mail the recipient's own
629
+ * act made owed ({@link OutboundScreenPolicy.owed}).
630
+ *
631
+ * A credential-harvest page (a password, card or one-time-code field that
632
+ * is not one of the platform's own sign-in or checkout elements) is STRONG:
633
+ * see `hosted-page-screen.ts`.
634
+ *=========================================*/ /** A workspace younger than this many days has the soft rules applied. */ export const OUTBOUND_REVIEW_YOUNG_DAYS = 14;
635
+ /** The signal codes that hold for every workspace. */ export const STRONG_PHISHING_SIGNAL_CODES = new Set([
636
+ 'lookalike-link',
637
+ 'credential-field'
638
+ ]);
639
+ export function phishingSignalTier(signal) {
640
+ return STRONG_PHISHING_SIGNAL_CODES.has(signal == null ? void 0 : signal.code) ? 'strong' : 'soft';
641
+ }
642
+ /** Is a workspace of this age (days, `null` = unreadable) screened by the soft rules? */ export function isYoungWorkspaceAge(ageDays) {
643
+ return typeof ageDays === 'number' && Number.isFinite(ageDays) && ageDays < OUTBOUND_REVIEW_YOUNG_DAYS;
644
+ }
645
+ /** The signals that HOLD under the tiers, for this workspace and message. */ export function signalsThatHold(signals, policy) {
646
+ const soft = isYoungWorkspaceAge(policy.ageDays) && !policy.owed;
647
+ return signals.filter((signal)=>phishingSignalTier(signal) === 'strong' || soft);
648
+ }
649
+ /**
650
+ * The same brand, lookalike and tier logic applied to a HOST NAME a workspace
651
+ * asks for — a subdomain on the tenant apex, or a custom domain it attaches.
652
+ *
653
+ * Words a brand's impersonator hyphen-joins to its name. A subdomain that
654
+ * joins a brand's word to one of these (`booking-review`, `apple-support`,
655
+ * `amazon-account`) is the incident's shape even where the brand's word is
656
+ * an ordinary one — which the lookalike rule alone deliberately lets pass,
657
+ * so a salon can be `tanyas-booking` and a bakery `apple-pie-co`.
658
+ */ export const BRAND_IMPERSONATION_WORDS = new Set([
659
+ 'account',
660
+ 'accounts',
661
+ 'auth',
662
+ 'billing',
663
+ 'help',
664
+ 'helpdesk',
665
+ 'id',
666
+ 'login',
667
+ 'logon',
668
+ 'order',
669
+ 'orders',
670
+ 'pay',
671
+ 'payment',
672
+ 'payments',
673
+ 'refund',
674
+ 'resolution',
675
+ 'review',
676
+ 'reviews',
677
+ 'secure',
678
+ 'security',
679
+ 'seller',
680
+ 'service',
681
+ 'signin',
682
+ 'support',
683
+ 'update',
684
+ 'verify',
685
+ 'verification',
686
+ 'wallet'
687
+ ]);
688
+ /**
689
+ * The brand a requested subdomain label wears, or null.
690
+ *
691
+ * Refused for EVERY workspace, at creation and at rename — the tier is
692
+ * strong, because nothing is lost by asking a real bakery to pick
693
+ * `apple-pie-co` instead of `apple-support`, and a brand's name on our apex
694
+ * is a disguise that outlives every later screen. Two shapes:
695
+ *
696
+ * 1. the label is a lookalike host label ({@link lookalikeBrandForHost} on
697
+ * `<label>.<apex>`): `poshmark`, `paypal-secure`, `paypa1`, `appleid`;
698
+ * 2. a brand's word hyphen-joined to an impersonation word
699
+ * ({@link BRAND_IMPERSONATION_WORDS}): `booking-review`, `apple-support`.
700
+ */ export function brandForSubdomainLabel(label, apex = 'example.invalid') {
701
+ const normalized = String(label != null ? label : '').trim().toLowerCase();
702
+ if (!normalized) return null;
703
+ const byHost = lookalikeBrandForHost(`${normalized}.${apex}`);
704
+ if (byHost) return byHost;
705
+ const parts = foldLeet(normalized).split('-').filter(Boolean);
706
+ if (parts.length < 2 || !parts.some((part)=>BRAND_IMPERSONATION_WORDS.has(part))) {
707
+ return null;
708
+ }
709
+ for (const brand of PHISHING_SCREEN_BRANDS){
710
+ const words = [
711
+ brand.id,
712
+ ...brand.hostTokens
713
+ ].filter((word)=>!word.includes('-'));
714
+ if (parts.some((part)=>words.includes(part))) return brand;
715
+ }
716
+ return null;
717
+ }
718
+
719
+ //# sourceMappingURL=outbound-phishing-screen.js.map