@aglyn/shared-util-email 1.0.0-beta.199 → 1.0.0-beta.201
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/index.d.ts +6 -0
- package/src/index.js +16 -0
- package/src/index.js.map +1 -1
- package/src/lib/email-deliverability.d.ts +200 -0
- package/src/lib/email-deliverability.js +461 -0
- package/src/lib/email-deliverability.js.map +1 -0
- package/src/lib/email-delivery-events.d.ts +16 -39
- package/src/lib/email-delivery-events.js +28 -12
- package/src/lib/email-delivery-events.js.map +1 -1
- package/src/lib/hosted-page-screen.d.ts +60 -0
- package/src/lib/hosted-page-screen.js +314 -0
- package/src/lib/hosted-page-screen.js.map +1 -0
- package/src/lib/mail-bounce.d.ts +42 -0
- package/src/lib/mail-bounce.js +108 -0
- package/src/lib/mail-bounce.js.map +1 -0
- package/src/lib/mail-gateway.d.ts +161 -0
- package/src/lib/mail-gateway.js +330 -0
- package/src/lib/mail-gateway.js.map +1 -0
- package/src/lib/outbound-phishing-screen.d.ts +220 -0
- package/src/lib/outbound-phishing-screen.js +719 -0
- package/src/lib/outbound-phishing-screen.js.map +1 -0
- package/src/lib/outbound-screen-gate.d.ts +95 -0
- package/src/lib/outbound-screen-gate.js +109 -0
- package/src/lib/outbound-screen-gate.js.map +1 -0
- package/src/lib/send-email.d.ts +79 -1
- package/src/lib/send-email.js +153 -10
- package/src/lib/send-email.js.map +1 -1
- package/src/lib/sending-domain.d.ts +22 -1
- package/src/lib/sending-domain.js +0 -95
- package/src/lib/sending-domain.js.map +1 -1
|
@@ -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"}
|
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
export type MailGateway = 'barracuda' | 'proofpoint' | 'mimecast' | 'google' | 'microsoft' | 'other' | 'none';
|
|
18
|
+
export declare const MAIL_GATEWAYS: readonly MailGateway[];
|
|
19
|
+
/** The gateway as a chip, a table row or a sentence names it. */
|
|
20
|
+
export declare const MAIL_GATEWAY_LABELS: Record<MailGateway, string>;
|
|
21
|
+
/**
|
|
22
|
+
* The security gateways that stand in front of a whole company's mail and
|
|
23
|
+
* refuse a sender for everyone behind them.
|
|
24
|
+
*/
|
|
25
|
+
export declare const MAIL_GATEWAYS_FRONTED: readonly MailGateway[];
|
|
26
|
+
/** Refusals in the window, with no delivery beside them, that hold the next bulk send. */
|
|
27
|
+
export declare const MAIL_GATEWAY_HOLD_BLOCKS = 2;
|
|
28
|
+
/** The days the hold is judged over. */
|
|
29
|
+
export declare const MAIL_GATEWAY_WINDOW_DAYS = 30;
|
|
30
|
+
/** The days a gateway chip counts. */
|
|
31
|
+
export declare const MAIL_GATEWAY_CHIP_DAYS = 7;
|
|
32
|
+
/** How long a domain's MX answer is trusted before it is looked up again. */
|
|
33
|
+
export declare const MAIL_DOMAIN_INTEL_TTL_MS: number;
|
|
34
|
+
/**
|
|
35
|
+
* How long an answer that the domain takes NO mail is trusted: a day, so a
|
|
36
|
+
* domain whose owner is still setting up its mail is looked at again soon.
|
|
37
|
+
*/
|
|
38
|
+
export declare const MAIL_DOMAIN_NEGATIVE_TTL_MS: number;
|
|
39
|
+
/** A send with no bounce this long after it counts as delivered, where no delivery event is reported. */
|
|
40
|
+
export declare const MAIL_GATEWAY_DELIVERED_AFTER_MS: number;
|
|
41
|
+
/**
|
|
42
|
+
* The deliveries one ledger counts per UTC day at most.
|
|
43
|
+
*
|
|
44
|
+
* The hold asks whether a gateway delivered ANYTHING in the window, and the
|
|
45
|
+
* chip reads a week of counts; neither needs the ten-thousandth delivery of
|
|
46
|
+
* a campaign to Gmail, and a ledger document written once per delivery
|
|
47
|
+
* event would be the hottest document in the database. A day that reached
|
|
48
|
+
* the cap reads "at least".
|
|
49
|
+
*/
|
|
50
|
+
export declare const MAIL_GATEWAY_DAILY_DELIVERY_CAP = 50;
|
|
51
|
+
/** A host as the suffix table reads it: lowercase, trimmed, no trailing dot. */
|
|
52
|
+
export declare function normalizeMailHost(host: unknown): string;
|
|
53
|
+
/** Whether a value names one of the gateways. */
|
|
54
|
+
export declare function isMailGateway(value: unknown): value is MailGateway;
|
|
55
|
+
/**
|
|
56
|
+
* The gateway ONE host names, or `null` when it names none of the known
|
|
57
|
+
* ones — a Remote-MTA in a bounce, or one MX exchange.
|
|
58
|
+
*/
|
|
59
|
+
export declare function mailGatewayOfHost(host: unknown): MailGateway | null;
|
|
60
|
+
/**
|
|
61
|
+
* The gateway a domain's MX records name. A domain whose exchanges name more
|
|
62
|
+
* than one is read as the first named one in MX order (the list is handed
|
|
63
|
+
* over lowest-preference-first); one that names none is `other`; an empty
|
|
64
|
+
* list, or RFC 7505's lone ".", is `none`.
|
|
65
|
+
*/
|
|
66
|
+
export declare function classifyMailGateway(mxHosts: readonly string[]): MailGateway;
|
|
67
|
+
/** Whether the gateway is one of the security gateways that refuse a sender for a whole company. */
|
|
68
|
+
export declare function isMailGatewayFronted(gateway: MailGateway | null | undefined): boolean;
|
|
69
|
+
/** The counts one UTC day holds on a gateway's ledger. */
|
|
70
|
+
export interface MailGatewayDayCounts {
|
|
71
|
+
sent: number;
|
|
72
|
+
delivered: number;
|
|
73
|
+
blocked: number;
|
|
74
|
+
}
|
|
75
|
+
/** What a send met at a gateway, as the ledger counts it. */
|
|
76
|
+
export type MailGatewayOutcome = keyof MailGatewayDayCounts;
|
|
77
|
+
/** `YYYY-MM-DD` in UTC: the day a ledger entry is filed under. */
|
|
78
|
+
export declare function mailGatewayDay(atMs: number): string;
|
|
79
|
+
/** The ledger's counts summed over the last `windowDays` UTC days, today included. */
|
|
80
|
+
export declare function mailGatewayWindow(days: Record<string, Partial<MailGatewayDayCounts>> | null | undefined, nowMs: number, windowDays: number): MailGatewayDayCounts;
|
|
81
|
+
/** The ledger's days with everything older than `keepDays` dropped. */
|
|
82
|
+
export declare function pruneMailGatewayDays<T>(days: Record<string, T> | null | undefined, nowMs: number, keepDays: number): Record<string, T>;
|
|
83
|
+
/**
|
|
84
|
+
* One sending domain's record with one gateway: totals, and the same counts
|
|
85
|
+
* by UTC day for the windows the hold and the chip read. Stored by the
|
|
86
|
+
* server layer under {@link mailGatewayLedgerKey}.
|
|
87
|
+
*/
|
|
88
|
+
export interface MailGatewayLedger {
|
|
89
|
+
/** The domain the mail left from — the `From:` domain, or a mailbox's. */
|
|
90
|
+
sendingDomain: string;
|
|
91
|
+
gateway: MailGateway;
|
|
92
|
+
sent: number;
|
|
93
|
+
delivered: number;
|
|
94
|
+
blocked: number;
|
|
95
|
+
lastBlockedAtMs: number | null;
|
|
96
|
+
/** The last refusal's diagnostic, scrubbed of addresses, for the staff table. */
|
|
97
|
+
lastBlockedDetail: string | null;
|
|
98
|
+
/** `YYYY-MM-DD` (UTC) → that day's counts, the last thirty days kept. */
|
|
99
|
+
days: Record<string, MailGatewayDayCounts>;
|
|
100
|
+
updatedAtMs: number;
|
|
101
|
+
}
|
|
102
|
+
/** A sending domain as the ledger spells it: lowercase, no trailing dot, `null` for none. */
|
|
103
|
+
export declare function normalizeLedgerSendingDomain(value: unknown): string | null;
|
|
104
|
+
/**
|
|
105
|
+
* The document id a ledger is stored under: `aglyn.io~barracuda`. A `~`
|
|
106
|
+
* because a domain never holds one and a Firestore id may.
|
|
107
|
+
*/
|
|
108
|
+
export declare function mailGatewayLedgerKey(sendingDomain: string, gateway: MailGateway): string;
|
|
109
|
+
/** A stored ledger in its model shape, or `null` for no document or an unreadable one. */
|
|
110
|
+
export declare function readStoredMailGatewayLedger(id: string, data: Record<string, unknown> | null | undefined): MailGatewayLedger | null;
|
|
111
|
+
/**
|
|
112
|
+
* The ledger after one more outcome — the pure half of the write, which the
|
|
113
|
+
* store runs inside its transaction. Deliveries stop counting for the day at
|
|
114
|
+
* {@link MAIL_GATEWAY_DAILY_DELIVERY_CAP}; answers `null` when the write
|
|
115
|
+
* would change nothing, so the store can skip it.
|
|
116
|
+
*/
|
|
117
|
+
export declare function applyMailGatewayOutcome(ledger: MailGatewayLedger | null, input: {
|
|
118
|
+
sendingDomain: string;
|
|
119
|
+
gateway: MailGateway;
|
|
120
|
+
outcome: MailGatewayOutcome;
|
|
121
|
+
count?: number;
|
|
122
|
+
atMs: number;
|
|
123
|
+
detail?: string | null;
|
|
124
|
+
}): MailGatewayLedger | null;
|
|
125
|
+
/**
|
|
126
|
+
* What a surface knows about the gateway in front of one address: which it
|
|
127
|
+
* is, and what it did with this sending domain's mail over the chip's week
|
|
128
|
+
* and the hold's month.
|
|
129
|
+
*/
|
|
130
|
+
export interface MailGatewayStanding {
|
|
131
|
+
gateway: MailGateway;
|
|
132
|
+
/** Refusals and deliveries in the last {@link MAIL_GATEWAY_CHIP_DAYS} days. */
|
|
133
|
+
blocked7: number;
|
|
134
|
+
delivered7: number;
|
|
135
|
+
/** Refusals and deliveries in the last {@link MAIL_GATEWAY_WINDOW_DAYS} days. */
|
|
136
|
+
blocked30: number;
|
|
137
|
+
delivered30: number;
|
|
138
|
+
}
|
|
139
|
+
/** A gateway's standing with a sending domain, read off its ledger (or none yet). */
|
|
140
|
+
export declare function mailGatewayStanding(gateway: MailGateway, ledger: Pick<MailGatewayLedger, 'days'> | null | undefined, nowMs: number): MailGatewayStanding;
|
|
141
|
+
/**
|
|
142
|
+
* Whether the gateway holds the next bulk send — see the module note. A
|
|
143
|
+
* named gateway only: `other` is many exchanges, and `none` refuses instead.
|
|
144
|
+
*/
|
|
145
|
+
export declare function mailGatewayHolds(standing: Pick<MailGatewayStanding, 'gateway' | 'blocked30' | 'delivered30'>): boolean;
|
|
146
|
+
/** Why a gateway holds, in one sentence a surface or a send record can carry. */
|
|
147
|
+
export declare function mailGatewayHoldSentence(gateway: MailGateway, blocked30: number): string;
|
|
148
|
+
/** The chip's tone: what color a surface gives it. */
|
|
149
|
+
export type MailGatewayChipTone = 'refused' | 'delivered' | 'neutral' | 'blocked';
|
|
150
|
+
export interface MailGatewayChip {
|
|
151
|
+
label: string;
|
|
152
|
+
tone: MailGatewayChipTone;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The chip for one address, or `null` when there is nothing worth a chip: an
|
|
156
|
+
* unrecognized exchange with no verdict this week. Red for a gateway that
|
|
157
|
+
* refused this week, green for one that delivered, neutral for the hosted
|
|
158
|
+
* providers and the security gateways with no verdict yet, and blocked for a
|
|
159
|
+
* domain with no MX.
|
|
160
|
+
*/
|
|
161
|
+
export declare function mailGatewayChip(standing: MailGatewayStanding | null | undefined): MailGatewayChip | null;
|