@aglyn/shared-util-email 1.0.0-beta.219 → 1.0.0-beta.221

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,74 @@
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
+ /** The threat lists a host is looked up against. */
19
+ export type LinkThreatType = 'SOCIAL_ENGINEERING' | 'MALWARE' | 'UNWANTED_SOFTWARE';
20
+ export declare const LINK_THREAT_TYPES: readonly LinkThreatType[];
21
+ /** A host the list names, and what it names it for. */
22
+ export interface HostReputationHit {
23
+ host: string;
24
+ threats: LinkThreatType[];
25
+ }
26
+ /** What one lookup answered, host by host. */
27
+ export interface LinkReputationAnswer {
28
+ /** Hosts the list names. */
29
+ hits: HostReputationHit[];
30
+ /** Hosts the list does not name. */
31
+ clean: string[];
32
+ /** Hosts nobody could answer for: an error, a timeout, a switched-off lookup. */
33
+ unknown: string[];
34
+ }
35
+ export interface LinkReputationLookupOptions {
36
+ /** How long the caller will wait, overall. */
37
+ deadlineMs?: number;
38
+ }
39
+ export type LinkReputationLookup = (hosts: readonly string[], options?: LinkReputationLookupOptions) => Promise<LinkReputationAnswer>;
40
+ /** The most foreign hosts one page, message or redirect is looked up for. */
41
+ export declare const MAX_REPUTATION_HOSTS_PER_LOOKUP = 20;
42
+ /** Installs the lookup. Called once, from `@aglyn/tenant-data-admin`. */
43
+ export declare function setLinkReputationLookup(lookup: LinkReputationLookup | null): void;
44
+ /** The installed lookup, or null. */
45
+ export declare function getLinkReputationLookup(): LinkReputationLookup | null;
46
+ /** Test seam: forget any installed lookup. */
47
+ export declare function resetLinkReputationLookupForTests(): void;
48
+ /** A host as the lookup takes it, or null for anything that is not one. */
49
+ export declare function normalizeReputationHost(host: string): string | null;
50
+ /**
51
+ * The hosts worth asking about: foreign ones. Not the workspace's own, not
52
+ * a domain under one of `excludeDomains` (the platform's own), not a listed
53
+ * brand's own domain and not a common social, maps or review link — those
54
+ * are judged by the screen's rules, and asking about them would only spend
55
+ * lookups. Deduplicated, in the order found, at most
56
+ * {@link MAX_REPUTATION_HOSTS_PER_LOOKUP} unless `max` says otherwise.
57
+ */
58
+ export declare function foreignHostsForReputation(hosts: readonly string[], options?: {
59
+ ownDomains?: readonly (string | null | undefined)[];
60
+ excludeDomains?: readonly (string | null | undefined)[];
61
+ max?: number;
62
+ }): string[];
63
+ /** A listing, as the screen's signal. */
64
+ export declare function webRiskSignals(hits: readonly HostReputationHit[]): PhishingScreenSignal[];
65
+ /**
66
+ * Look the foreign hosts up through the installed lookup and answer the
67
+ * signals they carry. Nothing installed, nothing foreign, an error: no
68
+ * signals. Never throws.
69
+ */
70
+ export declare function linkReputationSignals(hosts: readonly string[], options?: {
71
+ ownDomains?: readonly (string | null | undefined)[];
72
+ excludeDomains?: readonly (string | null | undefined)[];
73
+ deadlineMs?: number;
74
+ }): Promise<PhishingScreenSignal[]>;
@@ -0,0 +1,123 @@
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
+ * LINK REPUTATION (AGL-3451): what the phishing screen asks about a host
18
+ * nobody's rules can judge.
19
+ *
20
+ * The screen's own rules catch a page or a message that LOOKS like a lure —
21
+ * a brand's lookalike host, a password field, a brand beside a sign-in. The
22
+ * 2026-10-01 harvesters linked to `temps-juenes.com` and
23
+ * `conservascaorvi.com`, which wear no brand at all, so nothing held them.
24
+ * A host already known to be bad anywhere is caught here instead: every
25
+ * FOREIGN host a page, an email or a redirect points at is looked up against
26
+ * a reputation list (Google Web Risk), and a listed one is a
27
+ * `web-risk-link` signal — STRONG, so it holds for every workspace.
28
+ *
29
+ * Split like the send seam's gate: the pure half lives here (which hosts are
30
+ * foreign, how a listing becomes a signal), and the lookup — a network call
31
+ * and a cache — is installed by `@aglyn/tenant-data-admin`
32
+ * (`web-risk.ts`) through {@link setLinkReputationLookup}. Nothing installed
33
+ * means nothing looked up.
34
+ *
35
+ * ## Not evidence when it fails
36
+ *
37
+ * A lookup that errors or times out says nothing about the host. It never
38
+ * holds by itself; the lookup reports the host as unknown and logs a warning.
39
+ *
40
+ * ## Only the host
41
+ *
42
+ * The lookup sees `https://<host>/` and nothing else: never the path, the
43
+ * query, the page or the message. A host is enough to catch a domain that
44
+ * exists to harvest, and it is the least a third party can be told.
45
+ *=========================================*/ import { isAnyOfficialBrandDomain, isCommonLinkDomain, registrableDomain } from "./outbound-phishing-screen.js";
46
+ export const LINK_THREAT_TYPES = [
47
+ 'SOCIAL_ENGINEERING',
48
+ 'MALWARE',
49
+ 'UNWANTED_SOFTWARE'
50
+ ];
51
+ /** The most foreign hosts one page, message or redirect is looked up for. */ export const MAX_REPUTATION_HOSTS_PER_LOOKUP = 20;
52
+ let installedLookup = null;
53
+ /** Installs the lookup. Called once, from `@aglyn/tenant-data-admin`. */ export function setLinkReputationLookup(lookup) {
54
+ installedLookup = lookup;
55
+ }
56
+ /** The installed lookup, or null. */ export function getLinkReputationLookup() {
57
+ return installedLookup;
58
+ }
59
+ /** Test seam: forget any installed lookup. */ export function resetLinkReputationLookupForTests() {
60
+ installedLookup = null;
61
+ }
62
+ const HOST_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)+$/;
63
+ /** A host as the lookup takes it, or null for anything that is not one. */ export function normalizeReputationHost(host) {
64
+ const value = String(host != null ? host : '').trim().toLowerCase().replace(/\.+$/, '');
65
+ return value.length <= 253 && HOST_PATTERN.test(value) ? value : null;
66
+ }
67
+ /**
68
+ * The hosts worth asking about: foreign ones. Not the workspace's own, not
69
+ * a domain under one of `excludeDomains` (the platform's own), not a listed
70
+ * brand's own domain and not a common social, maps or review link — those
71
+ * are judged by the screen's rules, and asking about them would only spend
72
+ * lookups. Deduplicated, in the order found, at most
73
+ * {@link MAX_REPUTATION_HOSTS_PER_LOOKUP} unless `max` says otherwise.
74
+ */ export function foreignHostsForReputation(hosts, options = {}) {
75
+ var _options_max;
76
+ const under = (list)=>(list != null ? list : []).map((domain)=>normalizeReputationHost(String(domain != null ? domain : ''))).filter((domain)=>Boolean(domain));
77
+ const own = [
78
+ ...under(options.ownDomains),
79
+ ...under(options.excludeDomains)
80
+ ];
81
+ const max = (_options_max = options.max) != null ? _options_max : MAX_REPUTATION_HOSTS_PER_LOOKUP;
82
+ const out = [];
83
+ for (const raw of hosts){
84
+ if (out.length >= max) break;
85
+ const host = normalizeReputationHost(raw);
86
+ if (!host || out.includes(host)) continue;
87
+ if (own.some((domain)=>host === domain || host.endsWith(`.${domain}`))) continue;
88
+ const registrable = registrableDomain(host);
89
+ if (isAnyOfficialBrandDomain(registrable) || isCommonLinkDomain(registrable)) continue;
90
+ out.push(host);
91
+ }
92
+ return out;
93
+ }
94
+ /** A listing, as the screen's signal. */ export function webRiskSignals(hits) {
95
+ return hits.map((hit)=>({
96
+ code: 'web-risk-link',
97
+ host: hit.host,
98
+ threats: [
99
+ ...new Set(hit.threats)
100
+ ].sort()
101
+ }));
102
+ }
103
+ /**
104
+ * Look the foreign hosts up through the installed lookup and answer the
105
+ * signals they carry. Nothing installed, nothing foreign, an error: no
106
+ * signals. Never throws.
107
+ */ export async function linkReputationSignals(hosts, options = {}) {
108
+ const lookup = installedLookup;
109
+ if (!lookup) return [];
110
+ const foreign = foreignHostsForReputation(hosts, options);
111
+ if (!foreign.length) return [];
112
+ try {
113
+ const answer = await lookup(foreign, {
114
+ deadlineMs: options.deadlineMs
115
+ });
116
+ return webRiskSignals(answer.hits.filter((hit)=>foreign.includes(hit.host)));
117
+ } catch (error) {
118
+ console.warn('[link-reputation] the lookup failed — no signal', error);
119
+ return [];
120
+ }
121
+ }
122
+
123
+ //# sourceMappingURL=link-reputation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/link-reputation.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 * LINK REPUTATION (AGL-3451): what the phishing screen asks about a host\n * nobody's rules can judge.\n *\n * The screen's own rules catch a page or a message that LOOKS like a lure —\n * a brand's lookalike host, a password field, a brand beside a sign-in. The\n * 2026-10-01 harvesters linked to `temps-juenes.com` and\n * `conservascaorvi.com`, which wear no brand at all, so nothing held them.\n * A host already known to be bad anywhere is caught here instead: every\n * FOREIGN host a page, an email or a redirect points at is looked up against\n * a reputation list (Google Web Risk), and a listed one is a\n * `web-risk-link` signal — STRONG, so it holds for every workspace.\n *\n * Split like the send seam's gate: the pure half lives here (which hosts are\n * foreign, how a listing becomes a signal), and the lookup — a network call\n * and a cache — is installed by `@aglyn/tenant-data-admin`\n * (`web-risk.ts`) through {@link setLinkReputationLookup}. Nothing installed\n * means nothing looked up.\n *\n * ## Not evidence when it fails\n *\n * A lookup that errors or times out says nothing about the host. It never\n * holds by itself; the lookup reports the host as unknown and logs a warning.\n *\n * ## Only the host\n *\n * The lookup sees `https://<host>/` and nothing else: never the path, the\n * query, the page or the message. A host is enough to catch a domain that\n * exists to harvest, and it is the least a third party can be told.\n *=========================================*/\n\nimport type { PhishingScreenSignal } from './outbound-phishing-screen'\nimport {\n isAnyOfficialBrandDomain,\n isCommonLinkDomain,\n registrableDomain,\n} from './outbound-phishing-screen'\n\n/** The threat lists a host is looked up against. */\nexport type LinkThreatType = 'SOCIAL_ENGINEERING' | 'MALWARE' | 'UNWANTED_SOFTWARE'\n\nexport const LINK_THREAT_TYPES: readonly LinkThreatType[] = [\n 'SOCIAL_ENGINEERING',\n 'MALWARE',\n 'UNWANTED_SOFTWARE',\n]\n\n/** A host the list names, and what it names it for. */\nexport interface HostReputationHit {\n host: string\n threats: LinkThreatType[]\n}\n\n/** What one lookup answered, host by host. */\nexport interface LinkReputationAnswer {\n /** Hosts the list names. */\n hits: HostReputationHit[]\n /** Hosts the list does not name. */\n clean: string[]\n /** Hosts nobody could answer for: an error, a timeout, a switched-off lookup. */\n unknown: string[]\n}\n\nexport interface LinkReputationLookupOptions {\n /** How long the caller will wait, overall. */\n deadlineMs?: number\n}\n\nexport type LinkReputationLookup = (\n hosts: readonly string[],\n options?: LinkReputationLookupOptions,\n) => Promise<LinkReputationAnswer>\n\n/** The most foreign hosts one page, message or redirect is looked up for. */\nexport const MAX_REPUTATION_HOSTS_PER_LOOKUP = 20\n\nlet installedLookup: LinkReputationLookup | null = null\n\n/** Installs the lookup. Called once, from `@aglyn/tenant-data-admin`. */\nexport function setLinkReputationLookup(lookup: LinkReputationLookup | null): void {\n installedLookup = lookup\n}\n\n/** The installed lookup, or null. */\nexport function getLinkReputationLookup(): LinkReputationLookup | null {\n return installedLookup\n}\n\n/** Test seam: forget any installed lookup. */\nexport function resetLinkReputationLookupForTests(): void {\n installedLookup = null\n}\n\nconst HOST_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)+$/\n\n/** A host as the lookup takes it, or null for anything that is not one. */\nexport function normalizeReputationHost(host: string): string | null {\n const value = String(host ?? '')\n .trim()\n .toLowerCase()\n .replace(/\\.+$/, '')\n return value.length <= 253 && HOST_PATTERN.test(value) ? value : null\n}\n\n/**\n * The hosts worth asking about: foreign ones. Not the workspace's own, not\n * a domain under one of `excludeDomains` (the platform's own), not a listed\n * brand's own domain and not a common social, maps or review link — those\n * are judged by the screen's rules, and asking about them would only spend\n * lookups. Deduplicated, in the order found, at most\n * {@link MAX_REPUTATION_HOSTS_PER_LOOKUP} unless `max` says otherwise.\n */\nexport function foreignHostsForReputation(\n hosts: readonly string[],\n options: {\n ownDomains?: readonly (string | null | undefined)[]\n excludeDomains?: readonly (string | null | undefined)[]\n max?: number\n } = {},\n): string[] {\n const under = (list: readonly (string | null | undefined)[] | undefined) =>\n (list ?? [])\n .map((domain) => normalizeReputationHost(String(domain ?? '')))\n .filter((domain): domain is string => Boolean(domain))\n const own = [...under(options.ownDomains), ...under(options.excludeDomains)]\n const max = options.max ?? MAX_REPUTATION_HOSTS_PER_LOOKUP\n const out: string[] = []\n for (const raw of hosts) {\n if (out.length >= max) break\n const host = normalizeReputationHost(raw)\n if (!host || out.includes(host)) continue\n if (own.some((domain) => host === domain || host.endsWith(`.${domain}`))) continue\n const registrable = registrableDomain(host)\n if (isAnyOfficialBrandDomain(registrable) || isCommonLinkDomain(registrable)) continue\n out.push(host)\n }\n return out\n}\n\n/** A listing, as the screen's signal. */\nexport function webRiskSignals(hits: readonly HostReputationHit[]): PhishingScreenSignal[] {\n return hits.map((hit) => ({\n code: 'web-risk-link' as const,\n host: hit.host,\n threats: [...new Set(hit.threats)].sort(),\n }))\n}\n\n/**\n * Look the foreign hosts up through the installed lookup and answer the\n * signals they carry. Nothing installed, nothing foreign, an error: no\n * signals. Never throws.\n */\nexport async function linkReputationSignals(\n hosts: readonly string[],\n options: {\n ownDomains?: readonly (string | null | undefined)[]\n excludeDomains?: readonly (string | null | undefined)[]\n deadlineMs?: number\n } = {},\n): Promise<PhishingScreenSignal[]> {\n const lookup = installedLookup\n if (!lookup) return []\n const foreign = foreignHostsForReputation(hosts, options)\n if (!foreign.length) return []\n try {\n const answer = await lookup(foreign, { deadlineMs: options.deadlineMs })\n return webRiskSignals(answer.hits.filter((hit) => foreign.includes(hit.host)))\n } catch (error) {\n console.warn('[link-reputation] the lookup failed — no signal', error)\n return []\n }\n}\n"],"names":["isAnyOfficialBrandDomain","isCommonLinkDomain","registrableDomain","LINK_THREAT_TYPES","MAX_REPUTATION_HOSTS_PER_LOOKUP","installedLookup","setLinkReputationLookup","lookup","getLinkReputationLookup","resetLinkReputationLookupForTests","HOST_PATTERN","normalizeReputationHost","host","value","String","trim","toLowerCase","replace","length","test","foreignHostsForReputation","hosts","options","under","list","map","domain","filter","Boolean","own","ownDomains","excludeDomains","max","out","raw","includes","some","endsWith","registrable","push","webRiskSignals","hits","hit","code","threats","Set","sort","linkReputationSignals","foreign","answer","deadlineMs","error","console","warn"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2CA6B2C,GAG3C,SACEA,wBAAwB,EACxBC,kBAAkB,EAClBC,iBAAiB,QACZ,gCAA4B;AAKnC,OAAO,MAAMC,oBAA+C;IAC1D;IACA;IACA;CACD,CAAA;AA4BD,2EAA2E,GAC3E,OAAO,MAAMC,kCAAkC,GAAE;AAEjD,IAAIC,kBAA+C;AAEnD,uEAAuE,GACvE,OAAO,SAASC,wBAAwBC,MAAmC;IACzEF,kBAAkBE;AACpB;AAEA,mCAAmC,GACnC,OAAO,SAASC;IACd,OAAOH;AACT;AAEA,4CAA4C,GAC5C,OAAO,SAASI;IACdJ,kBAAkB;AACpB;AAEA,MAAMK,eAAe;AAErB,yEAAyE,GACzE,OAAO,SAASC,wBAAwBC,IAAY;IAClD,MAAMC,QAAQC,OAAOF,eAAAA,OAAQ,IAC1BG,IAAI,GACJC,WAAW,GACXC,OAAO,CAAC,QAAQ;IACnB,OAAOJ,MAAMK,MAAM,IAAI,OAAOR,aAAaS,IAAI,CAACN,SAASA,QAAQ;AACnE;AAEA;;;;;;;CAOC,GACD,OAAO,SAASO,0BACdC,KAAwB,EACxBC,UAII,CAAC,CAAC;QAOMA;IALZ,MAAMC,QAAQ,CAACC,OACb,CAACA,eAAAA,OAAQ,EAAE,EACRC,GAAG,CAAC,CAACC,SAAWf,wBAAwBG,OAAOY,iBAAAA,SAAU,MACzDC,MAAM,CAAC,CAACD,SAA6BE,QAAQF;IAClD,MAAMG,MAAM;WAAIN,MAAMD,QAAQQ,UAAU;WAAMP,MAAMD,QAAQS,cAAc;KAAE;IAC5E,MAAMC,OAAMV,eAAAA,QAAQU,GAAG,YAAXV,eAAelB;IAC3B,MAAM6B,MAAgB,EAAE;IACxB,KAAK,MAAMC,OAAOb,MAAO;QACvB,IAAIY,IAAIf,MAAM,IAAIc,KAAK;QACvB,MAAMpB,OAAOD,wBAAwBuB;QACrC,IAAI,CAACtB,QAAQqB,IAAIE,QAAQ,CAACvB,OAAO;QACjC,IAAIiB,IAAIO,IAAI,CAAC,CAACV,SAAWd,SAASc,UAAUd,KAAKyB,QAAQ,CAAC,CAAC,CAAC,EAAEX,QAAQ,IAAI;QAC1E,MAAMY,cAAcpC,kBAAkBU;QACtC,IAAIZ,yBAAyBsC,gBAAgBrC,mBAAmBqC,cAAc;QAC9EL,IAAIM,IAAI,CAAC3B;IACX;IACA,OAAOqB;AACT;AAEA,uCAAuC,GACvC,OAAO,SAASO,eAAeC,IAAkC;IAC/D,OAAOA,KAAKhB,GAAG,CAAC,CAACiB,MAAS,CAAA;YACxBC,MAAM;YACN/B,MAAM8B,IAAI9B,IAAI;YACdgC,SAAS;mBAAI,IAAIC,IAAIH,IAAIE,OAAO;aAAE,CAACE,IAAI;QACzC,CAAA;AACF;AAEA;;;;CAIC,GACD,OAAO,eAAeC,sBACpB1B,KAAwB,EACxBC,UAII,CAAC,CAAC;IAEN,MAAMf,SAASF;IACf,IAAI,CAACE,QAAQ,OAAO,EAAE;IACtB,MAAMyC,UAAU5B,0BAA0BC,OAAOC;IACjD,IAAI,CAAC0B,QAAQ9B,MAAM,EAAE,OAAO,EAAE;IAC9B,IAAI;QACF,MAAM+B,SAAS,MAAM1C,OAAOyC,SAAS;YAAEE,YAAY5B,QAAQ4B,UAAU;QAAC;QACtE,OAAOV,eAAeS,OAAOR,IAAI,CAACd,MAAM,CAAC,CAACe,MAAQM,QAAQb,QAAQ,CAACO,IAAI9B,IAAI;IAC7E,EAAE,OAAOuC,OAAO;QACdC,QAAQC,IAAI,CAAC,mDAAmDF;QAChE,OAAO,EAAE;IACX;AACF"}
@@ -58,9 +58,22 @@ export interface PhishingScreenBrand {
58
58
  }
59
59
  export declare const PHISHING_SCREEN_BRANDS: readonly PhishingScreenBrand[];
60
60
  /**
61
- * Account-action and credential phrasing — the half of a lure that asks the
62
- * reader to DO something with an account. Only ever counted beside a brand
63
- * AND a foreign link; alone, several of these are ordinary transactional copy.
61
+ * Account and credential phrasing: the lure that asks the reader to DO
62
+ * something with an account — verify it, confirm it, sign in to it.
63
+ */
64
+ export declare const ACCOUNT_LURE_PATTERNS: readonly RegExp[];
65
+ /**
66
+ * Document-share phrasing (AGL-3447): the lure that says a file is waiting
67
+ * behind a secure door — "Secure Document Access Portal", "Continue to
68
+ * Document", "shared a file with you", "encrypted message". The credential
69
+ * harvest is on the far side of the button, so these are the words the page
70
+ * or the mail itself carries.
71
+ */
72
+ export declare const DOCUMENT_SHARE_LURE_PATTERNS: readonly RegExp[];
73
+ /**
74
+ * Every lure — the half of the three-part lure that asks the reader to act.
75
+ * Only ever counted beside a brand AND a foreign link; alone, several of
76
+ * these are ordinary transactional copy.
64
77
  */
65
78
  export declare const PHISHING_LURE_PATTERNS: readonly RegExp[];
66
79
  /** A signal the screen found. */
@@ -97,6 +110,65 @@ export type PhishingScreenSignal = {
97
110
  code: 'brand-action-page';
98
111
  brand: string;
99
112
  action: string;
113
+ }
114
+ /**
115
+ * The same, read over the page as a WHOLE (AGL-3447): a brand that is not
116
+ * the workspace's anywhere on a page that carries an account or
117
+ * document-share lure anywhere, or whose call to action leaves the site
118
+ * worded as one, and that links away (`hosted-page-screen.ts`). Soft.
119
+ */
120
+ | {
121
+ code: 'brand-lure-page';
122
+ brand: string;
123
+ lure: string;
124
+ host: string;
125
+ }
126
+ /**
127
+ * A published page's call to action — its only one, or one worded as an
128
+ * account or document action — sends visitors to a host that is not the
129
+ * site's, a listed brand's or a common link, on a page that carries an
130
+ * account or document-share lure (`hosted-page-screen.ts`, AGL-3447). No
131
+ * brand needed. Soft.
132
+ */
133
+ | {
134
+ code: 'offsite-action-page';
135
+ action: string;
136
+ lure: string;
137
+ host: string;
138
+ }
139
+ /**
140
+ * A site redirect whose path reads as an account or document-share lure
141
+ * sends visitors to such a host (`screenSiteRedirect`, AGL-3447). Soft.
142
+ */
143
+ | {
144
+ code: 'offsite-redirect';
145
+ source: string;
146
+ lure: string;
147
+ host: string;
148
+ }
149
+ /**
150
+ * Account or password phrasing beside a link elsewhere that carries the
151
+ * reader's own address (AGL-3453). STRONG when the address is the whole
152
+ * fragment (`#victim@example.com`), which only a landing page's script
153
+ * reads; soft when it is a query parameter or part of a longer fragment.
154
+ * The address itself is never stored.
155
+ */
156
+ | {
157
+ code: 'recipient-prefill-link';
158
+ host: string;
159
+ lure: string;
160
+ place: 'fragment' | 'parameter';
161
+ }
162
+ /**
163
+ * A link, embed, redirect or form action whose host Google Web Risk lists
164
+ * as phishing, malware or unwanted software (AGL-3451,
165
+ * `link-reputation.ts`). Strong: a harvester already known to be bad
166
+ * anywhere is bad whatever page or message surrounds it.
167
+ */
168
+ | {
169
+ code: 'web-risk-link';
170
+ host: string;
171
+ threats: string[];
100
172
  };
101
173
  export interface PhishingScreenInput {
102
174
  subject?: string | null;
@@ -163,6 +235,21 @@ declare function isAnyOfficialDomain(registrable: string): boolean;
163
235
  export declare const MEDIA_EMBED_HOSTS: ReadonlySet<string>;
164
236
  /** The brand a host impersonates, or null. */
165
237
  export declare function lookalikeBrandForHost(host: string): PhishingScreenBrand | null;
238
+ /**
239
+ * What a reader SEES in a body (AGL-3453): its text with the markup taken
240
+ * out, the way a mail client or a browser draws it.
241
+ *
242
+ * A kit splits every letter of a brand and a lure with a tag —
243
+ * `G<SPAN class=…>o<SPAN …>o…gle`, `P<SPAN …>a<SPAN …>s…word E<SPAN …>x…pired`
244
+ * — so the raw HTML never contains the word a reader reads. Tags are
245
+ * therefore removed with NO separator, which rejoins the letters; `<style>`,
246
+ * `<script>` and `<head>` are dropped with their contents; comments and
247
+ * zero-width characters are dropped; entities are decoded; whitespace is
248
+ * collapsed. A second reading that puts a space where a line or block
249
+ * breaks is added when it differs, so two words in two paragraphs are still
250
+ * two words. Links are NOT read from this: an href lives inside a tag.
251
+ */
252
+ export declare function visibleTextOf(html: string | null | undefined): string;
166
253
  /** The hosts every link in the text points at, lowercased. */
167
254
  export declare function linkHostsIn(text: string): string[];
168
255
  /** Lowercased alphanumerics only, so "Wells-Fargo Advisors" contains `wellsfargo`. */
@@ -183,6 +270,8 @@ export declare function isCommonLinkDomain(registrable: string): boolean;
183
270
  export declare function screenOutboundEmail(input: PhishingScreenInput): PhishingScreenVerdict;
184
271
  /** The brand's label for a stored id, for the staff surface. */
185
272
  export declare function phishingScreenBrandLabel(id: string): string;
273
+ /** "phishing or social engineering and malware", from Web Risk's threat types. */
274
+ export declare function describeLinkThreats(threats: readonly string[]): string;
186
275
  /** One line per signal, in words staff read on the review row. */
187
276
  export declare function describePhishingScreenSignals(signals: readonly PhishingScreenSignal[]): string[];
188
277
  /** A workspace younger than this many days has the soft rules applied. */
@@ -202,6 +291,7 @@ export declare const ACCOUNT_SUBDOMAIN_HOSTS: ReadonlySet<string>;
202
291
  export declare function phishingSignalTier(signal: {
203
292
  code: string;
204
293
  host?: string;
294
+ place?: string;
205
295
  }): PhishingSignalTier;
206
296
  /** Is a workspace of this age (days, `null` = unreadable) screened by the soft rules? */
207
297
  export declare function isYoungWorkspaceAge(ageDays: number | null | undefined): boolean;