@aglyn/shared-util-email 1.0.0-beta.217 → 1.0.0-beta.219
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/README.md +58 -19
- package/package.json +3 -3
- package/src/index.d.ts +2 -0
- package/src/index.js +9 -1
- package/src/index.js.map +1 -1
- package/src/lib/email-deliverability.d.ts +9 -2
- package/src/lib/email-deliverability.js.map +1 -1
- package/src/lib/email-delivery-events.d.ts +41 -48
- package/src/lib/email-delivery-events.js +18 -247
- package/src/lib/email-delivery-events.js.map +1 -1
- package/src/lib/email-health.d.ts +30 -43
- package/src/lib/email-health.js +45 -141
- package/src/lib/email-health.js.map +1 -1
- package/src/lib/mail-provider-resend.d.ts +194 -0
- package/src/lib/mail-provider-resend.js +649 -0
- package/src/lib/mail-provider-resend.js.map +1 -0
- package/src/lib/mail-provider-webhook.d.ts +80 -0
- package/src/lib/mail-provider-webhook.js +156 -0
- package/src/lib/mail-provider-webhook.js.map +1 -0
- package/src/lib/mail-provider.d.ts +148 -0
- package/src/lib/mail-provider.js +84 -0
- package/src/lib/mail-provider.js.map +1 -0
- package/src/lib/mail-providers.d.ts +99 -0
- package/src/lib/mail-providers.js +182 -0
- package/src/lib/mail-providers.js.map +1 -0
- package/src/lib/received-email.d.ts +7 -44
- package/src/lib/received-email.js +8 -104
- package/src/lib/received-email.js.map +1 -1
- package/src/lib/risk-notice-catalog.js +7 -1
- package/src/lib/risk-notice-catalog.js.map +1 -1
- package/src/lib/send-ceilings.d.ts +0 -57
- package/src/lib/send-ceilings.js +0 -47
- package/src/lib/send-ceilings.js.map +1 -1
- package/src/lib/send-email.d.ts +36 -58
- package/src/lib/send-email.js +102 -133
- package/src/lib/send-email.js.map +1 -1
- package/src/lib/send-rate.js +4 -3
- package/src/lib/send-rate.js.map +1 -1
- package/src/lib/system-email-catalog.js +3 -3
- package/src/lib/system-email-catalog.js.map +1 -1
- package/src/lib/tenant-email-catalog.d.ts +7 -0
- package/src/lib/tenant-email-catalog.js +10 -591
- package/src/lib/tenant-email-catalog.js.map +1 -1
- package/src/lib/tenant-emails.generated.d.ts +11 -0
- package/src/lib/tenant-emails.generated.js +594 -0
- package/src/lib/tenant-emails.generated.js.map +1 -0
package/README.md
CHANGED
|
@@ -6,34 +6,71 @@
|
|
|
6
6
|
|
|
7
7
|
npm install @aglyn/shared-util-email@beta
|
|
8
8
|
|
|
9
|
-
One place that sends outbound **application** email,
|
|
10
|
-
[Resend](https://resend.com)
|
|
9
|
+
One place that sends outbound **application** email, through a mail provider
|
|
10
|
+
the deployment chooses: [Resend](https://resend.com) by default, or the
|
|
11
|
+
operator's own sender behind a webhook.
|
|
11
12
|
|
|
12
|
-
Before this library the same ~30 lines of `fetch
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
Before this library the same ~30 lines of a vendor `fetch` were copy-pasted
|
|
14
|
+
across 10 files, each reading the env vars itself and each handling failure a
|
|
15
|
+
little differently. Consolidating them means the provider, the sender
|
|
16
|
+
identity, and the failure semantics change in one place.
|
|
16
17
|
|
|
17
18
|
## What is and is not in scope
|
|
18
19
|
|
|
19
20
|
| Mail | Sent by | Here? |
|
|
20
21
|
| --- | --- | --- |
|
|
21
|
-
| Invites, receipts, usage summaries, campaigns, staff alerts |
|
|
22
|
+
| Invites, receipts, usage summaries, campaigns, staff alerts | the mail provider | ✅ |
|
|
22
23
|
| Verification, password reset | Firebase Auth | ❌ |
|
|
23
24
|
| Inbound mail to `@aglyn.com` mailboxes | Google Workspace | ❌ |
|
|
24
25
|
|
|
25
26
|
## Configuration
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
The sender, and the settings of the provider that carries the mail, read at
|
|
29
|
+
call time:
|
|
28
30
|
|
|
29
31
|
```
|
|
30
|
-
RESEND_API_KEY=re_xxxxxxxx
|
|
31
32
|
USAGE_EMAIL_FROM="Aglyn <noreply@aglyn.com>"
|
|
33
|
+
RESEND_API_KEY=re_xxxxxxxx # the default provider
|
|
34
|
+
# or, for your own sender:
|
|
35
|
+
AGLYN_MAIL_WEBHOOK_URL=https://relay.internal/send
|
|
36
|
+
AGLYN_MAIL_WEBHOOK_TOKEN=…
|
|
32
37
|
```
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
`{ sent: false, reason:
|
|
36
|
-
|
|
39
|
+
Both the sender and the provider's settings are required. With either
|
|
40
|
+
missing, `sendEmail()` warns and returns `{ sent: false, reason:
|
|
41
|
+
'unconfigured' }` — it does not throw, so local and preview environments run
|
|
42
|
+
fine without a mail provider.
|
|
43
|
+
|
|
44
|
+
## The mail provider
|
|
45
|
+
|
|
46
|
+
`sendEmail()` decides whether, as whom and to whom a message leaves —
|
|
47
|
+
suppression, the send-rate governor, the phishing screen, the sending
|
|
48
|
+
identity, the unsubscribe footer. A `MailProvider` (`mail-provider.ts`) only
|
|
49
|
+
carries the finished message, and answers the platform's other questions
|
|
50
|
+
about mail in the platform's own vocabulary: whether its credential works,
|
|
51
|
+
what a delivery webhook said, what a received message contained, what the
|
|
52
|
+
account already sent.
|
|
53
|
+
|
|
54
|
+
`mail-providers.ts` picks the deployment's provider:
|
|
55
|
+
|
|
56
|
+
- `AGLYN_MAIL_PROVIDER` names one — `resend`, `webhook`, or an id a plugin or
|
|
57
|
+
fork registered with `registerMailProvider()`.
|
|
58
|
+
- Unset, the first built-in with its settings wins: `resend` when
|
|
59
|
+
`RESEND_API_KEY` is set, `webhook` when only `AGLYN_MAIL_WEBHOOK_URL` is,
|
|
60
|
+
otherwise `resend`, unconfigured.
|
|
61
|
+
- A name that is neither built in nor registered is **refused**: every send
|
|
62
|
+
is skipped with an error naming it, and nothing falls back to another
|
|
63
|
+
vendor.
|
|
64
|
+
|
|
65
|
+
The built-ins are compiled into this library rather than registered by a
|
|
66
|
+
plugin because this is the transactional rail: it must send from every server
|
|
67
|
+
process with or without a plugin loaded, and a registration that did not
|
|
68
|
+
happen would look exactly like mail that is switched off.
|
|
69
|
+
|
|
70
|
+
The rest of the platform asks through `normalizeDeliveryEvents()`,
|
|
71
|
+
`readInboundMailEvent()` and `mailProviderReads()`, never through a provider
|
|
72
|
+
by name. `mail-provider-resend.ts` is the only module that knows Resend's
|
|
73
|
+
wire format, and `mail-provider-webhook.ts` documents the webhook's.
|
|
37
74
|
|
|
38
75
|
Setup and DNS are documented in [`docs/EMAIL_SETUP.md`](../../../../docs/EMAIL_SETUP.md).
|
|
39
76
|
|
|
@@ -66,7 +103,8 @@ correct, since the point of `USAGE_EMAIL_FROM` is one verified identity).
|
|
|
66
103
|
|
|
67
104
|
### The transport boundary
|
|
68
105
|
|
|
69
|
-
`postResendEmail()` is the only function that
|
|
106
|
+
`postResendEmail()` (in `mail-provider-resend.ts`) is the only function that
|
|
107
|
+
POSTs to Resend's send endpoint,
|
|
70
108
|
and it **throws** on a payload with no `to` rather than putting it on the wire.
|
|
71
109
|
Such a payload cannot become a message; Resend answers `422
|
|
72
110
|
missing_required_field`, which costs an API call and then sits in the vendor
|
|
@@ -112,12 +150,13 @@ import { isEmailConfigured, describeEmailConfig, checkEmailCredentials }
|
|
|
112
150
|
from '@aglyn/shared-util-email'
|
|
113
151
|
```
|
|
114
152
|
|
|
115
|
-
- `isEmailConfigured()` —
|
|
116
|
-
route instead of pretending to have sent.
|
|
117
|
-
- `describeEmailConfig()` —
|
|
118
|
-
the
|
|
119
|
-
- `checkEmailCredentials()` — asks
|
|
120
|
-
**without sending anything
|
|
153
|
+
- `isEmailConfigured()` — the provider has its settings and a sender is set.
|
|
154
|
+
Use it to answer `501` from a route instead of pretending to have sent.
|
|
155
|
+
- `describeEmailConfig()` — which provider, which of its settings are
|
|
156
|
+
missing, plus the sender and its domain, with no credential ever included.
|
|
157
|
+
- `checkEmailCredentials()` — asks the provider whether its credential is
|
|
158
|
+
accepted **without sending anything**. A provider with no probe answers
|
|
159
|
+
`unknown`. Resend's probe `GET`s the domains collection and
|
|
121
160
|
reading the error NAME rather than the status. A `2xx`, or a
|
|
122
161
|
`restricted_api_key`/`invalid_permission` rejection (what a sending-scoped
|
|
123
162
|
key gets, and it can only be reached once Resend has authenticated the key),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/shared-util-email",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.219",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
28
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.219",
|
|
29
|
+
"@aglyn/shared-util-http": "1.0.0-beta.219",
|
|
30
30
|
"@msgpack/msgpack": "^3.1.3",
|
|
31
31
|
"@swc/helpers": "0.5.23"
|
|
32
32
|
},
|
package/src/index.d.ts
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
17
|
export * from './lib/send-email';
|
|
18
|
+
export * from './lib/mail-provider';
|
|
19
|
+
export * from './lib/mail-providers';
|
|
18
20
|
export * from './lib/send-rate';
|
|
19
21
|
export * from './lib/sender-reputation';
|
|
20
22
|
export * from './lib/send-ceilings';
|
package/src/index.js
CHANGED
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ export * from "./lib/send-email.js";
|
|
17
|
+
// Who carries the mail (AGL-3080): the provider contract, and the selection
|
|
18
|
+
// of the deployment's provider from the built-ins and any a plugin
|
|
19
|
+
// registered. The providers themselves are imported by their own paths —
|
|
20
|
+
// `@aglyn/shared-util-email/mail-provider-resend`, `…/mail-provider-webhook`
|
|
21
|
+
// — because nothing outside the seam should name one.
|
|
22
|
+
export * from "./lib/mail-provider.js";
|
|
23
|
+
export * from "./lib/mail-providers.js";
|
|
17
24
|
export * from "./lib/send-rate.js";
|
|
18
25
|
export * from "./lib/sender-reputation.js";
|
|
19
26
|
export * from "./lib/send-ceilings.js";
|
|
@@ -32,7 +39,8 @@ export * from "./lib/marketing-send.js";
|
|
|
32
39
|
// The List-Unsubscribe setting sequences and campaigns share (AGL-3307).
|
|
33
40
|
export * from "./lib/list-unsubscribe.js";
|
|
34
41
|
export * from "./lib/email-delivery-events.js";
|
|
35
|
-
//
|
|
42
|
+
// Received mail in the platform's vocabulary, which the CRM's capture route
|
|
43
|
+
// files from (AGL-2657); the provider reads it.
|
|
36
44
|
// Its neighbor `./lib/svix-signature` is deliberately NOT re-exported here:
|
|
37
45
|
// it holds a `crypto` HMAC, and this barrel is reached from the browser
|
|
38
46
|
// through `@aglyn/shared-ui-email-campaigns`, so a webhook imports it by its
|
package/src/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/shared/util/email/src/index.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\nexport * from './lib/send-email'\nexport * from './lib/send-rate'\nexport * from './lib/sender-reputation'\nexport * from './lib/send-ceilings'\nexport * from './lib/sending-domain'\nexport * from './lib/tracking-host'\nexport * from './lib/sending-mailbox'\nexport * from './lib/host-sender'\nexport * from './lib/platform-sending-domain'\nexport * from './lib/email-health'\nexport * from './lib/system-email-catalog'\nexport * from './lib/tenant-email-catalog'\nexport * from './lib/host-email-render'\nexport * from './lib/email-render'\nexport * from './lib/text-email-html'\nexport * from './lib/marketing-send'\n// The List-Unsubscribe setting sequences and campaigns share (AGL-3307).\nexport * from './lib/list-unsubscribe'\nexport * from './lib/email-delivery-events'\n//
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/shared/util/email/src/index.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\nexport * from './lib/send-email'\n// Who carries the mail (AGL-3080): the provider contract, and the selection\n// of the deployment's provider from the built-ins and any a plugin\n// registered. The providers themselves are imported by their own paths —\n// `@aglyn/shared-util-email/mail-provider-resend`, `…/mail-provider-webhook`\n// — because nothing outside the seam should name one.\nexport * from './lib/mail-provider'\nexport * from './lib/mail-providers'\nexport * from './lib/send-rate'\nexport * from './lib/sender-reputation'\nexport * from './lib/send-ceilings'\nexport * from './lib/sending-domain'\nexport * from './lib/tracking-host'\nexport * from './lib/sending-mailbox'\nexport * from './lib/host-sender'\nexport * from './lib/platform-sending-domain'\nexport * from './lib/email-health'\nexport * from './lib/system-email-catalog'\nexport * from './lib/tenant-email-catalog'\nexport * from './lib/host-email-render'\nexport * from './lib/email-render'\nexport * from './lib/text-email-html'\nexport * from './lib/marketing-send'\n// The List-Unsubscribe setting sequences and campaigns share (AGL-3307).\nexport * from './lib/list-unsubscribe'\nexport * from './lib/email-delivery-events'\n// Received mail in the platform's vocabulary, which the CRM's capture route\n// files from (AGL-2657); the provider reads it.\n// Its neighbor `./lib/svix-signature` is deliberately NOT re-exported here:\n// it holds a `crypto` HMAC, and this barrel is reached from the browser\n// through `@aglyn/shared-ui-email-campaigns`, so a webhook imports it by its\n// own path — `@aglyn/shared-util-email/svix-signature`.\nexport * from './lib/received-email'\nexport * from './lib/email-revenue-window'\n// Exported so the console drift guard can pin it against @aglyn/aglyn.\nexport * from './lib/email-media-src'\n// Same arrangement, same reason (AGL-1223): a copy of `decodeStoredNodes`\n// this scope:shared lib may not import, pinned by `email-nodes-drift.spec.ts`.\nexport * from './lib/stored-email-nodes'\nexport * from './lib/email-merge'\n// The deliverability engine every send path asks (AGL-3328): the gateway an\n// MX names and its ledger, the bounce reader, and the address verdicts. Pure\n// — the DNS lookup and the store are injected by `@aglyn/tenant-data-admin`,\n// so nothing here reaches `node:dns` from the browser.\nexport * from './lib/mail-gateway'\nexport * from './lib/mail-bounce'\nexport * from './lib/email-deliverability'\n// The sender's side of the same engine (AGL-3328): SPF, DKIM and DMARC for\n// the domain mail leaves from, and whether they align. Pure as well; the\n// lookups are `@aglyn/tenant-data-admin`'s.\nexport * from './lib/sender-readiness'\n// The outbound phishing screen a young workspace's campaigns and automated\n// email pass (AGL-3356). Pure; the hold it leads to is `tenant-data-admin`'s.\nexport * from './lib/outbound-phishing-screen'\n// The send seam's gate for that screen — the store a hold writes is injected\n// by `@aglyn/tenant-data-admin`, like the governor's.\nexport * from './lib/outbound-screen-gate'\n// The same screen read over a published page (AGL-3356): lookalike links and\n// embeds, author-defined credential fields, a brand's call to action.\nexport * from './lib/hosted-page-screen'\n// The site's theme palette as plain data (AGL-3370), so a palette token an\n// author picked resolves in mail. Built without MUI; `shared-ui-theme`'s\n// parity spec holds it equal to the theme.\nexport * from './lib/email-palette'\nexport * from './lib/email-design-chrome'\n"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,cAAc,sBAAkB;AAChC,4EAA4E;AAC5E,mEAAmE;AACnE,yEAAyE;AACzE,6EAA6E;AAC7E,sDAAsD;AACtD,cAAc,yBAAqB;AACnC,cAAc,0BAAsB;AACpC,cAAc,qBAAiB;AAC/B,cAAc,6BAAyB;AACvC,cAAc,yBAAqB;AACnC,cAAc,0BAAsB;AACpC,cAAc,yBAAqB;AACnC,cAAc,2BAAuB;AACrC,cAAc,uBAAmB;AACjC,cAAc,mCAA+B;AAC7C,cAAc,wBAAoB;AAClC,cAAc,gCAA4B;AAC1C,cAAc,gCAA4B;AAC1C,cAAc,6BAAyB;AACvC,cAAc,wBAAoB;AAClC,cAAc,2BAAuB;AACrC,cAAc,0BAAsB;AACpC,yEAAyE;AACzE,cAAc,4BAAwB;AACtC,cAAc,iCAA6B;AAC3C,4EAA4E;AAC5E,gDAAgD;AAChD,4EAA4E;AAC5E,wEAAwE;AACxE,6EAA6E;AAC7E,wDAAwD;AACxD,cAAc,0BAAsB;AACpC,cAAc,gCAA4B;AAC1C,uEAAuE;AACvE,cAAc,2BAAuB;AACrC,0EAA0E;AAC1E,+EAA+E;AAC/E,cAAc,8BAA0B;AACxC,cAAc,uBAAmB;AACjC,4EAA4E;AAC5E,6EAA6E;AAC7E,6EAA6E;AAC7E,uDAAuD;AACvD,cAAc,wBAAoB;AAClC,cAAc,uBAAmB;AACjC,cAAc,gCAA4B;AAC1C,2EAA2E;AAC3E,yEAAyE;AACzE,4CAA4C;AAC5C,cAAc,4BAAwB;AACtC,2EAA2E;AAC3E,8EAA8E;AAC9E,cAAc,oCAAgC;AAC9C,6EAA6E;AAC7E,sDAAsD;AACtD,cAAc,gCAA4B;AAC1C,6EAA6E;AAC7E,sEAAsE;AACtE,cAAc,8BAA0B;AACxC,2EAA2E;AAC3E,yEAAyE;AACzE,2CAA2C;AAC3C,cAAc,yBAAqB;AACnC,cAAc,+BAA2B"}
|
|
@@ -99,7 +99,9 @@ export declare function resolveMailDomain(resolver: MailDnsResolver, domain: str
|
|
|
99
99
|
* somebody who has not written to the sender.
|
|
100
100
|
*/
|
|
101
101
|
export type EmailSendPurpose = 'transactional' | 'bulk' | 'cold';
|
|
102
|
-
export type EmailDeliverabilityCode = 'invalid_syntax' | 'no_mx' | 'null_mx' | 'implicit_mx' | 'typo_domain' | 'disposable_domain' | 'role_address' | 'gateway_held'
|
|
102
|
+
export type EmailDeliverabilityCode = 'invalid_syntax' | 'no_mx' | 'null_mx' | 'implicit_mx' | 'typo_domain' | 'disposable_domain' | 'role_address' | 'gateway_held'
|
|
103
|
+
/** The account holding the address is banned (AGL-3420). */
|
|
104
|
+
| 'account_banned';
|
|
103
105
|
export type EmailDeliverabilitySeverity = 'refuse' | 'hold' | 'warn';
|
|
104
106
|
export interface EmailDeliverabilityFinding {
|
|
105
107
|
code: EmailDeliverabilityCode;
|
|
@@ -169,9 +171,14 @@ export interface EmailDeliverabilityPreflightRequest {
|
|
|
169
171
|
sendingSource: 'custom' | 'shared' | 'platform';
|
|
170
172
|
context?: string;
|
|
171
173
|
hostId?: string | null;
|
|
174
|
+
/** The send is a lock or lift notice, which reaches a banned account. */
|
|
175
|
+
accountBanExempt?: boolean;
|
|
172
176
|
}
|
|
173
177
|
export interface EmailDeliverabilityPreflightVerdict {
|
|
174
|
-
/**
|
|
178
|
+
/**
|
|
179
|
+
* Recipients whose domain takes no mail, or whose account is banned.
|
|
180
|
+
* Never sent, whatever the purpose.
|
|
181
|
+
*/
|
|
175
182
|
refused: Array<{
|
|
176
183
|
email: string;
|
|
177
184
|
code: EmailDeliverabilityCode;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-deliverability.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 * WILL THIS ADDRESS BOUNCE? — ANSWERED BEFORE THE SEND (AGL-3328).\n *\n * The one engine every email path asks: the Resend sender through\n * `sendEmail`'s preflight seam, campaigns before their review count, the\n * CRM at capture time, and Sequences at enrollment and before each send.\n * It answers from three free facts and nothing else:\n *\n * 1. **The address itself** — its syntax, a typo'd provider domain\n * (`gmial.com`, which is SUGGESTED, never corrected), a disposable\n * domain, a role address (`info@`), which warns for bulk and cold mail\n * only.\n * 2. **The domain's MX** — no MX at all, RFC 7505's null MX (`.`), or no MX\n * but an address record, which RFC 5321 §5.1 still delivers to and so\n * only warns.\n * 3. **The gateway's history with the sending domain** — see\n * `mail-gateway.ts`: a gateway that refused this sender twice lately\n * holds the next BULK send into it.\n *\n * Never an SMTP `RCPT TO` probe. A probe is a connection to the recipient's\n * server that sends nothing, which is exactly what a directory-harvest\n * attack looks like, and gateways list the probing IP for it. Nor a paid\n * verification API: the free checks are the whole of this module.\n *\n * ## The two severities that stop mail, and who they may stop\n *\n * - **refuse** — the domain takes no mail. Every purpose, transactional\n * included: a password reset to a domain with no MX is a hard bounce\n * charged to the sending domain, and nobody receives it either way.\n * - **hold** — the gateway refused this sender twice. BULK and COLD mail\n * only. Mail the recipient asked for (a password reset, a receipt, a\n * booking confirmation) is never held for a gateway: the person is\n * waiting for it, and one more refusal costs less than their not getting\n * it.\n *\n * A public mailbox provider is never refused for its MX. `gmail.com` has MX\n * records; a resolver that says otherwise is having a bad minute, and a\n * refusal there would suppress real people.\n *\n * ## Pure\n *\n * DNS is handed in as a {@link MailDnsResolver}. This library's barrel\n * reaches the browser, so `node:dns` belongs to the server layer that\n * installs the preflight — `@aglyn/tenant-data-admin`.\n *==========================================*/\n\nimport {\n classifyMailGateway,\n MAIL_DOMAIN_INTEL_TTL_MS,\n MAIL_DOMAIN_NEGATIVE_TTL_MS,\n type MailGateway,\n type MailGatewayStanding,\n mailGatewayHolds,\n mailGatewayHoldSentence,\n normalizeMailHost,\n} from './mail-gateway'\n\n/*==========================================\n * THE ADDRESS\n *==========================================*/\n\n/** RFC 1035's ceiling on a name, which no real domain reaches. */\nconst DOMAIN_MAX = 253\n\n/** A label: letters, digits and hyphens, not starting or ending on a hyphen. */\nconst LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/\n\n/** The local part a mailbox provider accepts in practice: RFC 5322's dot-atom. */\nconst LOCAL_PART = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/\n\n/** An address as the checks read it: trimmed and lowercased, or `null` when it is not one. */\nexport function normalizeDeliverabilityEmail(value: unknown): string | null {\n const address = String(value ?? '')\n .trim()\n .toLowerCase()\n const at = address.lastIndexOf('@')\n if (at < 1 || at === address.length - 1 || address.length > 254) return null\n const local = address.slice(0, at)\n const domain = address.slice(at + 1)\n if (local.length > 64 || !LOCAL_PART.test(local)) return null\n if (!isDeliverabilityDomain(domain)) return null\n return address\n}\n\n/** Whether a value is a domain with at least two labels and a non-numeric top level. */\nexport function isDeliverabilityDomain(value: unknown): boolean {\n const domain = String(value ?? '')\n if (!domain || domain.length > DOMAIN_MAX) return false\n const labels = domain.split('.')\n if (labels.length < 2 || !labels.every((label) => LABEL.test(label))) return false\n return !/^\\d+$/.test(labels[labels.length - 1])\n}\n\n/** The domain of an address, lowercased, or `null` for a value that is not one. */\nexport function emailAddressDomain(email: unknown): string | null {\n const address = normalizeDeliverabilityEmail(email)\n return address ? address.slice(address.lastIndexOf('@') + 1) : null\n}\n\n/**\n * The mailboxes a company's shared inbox answers as rather than a person.\n * Legal to mail and often read, but a cold email or a campaign sent there\n * reaches nobody who asked for it, and a spam report from a shared inbox is\n * a report from the whole company.\n */\nconst ROLE_LOCAL_PARTS: ReadonlySet<string> = new Set(\n // A word list rather than quoted strings: `marketing` is also a plugin's\n // id, and `check:plugin-domain-in-core` reads a quoted one as a plugin's\n // domain leaking into shared code — which a mailbox name is not.\n (\n 'abuse accounts admin administrator billing careers contact enquiries ' +\n 'help hello hr info inquiries jobs mail marketing media news no-reply ' +\n 'noreply office postmaster press privacy sales security service support ' +\n 'team webmaster'\n ).split(' '),\n)\n\n/** Whether an address is a role mailbox (`info@`, `sales@`), read before any `+tag`. */\nexport function isRoleEmailAddress(email: unknown): boolean {\n const address = normalizeDeliverabilityEmail(email)\n if (!address) return false\n const local = address.slice(0, address.lastIndexOf('@')).split('+')[0]\n return ROLE_LOCAL_PARTS.has(local)\n}\n\n/**\n * Domains that hand out a mailbox that expires. Not a complete list — no\n * list is — but the ones a signup form actually meets.\n */\nconst DISPOSABLE_DOMAINS: ReadonlySet<string> = new Set([\n '10minutemail.com',\n '20minutemail.com',\n 'burnermail.io',\n 'discard.email',\n 'dispostable.com',\n 'emailondeck.com',\n 'fakeinbox.com',\n 'getairmail.com',\n 'getnada.com',\n 'guerrillamail.com',\n 'guerrillamail.net',\n 'guerrillamail.org',\n 'guerrillamailblock.com',\n 'maildrop.cc',\n 'mailinator.com',\n 'mailinator.net',\n 'mailnesia.com',\n 'mintemail.com',\n 'mohmal.com',\n 'mytemp.email',\n 'sharklasers.com',\n 'spamgourmet.com',\n 'temp-mail.org',\n 'tempail.com',\n 'tempmail.com',\n 'tempmailo.com',\n 'tempr.email',\n 'throwawaymail.com',\n 'trashmail.com',\n 'trashmail.de',\n 'yopmail.com',\n 'yopmail.net',\n])\n\n/** Whether a domain is a disposable-mailbox provider. */\nexport function isDisposableEmailDomain(domain: unknown): boolean {\n return DISPOSABLE_DOMAINS.has(normalizeMailHost(domain))\n}\n\n/**\n * The providers a mistyped domain is compared against. Long labels only: a\n * three-letter label (`aol`, `msn`) is one keystroke from dozens of real\n * domains, and a suggestion there would be noise.\n */\nconst TYPO_TARGETS: readonly string[] = [\n 'gmail.com',\n 'googlemail.com',\n 'yahoo.com',\n 'hotmail.com',\n 'outlook.com',\n 'icloud.com',\n 'comcast.net',\n 'protonmail.com',\n 'verizon.net',\n 'sbcglobal.net',\n 'bellsouth.net',\n]\n\n/** Real providers one keystroke from a target, which must never be \"corrected\". */\nconst TYPO_EXEMPT: ReadonlySet<string> = new Set([\n 'mail.com',\n 'gmx.com',\n 'ymail.com',\n 'email.com',\n 'hotmail.co.uk',\n 'yahoo.co.uk',\n 'outlook.fr',\n])\n\n/** Top-level domains that are a slip of `com` or `net`. */\nconst TLD_SLIPS: Readonly<Record<string, string>> = {\n co: 'com',\n con: 'com',\n cm: 'com',\n om: 'com',\n comm: 'com',\n cmo: 'com',\n ocm: 'com',\n vom: 'com',\n xom: 'com',\n cpm: 'com',\n coom: 'com',\n ne: 'net',\n nte: 'net',\n ner: 'net',\n nett: 'net',\n}\n\n/** Damerau–Levenshtein distance, stopping early above 1: only \"one slip\" matters here. */\nfunction withinOneEdit(a: string, b: string): boolean {\n if (a === b) return true\n if (Math.abs(a.length - b.length) > 1) return false\n if (a.length === b.length) {\n const diffs: number[] = []\n for (let index = 0; index < a.length; index += 1) {\n if (a[index] !== b[index]) diffs.push(index)\n if (diffs.length > 2) return false\n }\n if (diffs.length === 1) return true\n // One transposition of neighbors: `gmial` for `gmail`.\n return (\n diffs.length === 2 &&\n diffs[1] === diffs[0] + 1 &&\n a[diffs[0]] === b[diffs[1]] &&\n a[diffs[1]] === b[diffs[0]]\n )\n }\n const [longer, shorter] = a.length > b.length ? [a, b] : [b, a]\n for (let index = 0; index < longer.length; index += 1) {\n if (longer.slice(0, index) + longer.slice(index + 1) === shorter) return true\n }\n return false\n}\n\n/**\n * The provider a domain most likely meant, or `null` — a SUGGESTION a\n * surface may offer, never a correction applied: `gmial.com` may be a real\n * domain somebody owns, and changing an address a person typed is sending\n * their mail to someone else.\n */\nexport function suggestEmailDomain(domain: unknown): string | null {\n const name = normalizeMailHost(domain)\n if (!name || TYPO_EXEMPT.has(name) || TYPO_TARGETS.includes(name)) return null\n const dot = name.lastIndexOf('.')\n if (dot < 1) return null\n const label = name.slice(0, dot)\n const tld = name.slice(dot + 1)\n for (const target of TYPO_TARGETS) {\n const targetDot = target.lastIndexOf('.')\n const targetLabel = target.slice(0, targetDot)\n const targetTld = target.slice(targetDot + 1)\n if (label === targetLabel && TLD_SLIPS[tld] === targetTld) return target\n if (tld === targetTld && label.length >= 4 && withinOneEdit(label, targetLabel)) return target\n }\n return null\n}\n\n/*==========================================\n * THE DOMAIN'S MX\n *==========================================*/\n\n/** One MX record as a resolver answers it. */\nexport interface MailMxRecord {\n exchange: string\n priority: number\n}\n\n/**\n * The DNS the checks read, handed in by the server layer.\n *\n * `resolveMx` resolves the domain's MX records. A domain that publishes\n * none resolves `[]` or rejects with a `code` of `ENOTFOUND`, `ENODATA` or\n * `NXDOMAIN` — Node's own convention — and any other rejection is a\n * resolver that could not answer, which the checks read as \"unknown\" and\n * never as \"no MX\".\n *\n * `resolveAddress`, when given, answers whether the domain has an address\n * record (A or AAAA): the implicit MX a domain with no MX is still\n * delivered to. Omitted, a domain with no MX reads as `no_mx`.\n */\nexport interface MailDnsResolver {\n resolveMx(domain: string): Promise<ReadonlyArray<MailMxRecord>>\n resolveAddress?(domain: string): Promise<boolean>\n}\n\n/**\n * What a domain's DNS says about receiving mail.\n *\n * - `mx` — it publishes MX records.\n * - `implicit_mx` — no MX, but an address record, which RFC 5321 §5.1 still\n * delivers to. Deliverable; warned, because it is usually a domain whose\n * mail was never set up.\n * - `null_mx` — RFC 7505's `MX 0 .`: the domain says outright it takes no mail.\n * - `no_mx` — no MX and no address record: nothing to deliver to.\n */\nexport type MailDomainStatus = 'mx' | 'implicit_mx' | 'null_mx' | 'no_mx'\n\n/** One domain's answer, as the platform cache stores it. */\nexport interface MailDomainIntel {\n domain: string\n status: MailDomainStatus\n /** The MX exchanges, lowest preference first; empty for every status but `mx`. */\n mx: string[]\n gateway: MailGateway\n /** When the domain was looked up; trusted for {@link mailDomainIntelTtlMs}. */\n resolvedAtMs: number\n}\n\nexport const MAIL_DOMAIN_STATUSES: readonly MailDomainStatus[] = ['mx', 'implicit_mx', 'null_mx', 'no_mx']\n\n/** Whether a status means the domain takes no mail. */\nexport function mailDomainRefusesMail(status: MailDomainStatus | null | undefined): boolean {\n return status === 'no_mx' || status === 'null_mx'\n}\n\n/** How long an answer is trusted: a week for a domain that takes mail, a day for one that does not. */\nexport function mailDomainIntelTtlMs(status: MailDomainStatus): number {\n return mailDomainRefusesMail(status) ? MAIL_DOMAIN_NEGATIVE_TTL_MS : MAIL_DOMAIN_INTEL_TTL_MS\n}\n\n/** Whether a cached answer is still trusted at `nowMs`. */\nexport function isMailDomainIntelFresh(intel: MailDomainIntel | null | undefined, nowMs: number): boolean {\n return Boolean(intel) && intel.resolvedAtMs + mailDomainIntelTtlMs(intel.status) > nowMs\n}\n\n/** The resolver's codes that mean \"this domain has no such record\", as opposed to \"I could not ask\". */\nconst NO_RECORD_CODES = new Set(['ENOTFOUND', 'ENODATA', 'NXDOMAIN', 'NOTFOUND'])\n\n/** Whether a resolver rejection is an answer (\"no such record\") rather than a failure to get one. */\nexport function isNoRecordDnsError(error: unknown): boolean {\n return NO_RECORD_CODES.has(String((error as { code?: unknown })?.code ?? '').toUpperCase())\n}\n\n/** A stored answer in its model shape, or `null` for no document or an unreadable one. */\nexport function readStoredMailDomainIntel(\n id: string,\n data: Record<string, unknown> | null | undefined,\n): MailDomainIntel | null {\n if (!data) return null\n const status = (MAIL_DOMAIN_STATUSES as readonly unknown[]).includes(data['status'])\n ? (data['status'] as MailDomainStatus)\n : null\n if (!status) return null\n const mx = Array.isArray(data['mx']) ? data['mx'].filter((entry): entry is string => typeof entry === 'string') : []\n const resolvedAtMs = typeof data['resolvedAtMs'] === 'number' && Number.isFinite(data['resolvedAtMs']) ? data['resolvedAtMs'] : 0\n return {\n domain: typeof data['domain'] === 'string' && data['domain'] ? data['domain'] : id,\n status,\n mx,\n gateway: status === 'mx' ? classifyMailGateway(mx) : status === 'implicit_mx' ? 'other' : 'none',\n resolvedAtMs,\n }\n}\n\n/**\n * A domain's mail setup, looked up — see {@link MailDomainStatus}. Rejects\n * when the resolver could not answer, and the caller decides what a missing\n * answer means; every path here reads it as \"unknown\" and sends.\n */\nexport async function resolveMailDomain(\n resolver: MailDnsResolver,\n domain: string,\n nowMs: number,\n): Promise<MailDomainIntel> {\n let records: ReadonlyArray<MailMxRecord>\n try {\n records = await resolver.resolveMx(domain)\n } catch (error) {\n if (!isNoRecordDnsError(error)) throw error\n records = []\n }\n const exchanges = [...(records ?? [])]\n .filter((record) => record && typeof record.exchange === 'string')\n .sort((a, b) => (Number(a.priority) || 0) - (Number(b.priority) || 0))\n .map((record) => normalizeMailHost(record.exchange))\n const real = exchanges.filter((exchange) => exchange && exchange !== '.')\n if (real.length) {\n return { domain, status: 'mx', mx: real, gateway: classifyMailGateway(real), resolvedAtMs: nowMs }\n }\n // Records that are all \".\" (or empty, as Node reports a null MX) are RFC\n // 7505's null MX: the domain says it takes no mail, whatever its A says.\n if (exchanges.length) return { domain, status: 'null_mx', mx: [], gateway: 'none', resolvedAtMs: nowMs }\n if (resolver.resolveAddress) {\n let addressed: boolean\n try {\n addressed = await resolver.resolveAddress(domain)\n } catch (error) {\n if (!isNoRecordDnsError(error)) throw error\n addressed = false\n }\n if (addressed) return { domain, status: 'implicit_mx', mx: [], gateway: 'other', resolvedAtMs: nowMs }\n }\n return { domain, status: 'no_mx', mx: [], gateway: 'none', resolvedAtMs: nowMs }\n}\n\n/*==========================================\n * THE VERDICT\n *==========================================*/\n\n/**\n * Who a message is for, which decides what may stop it — see the module\n * note. `transactional` is mail the recipient asked for; `bulk` is a\n * campaign, a workflow's marketing step, a sweep; `cold` is a sequence to\n * somebody who has not written to the sender.\n */\nexport type EmailSendPurpose = 'transactional' | 'bulk' | 'cold'\n\nexport type EmailDeliverabilityCode =\n | 'invalid_syntax'\n | 'no_mx'\n | 'null_mx'\n | 'implicit_mx'\n | 'typo_domain'\n | 'disposable_domain'\n | 'role_address'\n | 'gateway_held'\n\nexport type EmailDeliverabilitySeverity = 'refuse' | 'hold' | 'warn'\n\nexport interface EmailDeliverabilityFinding {\n code: EmailDeliverabilityCode\n severity: EmailDeliverabilitySeverity\n /** A sentence a surface shows as is. */\n message: string\n /** `typo_domain` only: the address with the suggested domain. Never applied for the person. */\n suggestion?: string\n}\n\n/**\n * What the checks conclude, worst first: `refused` (never sent), `held`\n * (bulk and cold mail wait for a person), `deliverable` (nothing stops it,\n * warnings or not), `unchecked` (the MX could not be read, so nothing is\n * known — and nothing is stopped).\n */\nexport type EmailDeliverabilityOutcome = 'refused' | 'held' | 'deliverable' | 'unchecked'\n\nexport interface EmailDeliverabilityVerdict {\n /** The address, normalized, or `null` when it is not one. */\n email: string | null\n domain: string | null\n outcome: EmailDeliverabilityOutcome\n findings: EmailDeliverabilityFinding[]\n intel: MailDomainIntel | null\n gateway: MailGatewayStanding | null\n}\n\nexport interface EmailDeliverabilityInput {\n email: unknown\n purpose: EmailSendPurpose\n /** The domain's MX answer; `null` or absent when it could not be read. */\n intel?: MailDomainIntel | null\n /** The gateway's standing with the sending domain, when the caller read the ledger. */\n gateway?: MailGatewayStanding | null\n /**\n * The domain is a public mailbox provider (`gmail.com`), which is never\n * refused for its MX. The list lives with the CRM's, so the caller asks.\n */\n publicMailbox?: boolean\n /** A person already released the gateway hold for this address. */\n holdReleased?: boolean\n}\n\nconst SEVERITY_RANK: Record<EmailDeliverabilitySeverity, number> = { refuse: 0, hold: 1, warn: 2 }\n\n/** The whole verdict for one address — see the module note. */\nexport function assessEmailDeliverability(input: EmailDeliverabilityInput): EmailDeliverabilityVerdict {\n const email = normalizeDeliverabilityEmail(input.email)\n if (!email) {\n return {\n email: null,\n domain: null,\n outcome: 'refused',\n findings: [\n {\n code: 'invalid_syntax',\n severity: 'refuse',\n message: `${String(input.email ?? '').trim() || 'This'} is not an email address.`,\n },\n ],\n intel: null,\n gateway: null,\n }\n }\n const domain = email.slice(email.lastIndexOf('@') + 1)\n const findings: EmailDeliverabilityFinding[] = []\n const bulk = input.purpose !== 'transactional'\n const intel = input.intel ?? null\n\n if (intel && !input.publicMailbox) {\n if (intel.status === 'no_mx') {\n findings.push({\n code: 'no_mx',\n severity: 'refuse',\n message: `${domain} has no mail server, so ${email} would bounce.`,\n })\n } else if (intel.status === 'null_mx') {\n findings.push({\n code: 'null_mx',\n severity: 'refuse',\n message: `${domain} publishes a null MX record: it says it accepts no email, so ${email} would bounce.`,\n })\n } else if (intel.status === 'implicit_mx') {\n findings.push({\n code: 'implicit_mx',\n severity: 'warn',\n message: `${domain} has no MX record. Mail may still reach its web server's address, but most domains like this never set up email.`,\n })\n }\n }\n\n const suggestion = suggestEmailDomain(domain)\n if (suggestion) {\n const local = email.slice(0, email.lastIndexOf('@'))\n findings.push({\n code: 'typo_domain',\n severity: 'warn',\n message: `Did you mean ${local}@${suggestion}? ${domain} looks like a typo.`,\n suggestion: `${local}@${suggestion}`,\n })\n }\n if (isDisposableEmailDomain(domain)) {\n findings.push({\n code: 'disposable_domain',\n severity: 'warn',\n message: `${domain} hands out mailboxes that expire, so this address may stop working soon.`,\n })\n }\n if (bulk && isRoleEmailAddress(email)) {\n findings.push({\n code: 'role_address',\n severity: 'warn',\n message: `${email} is a shared role inbox rather than a person, which marketing and cold mail should avoid.`,\n })\n }\n\n const gateway = input.gateway ?? null\n if (bulk && gateway && input.holdReleased !== true && mailGatewayHolds(gateway)) {\n findings.push({\n code: 'gateway_held',\n severity: 'hold',\n message: `${mailGatewayHoldSentence(gateway.gateway, gateway.blocked30)}, so bulk mail to ${domain} is held.`,\n })\n }\n\n findings.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])\n const outcome: EmailDeliverabilityOutcome = findings.some((finding) => finding.severity === 'refuse')\n ? 'refused'\n : findings.some((finding) => finding.severity === 'hold')\n ? 'held'\n : intel\n ? 'deliverable'\n : 'unchecked'\n return { email, domain, outcome, findings, intel, gateway }\n}\n\n/** The finding that decided a refusal or a hold, or `null` when nothing stops the address. */\nexport function decidingDeliverabilityFinding(verdict: EmailDeliverabilityVerdict): EmailDeliverabilityFinding | null {\n return verdict.findings.find((finding) => finding.severity !== 'warn') ?? null\n}\n\n/**\n * What a bulk send's review says about the addresses the checks took out,\n * or `null` when they took out nobody: \"3 recipients excluded: no mail\n * server · 2 behind a gateway that refused this sender\".\n */\nexport function deliverabilityExclusionSummary(counts: {\n noMailServer?: number | null\n gatewayHeld?: number | null\n}): string | null {\n const noMailServer = Math.max(0, Math.floor(Number(counts?.noMailServer) || 0))\n const gatewayHeld = Math.max(0, Math.floor(Number(counts?.gatewayHeld) || 0))\n const total = noMailServer + gatewayHeld\n if (!total) return null\n const parts = [\n ...(noMailServer ? [`${noMailServer.toLocaleString('en-US')} no mail server`] : []),\n ...(gatewayHeld ? [`${gatewayHeld.toLocaleString('en-US')} behind a gateway that refused this sender`] : []),\n ]\n return `${total.toLocaleString('en-US')} ${total === 1 ? 'recipient' : 'recipients'} excluded: ${parts.join(' · ')}`\n}\n\n/*==========================================\n * THE SEAM ON `sendEmail`\n *\n * `sendEmail` lives in this library and the store it would need lives in\n * `@aglyn/tenant-data-admin`, which imports this one — so the durable half\n * reaches the send by injection, the way the send-rate governor and the\n * marketing gate do. Nothing installed is nothing checked: a deployment\n * without the server layer sends as it always did.\n *==========================================*/\n\n/** What `sendEmail` asks before a message leaves. */\nexport interface EmailDeliverabilityPreflightRequest {\n /** The recipients, normalized and lowercased. */\n recipients: string[]\n purpose: EmailSendPurpose\n /** The domain the message leaves from, or `null` when it could not be read. */\n sendingDomain: string | null\n /**\n * Whose reputation the sending domain is: a site's own verified domain\n * (`custom`), the pooled tenant identity (`shared`), or Aglyn's own\n * (`platform`). The last two are learned at platform level, where a block\n * hurts every tenant.\n */\n sendingSource: 'custom' | 'shared' | 'platform'\n context?: string\n hostId?: string | null\n}\n\nexport interface EmailDeliverabilityPreflightVerdict {\n /** Recipients whose domain takes no mail. Never sent, whatever the purpose. */\n refused: Array<{ email: string; code: EmailDeliverabilityCode; reason: string }>\n /** Recipients behind a gateway that refused this sender twice. Bulk and cold mail only. */\n held: Array<{ email: string; gateway: MailGateway; reason: string }>\n}\n\nexport type EmailDeliverabilityPreflight = (\n request: EmailDeliverabilityPreflightRequest,\n) => Promise<EmailDeliverabilityPreflightVerdict>\n\nlet installedPreflight: EmailDeliverabilityPreflight | null = null\n\n/** Installs the durable preflight. Called once, from `@aglyn/tenant-data-admin`. */\nexport function setEmailDeliverabilityPreflight(preflight: EmailDeliverabilityPreflight | null): void {\n installedPreflight = preflight\n}\n\n/** The installed preflight, or `null` when nothing has been installed. */\nexport function getEmailDeliverabilityPreflight(): EmailDeliverabilityPreflight | null {\n return installedPreflight\n}\n\n/** Test seam: forget any installed preflight. */\nexport function resetEmailDeliverabilityPreflightForTests(): void {\n installedPreflight = null\n}\n\n/**\n * How long `sendEmail` waits for the preflight before sending anyway. A\n * cold lookup is one DNS round trip and one document read; a preflight\n * slower than this is an outage on the control, and a control outage must\n * not become a mail outage.\n */\nexport const EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS = 2_500\n"],"names":["classifyMailGateway","MAIL_DOMAIN_INTEL_TTL_MS","MAIL_DOMAIN_NEGATIVE_TTL_MS","mailGatewayHolds","mailGatewayHoldSentence","normalizeMailHost","DOMAIN_MAX","LABEL","LOCAL_PART","normalizeDeliverabilityEmail","value","address","String","trim","toLowerCase","at","lastIndexOf","length","local","slice","domain","test","isDeliverabilityDomain","labels","split","every","label","emailAddressDomain","email","ROLE_LOCAL_PARTS","Set","isRoleEmailAddress","has","DISPOSABLE_DOMAINS","isDisposableEmailDomain","TYPO_TARGETS","TYPO_EXEMPT","TLD_SLIPS","co","con","cm","om","comm","cmo","ocm","vom","xom","cpm","coom","ne","nte","ner","nett","withinOneEdit","a","b","Math","abs","diffs","index","push","longer","shorter","suggestEmailDomain","name","includes","dot","tld","target","targetDot","targetLabel","targetTld","MAIL_DOMAIN_STATUSES","mailDomainRefusesMail","status","mailDomainIntelTtlMs","isMailDomainIntelFresh","intel","nowMs","Boolean","resolvedAtMs","NO_RECORD_CODES","isNoRecordDnsError","error","code","toUpperCase","readStoredMailDomainIntel","id","data","mx","Array","isArray","filter","entry","Number","isFinite","gateway","resolveMailDomain","resolver","records","resolveMx","exchanges","record","exchange","sort","priority","map","real","resolveAddress","addressed","SEVERITY_RANK","refuse","hold","warn","assessEmailDeliverability","input","outcome","findings","severity","message","bulk","purpose","publicMailbox","suggestion","holdReleased","blocked30","some","finding","decidingDeliverabilityFinding","verdict","find","deliverabilityExclusionSummary","counts","noMailServer","max","floor","gatewayHeld","total","parts","toLocaleString","join","installedPreflight","setEmailDeliverabilityPreflight","preflight","getEmailDeliverabilityPreflight","resetEmailDeliverabilityPreflightForTests","EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-deliverability.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 * WILL THIS ADDRESS BOUNCE? — ANSWERED BEFORE THE SEND (AGL-3328).\n *\n * The one engine every email path asks: the Resend sender through\n * `sendEmail`'s preflight seam, campaigns before their review count, the\n * CRM at capture time, and Sequences at enrollment and before each send.\n * It answers from three free facts and nothing else:\n *\n * 1. **The address itself** — its syntax, a typo'd provider domain\n * (`gmial.com`, which is SUGGESTED, never corrected), a disposable\n * domain, a role address (`info@`), which warns for bulk and cold mail\n * only.\n * 2. **The domain's MX** — no MX at all, RFC 7505's null MX (`.`), or no MX\n * but an address record, which RFC 5321 §5.1 still delivers to and so\n * only warns.\n * 3. **The gateway's history with the sending domain** — see\n * `mail-gateway.ts`: a gateway that refused this sender twice lately\n * holds the next BULK send into it.\n *\n * Never an SMTP `RCPT TO` probe. A probe is a connection to the recipient's\n * server that sends nothing, which is exactly what a directory-harvest\n * attack looks like, and gateways list the probing IP for it. Nor a paid\n * verification API: the free checks are the whole of this module.\n *\n * ## The two severities that stop mail, and who they may stop\n *\n * - **refuse** — the domain takes no mail. Every purpose, transactional\n * included: a password reset to a domain with no MX is a hard bounce\n * charged to the sending domain, and nobody receives it either way.\n * - **hold** — the gateway refused this sender twice. BULK and COLD mail\n * only. Mail the recipient asked for (a password reset, a receipt, a\n * booking confirmation) is never held for a gateway: the person is\n * waiting for it, and one more refusal costs less than their not getting\n * it.\n *\n * A public mailbox provider is never refused for its MX. `gmail.com` has MX\n * records; a resolver that says otherwise is having a bad minute, and a\n * refusal there would suppress real people.\n *\n * ## Pure\n *\n * DNS is handed in as a {@link MailDnsResolver}. This library's barrel\n * reaches the browser, so `node:dns` belongs to the server layer that\n * installs the preflight — `@aglyn/tenant-data-admin`.\n *==========================================*/\n\nimport {\n classifyMailGateway,\n MAIL_DOMAIN_INTEL_TTL_MS,\n MAIL_DOMAIN_NEGATIVE_TTL_MS,\n type MailGateway,\n type MailGatewayStanding,\n mailGatewayHolds,\n mailGatewayHoldSentence,\n normalizeMailHost,\n} from './mail-gateway'\n\n/*==========================================\n * THE ADDRESS\n *==========================================*/\n\n/** RFC 1035's ceiling on a name, which no real domain reaches. */\nconst DOMAIN_MAX = 253\n\n/** A label: letters, digits and hyphens, not starting or ending on a hyphen. */\nconst LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/\n\n/** The local part a mailbox provider accepts in practice: RFC 5322's dot-atom. */\nconst LOCAL_PART = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*$/\n\n/** An address as the checks read it: trimmed and lowercased, or `null` when it is not one. */\nexport function normalizeDeliverabilityEmail(value: unknown): string | null {\n const address = String(value ?? '')\n .trim()\n .toLowerCase()\n const at = address.lastIndexOf('@')\n if (at < 1 || at === address.length - 1 || address.length > 254) return null\n const local = address.slice(0, at)\n const domain = address.slice(at + 1)\n if (local.length > 64 || !LOCAL_PART.test(local)) return null\n if (!isDeliverabilityDomain(domain)) return null\n return address\n}\n\n/** Whether a value is a domain with at least two labels and a non-numeric top level. */\nexport function isDeliverabilityDomain(value: unknown): boolean {\n const domain = String(value ?? '')\n if (!domain || domain.length > DOMAIN_MAX) return false\n const labels = domain.split('.')\n if (labels.length < 2 || !labels.every((label) => LABEL.test(label))) return false\n return !/^\\d+$/.test(labels[labels.length - 1])\n}\n\n/** The domain of an address, lowercased, or `null` for a value that is not one. */\nexport function emailAddressDomain(email: unknown): string | null {\n const address = normalizeDeliverabilityEmail(email)\n return address ? address.slice(address.lastIndexOf('@') + 1) : null\n}\n\n/**\n * The mailboxes a company's shared inbox answers as rather than a person.\n * Legal to mail and often read, but a cold email or a campaign sent there\n * reaches nobody who asked for it, and a spam report from a shared inbox is\n * a report from the whole company.\n */\nconst ROLE_LOCAL_PARTS: ReadonlySet<string> = new Set(\n // A word list rather than quoted strings: `marketing` is also a plugin's\n // id, and `check:plugin-domain-in-core` reads a quoted one as a plugin's\n // domain leaking into shared code — which a mailbox name is not.\n (\n 'abuse accounts admin administrator billing careers contact enquiries ' +\n 'help hello hr info inquiries jobs mail marketing media news no-reply ' +\n 'noreply office postmaster press privacy sales security service support ' +\n 'team webmaster'\n ).split(' '),\n)\n\n/** Whether an address is a role mailbox (`info@`, `sales@`), read before any `+tag`. */\nexport function isRoleEmailAddress(email: unknown): boolean {\n const address = normalizeDeliverabilityEmail(email)\n if (!address) return false\n const local = address.slice(0, address.lastIndexOf('@')).split('+')[0]\n return ROLE_LOCAL_PARTS.has(local)\n}\n\n/**\n * Domains that hand out a mailbox that expires. Not a complete list — no\n * list is — but the ones a signup form actually meets.\n */\nconst DISPOSABLE_DOMAINS: ReadonlySet<string> = new Set([\n '10minutemail.com',\n '20minutemail.com',\n 'burnermail.io',\n 'discard.email',\n 'dispostable.com',\n 'emailondeck.com',\n 'fakeinbox.com',\n 'getairmail.com',\n 'getnada.com',\n 'guerrillamail.com',\n 'guerrillamail.net',\n 'guerrillamail.org',\n 'guerrillamailblock.com',\n 'maildrop.cc',\n 'mailinator.com',\n 'mailinator.net',\n 'mailnesia.com',\n 'mintemail.com',\n 'mohmal.com',\n 'mytemp.email',\n 'sharklasers.com',\n 'spamgourmet.com',\n 'temp-mail.org',\n 'tempail.com',\n 'tempmail.com',\n 'tempmailo.com',\n 'tempr.email',\n 'throwawaymail.com',\n 'trashmail.com',\n 'trashmail.de',\n 'yopmail.com',\n 'yopmail.net',\n])\n\n/** Whether a domain is a disposable-mailbox provider. */\nexport function isDisposableEmailDomain(domain: unknown): boolean {\n return DISPOSABLE_DOMAINS.has(normalizeMailHost(domain))\n}\n\n/**\n * The providers a mistyped domain is compared against. Long labels only: a\n * three-letter label (`aol`, `msn`) is one keystroke from dozens of real\n * domains, and a suggestion there would be noise.\n */\nconst TYPO_TARGETS: readonly string[] = [\n 'gmail.com',\n 'googlemail.com',\n 'yahoo.com',\n 'hotmail.com',\n 'outlook.com',\n 'icloud.com',\n 'comcast.net',\n 'protonmail.com',\n 'verizon.net',\n 'sbcglobal.net',\n 'bellsouth.net',\n]\n\n/** Real providers one keystroke from a target, which must never be \"corrected\". */\nconst TYPO_EXEMPT: ReadonlySet<string> = new Set([\n 'mail.com',\n 'gmx.com',\n 'ymail.com',\n 'email.com',\n 'hotmail.co.uk',\n 'yahoo.co.uk',\n 'outlook.fr',\n])\n\n/** Top-level domains that are a slip of `com` or `net`. */\nconst TLD_SLIPS: Readonly<Record<string, string>> = {\n co: 'com',\n con: 'com',\n cm: 'com',\n om: 'com',\n comm: 'com',\n cmo: 'com',\n ocm: 'com',\n vom: 'com',\n xom: 'com',\n cpm: 'com',\n coom: 'com',\n ne: 'net',\n nte: 'net',\n ner: 'net',\n nett: 'net',\n}\n\n/** Damerau–Levenshtein distance, stopping early above 1: only \"one slip\" matters here. */\nfunction withinOneEdit(a: string, b: string): boolean {\n if (a === b) return true\n if (Math.abs(a.length - b.length) > 1) return false\n if (a.length === b.length) {\n const diffs: number[] = []\n for (let index = 0; index < a.length; index += 1) {\n if (a[index] !== b[index]) diffs.push(index)\n if (diffs.length > 2) return false\n }\n if (diffs.length === 1) return true\n // One transposition of neighbors: `gmial` for `gmail`.\n return (\n diffs.length === 2 &&\n diffs[1] === diffs[0] + 1 &&\n a[diffs[0]] === b[diffs[1]] &&\n a[diffs[1]] === b[diffs[0]]\n )\n }\n const [longer, shorter] = a.length > b.length ? [a, b] : [b, a]\n for (let index = 0; index < longer.length; index += 1) {\n if (longer.slice(0, index) + longer.slice(index + 1) === shorter) return true\n }\n return false\n}\n\n/**\n * The provider a domain most likely meant, or `null` — a SUGGESTION a\n * surface may offer, never a correction applied: `gmial.com` may be a real\n * domain somebody owns, and changing an address a person typed is sending\n * their mail to someone else.\n */\nexport function suggestEmailDomain(domain: unknown): string | null {\n const name = normalizeMailHost(domain)\n if (!name || TYPO_EXEMPT.has(name) || TYPO_TARGETS.includes(name)) return null\n const dot = name.lastIndexOf('.')\n if (dot < 1) return null\n const label = name.slice(0, dot)\n const tld = name.slice(dot + 1)\n for (const target of TYPO_TARGETS) {\n const targetDot = target.lastIndexOf('.')\n const targetLabel = target.slice(0, targetDot)\n const targetTld = target.slice(targetDot + 1)\n if (label === targetLabel && TLD_SLIPS[tld] === targetTld) return target\n if (tld === targetTld && label.length >= 4 && withinOneEdit(label, targetLabel)) return target\n }\n return null\n}\n\n/*==========================================\n * THE DOMAIN'S MX\n *==========================================*/\n\n/** One MX record as a resolver answers it. */\nexport interface MailMxRecord {\n exchange: string\n priority: number\n}\n\n/**\n * The DNS the checks read, handed in by the server layer.\n *\n * `resolveMx` resolves the domain's MX records. A domain that publishes\n * none resolves `[]` or rejects with a `code` of `ENOTFOUND`, `ENODATA` or\n * `NXDOMAIN` — Node's own convention — and any other rejection is a\n * resolver that could not answer, which the checks read as \"unknown\" and\n * never as \"no MX\".\n *\n * `resolveAddress`, when given, answers whether the domain has an address\n * record (A or AAAA): the implicit MX a domain with no MX is still\n * delivered to. Omitted, a domain with no MX reads as `no_mx`.\n */\nexport interface MailDnsResolver {\n resolveMx(domain: string): Promise<ReadonlyArray<MailMxRecord>>\n resolveAddress?(domain: string): Promise<boolean>\n}\n\n/**\n * What a domain's DNS says about receiving mail.\n *\n * - `mx` — it publishes MX records.\n * - `implicit_mx` — no MX, but an address record, which RFC 5321 §5.1 still\n * delivers to. Deliverable; warned, because it is usually a domain whose\n * mail was never set up.\n * - `null_mx` — RFC 7505's `MX 0 .`: the domain says outright it takes no mail.\n * - `no_mx` — no MX and no address record: nothing to deliver to.\n */\nexport type MailDomainStatus = 'mx' | 'implicit_mx' | 'null_mx' | 'no_mx'\n\n/** One domain's answer, as the platform cache stores it. */\nexport interface MailDomainIntel {\n domain: string\n status: MailDomainStatus\n /** The MX exchanges, lowest preference first; empty for every status but `mx`. */\n mx: string[]\n gateway: MailGateway\n /** When the domain was looked up; trusted for {@link mailDomainIntelTtlMs}. */\n resolvedAtMs: number\n}\n\nexport const MAIL_DOMAIN_STATUSES: readonly MailDomainStatus[] = ['mx', 'implicit_mx', 'null_mx', 'no_mx']\n\n/** Whether a status means the domain takes no mail. */\nexport function mailDomainRefusesMail(status: MailDomainStatus | null | undefined): boolean {\n return status === 'no_mx' || status === 'null_mx'\n}\n\n/** How long an answer is trusted: a week for a domain that takes mail, a day for one that does not. */\nexport function mailDomainIntelTtlMs(status: MailDomainStatus): number {\n return mailDomainRefusesMail(status) ? MAIL_DOMAIN_NEGATIVE_TTL_MS : MAIL_DOMAIN_INTEL_TTL_MS\n}\n\n/** Whether a cached answer is still trusted at `nowMs`. */\nexport function isMailDomainIntelFresh(intel: MailDomainIntel | null | undefined, nowMs: number): boolean {\n return Boolean(intel) && intel.resolvedAtMs + mailDomainIntelTtlMs(intel.status) > nowMs\n}\n\n/** The resolver's codes that mean \"this domain has no such record\", as opposed to \"I could not ask\". */\nconst NO_RECORD_CODES = new Set(['ENOTFOUND', 'ENODATA', 'NXDOMAIN', 'NOTFOUND'])\n\n/** Whether a resolver rejection is an answer (\"no such record\") rather than a failure to get one. */\nexport function isNoRecordDnsError(error: unknown): boolean {\n return NO_RECORD_CODES.has(String((error as { code?: unknown })?.code ?? '').toUpperCase())\n}\n\n/** A stored answer in its model shape, or `null` for no document or an unreadable one. */\nexport function readStoredMailDomainIntel(\n id: string,\n data: Record<string, unknown> | null | undefined,\n): MailDomainIntel | null {\n if (!data) return null\n const status = (MAIL_DOMAIN_STATUSES as readonly unknown[]).includes(data['status'])\n ? (data['status'] as MailDomainStatus)\n : null\n if (!status) return null\n const mx = Array.isArray(data['mx']) ? data['mx'].filter((entry): entry is string => typeof entry === 'string') : []\n const resolvedAtMs = typeof data['resolvedAtMs'] === 'number' && Number.isFinite(data['resolvedAtMs']) ? data['resolvedAtMs'] : 0\n return {\n domain: typeof data['domain'] === 'string' && data['domain'] ? data['domain'] : id,\n status,\n mx,\n gateway: status === 'mx' ? classifyMailGateway(mx) : status === 'implicit_mx' ? 'other' : 'none',\n resolvedAtMs,\n }\n}\n\n/**\n * A domain's mail setup, looked up — see {@link MailDomainStatus}. Rejects\n * when the resolver could not answer, and the caller decides what a missing\n * answer means; every path here reads it as \"unknown\" and sends.\n */\nexport async function resolveMailDomain(\n resolver: MailDnsResolver,\n domain: string,\n nowMs: number,\n): Promise<MailDomainIntel> {\n let records: ReadonlyArray<MailMxRecord>\n try {\n records = await resolver.resolveMx(domain)\n } catch (error) {\n if (!isNoRecordDnsError(error)) throw error\n records = []\n }\n const exchanges = [...(records ?? [])]\n .filter((record) => record && typeof record.exchange === 'string')\n .sort((a, b) => (Number(a.priority) || 0) - (Number(b.priority) || 0))\n .map((record) => normalizeMailHost(record.exchange))\n const real = exchanges.filter((exchange) => exchange && exchange !== '.')\n if (real.length) {\n return { domain, status: 'mx', mx: real, gateway: classifyMailGateway(real), resolvedAtMs: nowMs }\n }\n // Records that are all \".\" (or empty, as Node reports a null MX) are RFC\n // 7505's null MX: the domain says it takes no mail, whatever its A says.\n if (exchanges.length) return { domain, status: 'null_mx', mx: [], gateway: 'none', resolvedAtMs: nowMs }\n if (resolver.resolveAddress) {\n let addressed: boolean\n try {\n addressed = await resolver.resolveAddress(domain)\n } catch (error) {\n if (!isNoRecordDnsError(error)) throw error\n addressed = false\n }\n if (addressed) return { domain, status: 'implicit_mx', mx: [], gateway: 'other', resolvedAtMs: nowMs }\n }\n return { domain, status: 'no_mx', mx: [], gateway: 'none', resolvedAtMs: nowMs }\n}\n\n/*==========================================\n * THE VERDICT\n *==========================================*/\n\n/**\n * Who a message is for, which decides what may stop it — see the module\n * note. `transactional` is mail the recipient asked for; `bulk` is a\n * campaign, a workflow's marketing step, a sweep; `cold` is a sequence to\n * somebody who has not written to the sender.\n */\nexport type EmailSendPurpose = 'transactional' | 'bulk' | 'cold'\n\nexport type EmailDeliverabilityCode =\n | 'invalid_syntax'\n | 'no_mx'\n | 'null_mx'\n | 'implicit_mx'\n | 'typo_domain'\n | 'disposable_domain'\n | 'role_address'\n | 'gateway_held'\n /** The account holding the address is banned (AGL-3420). */\n | 'account_banned'\n\nexport type EmailDeliverabilitySeverity = 'refuse' | 'hold' | 'warn'\n\nexport interface EmailDeliverabilityFinding {\n code: EmailDeliverabilityCode\n severity: EmailDeliverabilitySeverity\n /** A sentence a surface shows as is. */\n message: string\n /** `typo_domain` only: the address with the suggested domain. Never applied for the person. */\n suggestion?: string\n}\n\n/**\n * What the checks conclude, worst first: `refused` (never sent), `held`\n * (bulk and cold mail wait for a person), `deliverable` (nothing stops it,\n * warnings or not), `unchecked` (the MX could not be read, so nothing is\n * known — and nothing is stopped).\n */\nexport type EmailDeliverabilityOutcome = 'refused' | 'held' | 'deliverable' | 'unchecked'\n\nexport interface EmailDeliverabilityVerdict {\n /** The address, normalized, or `null` when it is not one. */\n email: string | null\n domain: string | null\n outcome: EmailDeliverabilityOutcome\n findings: EmailDeliverabilityFinding[]\n intel: MailDomainIntel | null\n gateway: MailGatewayStanding | null\n}\n\nexport interface EmailDeliverabilityInput {\n email: unknown\n purpose: EmailSendPurpose\n /** The domain's MX answer; `null` or absent when it could not be read. */\n intel?: MailDomainIntel | null\n /** The gateway's standing with the sending domain, when the caller read the ledger. */\n gateway?: MailGatewayStanding | null\n /**\n * The domain is a public mailbox provider (`gmail.com`), which is never\n * refused for its MX. The list lives with the CRM's, so the caller asks.\n */\n publicMailbox?: boolean\n /** A person already released the gateway hold for this address. */\n holdReleased?: boolean\n}\n\nconst SEVERITY_RANK: Record<EmailDeliverabilitySeverity, number> = { refuse: 0, hold: 1, warn: 2 }\n\n/** The whole verdict for one address — see the module note. */\nexport function assessEmailDeliverability(input: EmailDeliverabilityInput): EmailDeliverabilityVerdict {\n const email = normalizeDeliverabilityEmail(input.email)\n if (!email) {\n return {\n email: null,\n domain: null,\n outcome: 'refused',\n findings: [\n {\n code: 'invalid_syntax',\n severity: 'refuse',\n message: `${String(input.email ?? '').trim() || 'This'} is not an email address.`,\n },\n ],\n intel: null,\n gateway: null,\n }\n }\n const domain = email.slice(email.lastIndexOf('@') + 1)\n const findings: EmailDeliverabilityFinding[] = []\n const bulk = input.purpose !== 'transactional'\n const intel = input.intel ?? null\n\n if (intel && !input.publicMailbox) {\n if (intel.status === 'no_mx') {\n findings.push({\n code: 'no_mx',\n severity: 'refuse',\n message: `${domain} has no mail server, so ${email} would bounce.`,\n })\n } else if (intel.status === 'null_mx') {\n findings.push({\n code: 'null_mx',\n severity: 'refuse',\n message: `${domain} publishes a null MX record: it says it accepts no email, so ${email} would bounce.`,\n })\n } else if (intel.status === 'implicit_mx') {\n findings.push({\n code: 'implicit_mx',\n severity: 'warn',\n message: `${domain} has no MX record. Mail may still reach its web server's address, but most domains like this never set up email.`,\n })\n }\n }\n\n const suggestion = suggestEmailDomain(domain)\n if (suggestion) {\n const local = email.slice(0, email.lastIndexOf('@'))\n findings.push({\n code: 'typo_domain',\n severity: 'warn',\n message: `Did you mean ${local}@${suggestion}? ${domain} looks like a typo.`,\n suggestion: `${local}@${suggestion}`,\n })\n }\n if (isDisposableEmailDomain(domain)) {\n findings.push({\n code: 'disposable_domain',\n severity: 'warn',\n message: `${domain} hands out mailboxes that expire, so this address may stop working soon.`,\n })\n }\n if (bulk && isRoleEmailAddress(email)) {\n findings.push({\n code: 'role_address',\n severity: 'warn',\n message: `${email} is a shared role inbox rather than a person, which marketing and cold mail should avoid.`,\n })\n }\n\n const gateway = input.gateway ?? null\n if (bulk && gateway && input.holdReleased !== true && mailGatewayHolds(gateway)) {\n findings.push({\n code: 'gateway_held',\n severity: 'hold',\n message: `${mailGatewayHoldSentence(gateway.gateway, gateway.blocked30)}, so bulk mail to ${domain} is held.`,\n })\n }\n\n findings.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])\n const outcome: EmailDeliverabilityOutcome = findings.some((finding) => finding.severity === 'refuse')\n ? 'refused'\n : findings.some((finding) => finding.severity === 'hold')\n ? 'held'\n : intel\n ? 'deliverable'\n : 'unchecked'\n return { email, domain, outcome, findings, intel, gateway }\n}\n\n/** The finding that decided a refusal or a hold, or `null` when nothing stops the address. */\nexport function decidingDeliverabilityFinding(verdict: EmailDeliverabilityVerdict): EmailDeliverabilityFinding | null {\n return verdict.findings.find((finding) => finding.severity !== 'warn') ?? null\n}\n\n/**\n * What a bulk send's review says about the addresses the checks took out,\n * or `null` when they took out nobody: \"3 recipients excluded: no mail\n * server · 2 behind a gateway that refused this sender\".\n */\nexport function deliverabilityExclusionSummary(counts: {\n noMailServer?: number | null\n gatewayHeld?: number | null\n}): string | null {\n const noMailServer = Math.max(0, Math.floor(Number(counts?.noMailServer) || 0))\n const gatewayHeld = Math.max(0, Math.floor(Number(counts?.gatewayHeld) || 0))\n const total = noMailServer + gatewayHeld\n if (!total) return null\n const parts = [\n ...(noMailServer ? [`${noMailServer.toLocaleString('en-US')} no mail server`] : []),\n ...(gatewayHeld ? [`${gatewayHeld.toLocaleString('en-US')} behind a gateway that refused this sender`] : []),\n ]\n return `${total.toLocaleString('en-US')} ${total === 1 ? 'recipient' : 'recipients'} excluded: ${parts.join(' · ')}`\n}\n\n/*==========================================\n * THE SEAM ON `sendEmail`\n *\n * `sendEmail` lives in this library and the store it would need lives in\n * `@aglyn/tenant-data-admin`, which imports this one — so the durable half\n * reaches the send by injection, the way the send-rate governor and the\n * marketing gate do. Nothing installed is nothing checked: a deployment\n * without the server layer sends as it always did.\n *==========================================*/\n\n/** What `sendEmail` asks before a message leaves. */\nexport interface EmailDeliverabilityPreflightRequest {\n /** The recipients, normalized and lowercased. */\n recipients: string[]\n purpose: EmailSendPurpose\n /** The domain the message leaves from, or `null` when it could not be read. */\n sendingDomain: string | null\n /**\n * Whose reputation the sending domain is: a site's own verified domain\n * (`custom`), the pooled tenant identity (`shared`), or Aglyn's own\n * (`platform`). The last two are learned at platform level, where a block\n * hurts every tenant.\n */\n sendingSource: 'custom' | 'shared' | 'platform'\n context?: string\n hostId?: string | null\n /** The send is a lock or lift notice, which reaches a banned account. */\n accountBanExempt?: boolean\n}\n\nexport interface EmailDeliverabilityPreflightVerdict {\n /**\n * Recipients whose domain takes no mail, or whose account is banned.\n * Never sent, whatever the purpose.\n */\n refused: Array<{ email: string; code: EmailDeliverabilityCode; reason: string }>\n /** Recipients behind a gateway that refused this sender twice. Bulk and cold mail only. */\n held: Array<{ email: string; gateway: MailGateway; reason: string }>\n}\n\nexport type EmailDeliverabilityPreflight = (\n request: EmailDeliverabilityPreflightRequest,\n) => Promise<EmailDeliverabilityPreflightVerdict>\n\nlet installedPreflight: EmailDeliverabilityPreflight | null = null\n\n/** Installs the durable preflight. Called once, from `@aglyn/tenant-data-admin`. */\nexport function setEmailDeliverabilityPreflight(preflight: EmailDeliverabilityPreflight | null): void {\n installedPreflight = preflight\n}\n\n/** The installed preflight, or `null` when nothing has been installed. */\nexport function getEmailDeliverabilityPreflight(): EmailDeliverabilityPreflight | null {\n return installedPreflight\n}\n\n/** Test seam: forget any installed preflight. */\nexport function resetEmailDeliverabilityPreflightForTests(): void {\n installedPreflight = null\n}\n\n/**\n * How long `sendEmail` waits for the preflight before sending anyway. A\n * cold lookup is one DNS round trip and one document read; a preflight\n * slower than this is an outage on the control, and a control outage must\n * not become a mail outage.\n */\nexport const EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS = 2_500\n"],"names":["classifyMailGateway","MAIL_DOMAIN_INTEL_TTL_MS","MAIL_DOMAIN_NEGATIVE_TTL_MS","mailGatewayHolds","mailGatewayHoldSentence","normalizeMailHost","DOMAIN_MAX","LABEL","LOCAL_PART","normalizeDeliverabilityEmail","value","address","String","trim","toLowerCase","at","lastIndexOf","length","local","slice","domain","test","isDeliverabilityDomain","labels","split","every","label","emailAddressDomain","email","ROLE_LOCAL_PARTS","Set","isRoleEmailAddress","has","DISPOSABLE_DOMAINS","isDisposableEmailDomain","TYPO_TARGETS","TYPO_EXEMPT","TLD_SLIPS","co","con","cm","om","comm","cmo","ocm","vom","xom","cpm","coom","ne","nte","ner","nett","withinOneEdit","a","b","Math","abs","diffs","index","push","longer","shorter","suggestEmailDomain","name","includes","dot","tld","target","targetDot","targetLabel","targetTld","MAIL_DOMAIN_STATUSES","mailDomainRefusesMail","status","mailDomainIntelTtlMs","isMailDomainIntelFresh","intel","nowMs","Boolean","resolvedAtMs","NO_RECORD_CODES","isNoRecordDnsError","error","code","toUpperCase","readStoredMailDomainIntel","id","data","mx","Array","isArray","filter","entry","Number","isFinite","gateway","resolveMailDomain","resolver","records","resolveMx","exchanges","record","exchange","sort","priority","map","real","resolveAddress","addressed","SEVERITY_RANK","refuse","hold","warn","assessEmailDeliverability","input","outcome","findings","severity","message","bulk","purpose","publicMailbox","suggestion","holdReleased","blocked30","some","finding","decidingDeliverabilityFinding","verdict","find","deliverabilityExclusionSummary","counts","noMailServer","max","floor","gatewayHeld","total","parts","toLocaleString","join","installedPreflight","setEmailDeliverabilityPreflight","preflight","getEmailDeliverabilityPreflight","resetEmailDeliverabilityPreflightForTests","EMAIL_DELIVERABILITY_PREFLIGHT_TIMEOUT_MS"],"mappings":"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"}
|
|
@@ -14,6 +14,42 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
/**
|
|
18
|
+
* WHAT HAPPENED TO A MESSAGE, IN OUR OWN VOCABULARY.
|
|
19
|
+
*
|
|
20
|
+
* ## The seam
|
|
21
|
+
*
|
|
22
|
+
* A staff answer to "did they get the invite, and did they open it?" must not
|
|
23
|
+
* be a question about one sending vendor. Two things follow from that, and
|
|
24
|
+
* this module is both of them:
|
|
25
|
+
*
|
|
26
|
+
* 1. **The stored shape is ours.** Nothing downstream — the delivery log, the
|
|
27
|
+
* staff card, a future export — reads a provider's field names or its event
|
|
28
|
+
* strings. A provider turns its own wire format into these types (its
|
|
29
|
+
* `deliveryEvents` and `reads`, see `mail-provider.ts`), and swapping the
|
|
30
|
+
* sender changes that provider and nothing else in the tree.
|
|
31
|
+
* 2. **The history is ours.** The log is written into our own Firestore and
|
|
32
|
+
* read from there, never from the provider on render. A provider's list
|
|
33
|
+
* endpoint is a different shape per vendor, has its own retention window,
|
|
34
|
+
* and disappears entirely with the account; a record we keep survives the
|
|
35
|
+
* migration that the seam exists to make possible.
|
|
36
|
+
*
|
|
37
|
+
* That is a rule about the READ PATH, not a rule against ever reading the
|
|
38
|
+
* provider. The event feed only knows mail sent after it was connected, so
|
|
39
|
+
* a log fed by events alone is empty for all existing history — which is
|
|
40
|
+
* precisely the mail a support question is about. The second half of this
|
|
41
|
+
* module (see THE READ SIDE OF THE SEAM below) is the vocabulary that
|
|
42
|
+
* history is imported through, once, into the same store.
|
|
43
|
+
*
|
|
44
|
+
* ## Pure on purpose
|
|
45
|
+
*
|
|
46
|
+
* No Firestore and no admin SDK. `system-email-catalog` is imported by console
|
|
47
|
+
* CLIENT components through this library's barrel, so anything reachable from
|
|
48
|
+
* it that touched `firebase-admin` would drag the admin SDK into a browser
|
|
49
|
+
* bundle. Normalisation is a pure function of a payload; the writing lives in
|
|
50
|
+
* `@aglyn/tenant-data-admin/server/email-delivery-log`, and the reading
|
|
51
|
+
* of a provider's payloads in that provider.
|
|
52
|
+
*/
|
|
17
53
|
/**
|
|
18
54
|
* The lifecycle of one message, in the order it normally happens.
|
|
19
55
|
*
|
|
@@ -34,7 +70,7 @@ export interface EmailDeliveryEvent {
|
|
|
34
70
|
type: EmailDeliveryEventType;
|
|
35
71
|
/** When the PROVIDER says it happened, falling back to receipt time. */
|
|
36
72
|
at: number;
|
|
37
|
-
/** Slug of the sending provider
|
|
73
|
+
/** Slug of the sending provider — the `id` of the provider that carried it. */
|
|
38
74
|
provider: string;
|
|
39
75
|
/** The provider's id for the message. Our per-message document key. */
|
|
40
76
|
providerMessageId: string;
|
|
@@ -80,23 +116,6 @@ export declare function bareSenderAddress(value: unknown): string | null;
|
|
|
80
116
|
export declare function worstDeliveryStatus(current: EmailDeliveryEventType | null | undefined, next: EmailDeliveryEventType): EmailDeliveryEventType;
|
|
81
117
|
/** Tags arrive as an array of `{name, value}` or a plain map — accept both. */
|
|
82
118
|
export declare function normalizeEventTags(raw: unknown): Record<string, string>;
|
|
83
|
-
/**
|
|
84
|
-
* Turns one Resend webhook payload into zero or more of our events — **the
|
|
85
|
-
* only function in the tree that knows Resend's wire format.**
|
|
86
|
-
*
|
|
87
|
-
* One event per recipient, not per message: a send addressed to three people
|
|
88
|
-
* produces one webhook, and a staff view keyed on a person has to be able to
|
|
89
|
-
* find it under each of them.
|
|
90
|
-
*
|
|
91
|
-
* Returns an empty array for anything unrecognised — a contact or domain
|
|
92
|
-
* event, an inbound `email.received`, a type added after this was written.
|
|
93
|
-
* Silence rather than a throw, because a webhook handler that 500s on an
|
|
94
|
-
* unfamiliar event teaches the provider to retry it forever.
|
|
95
|
-
*
|
|
96
|
-
* @param payload The parsed webhook body.
|
|
97
|
-
* @param receivedAtMs Fallback timestamp for a payload that carries none.
|
|
98
|
-
*/
|
|
99
|
-
export declare function normalizeResendDeliveryEvents(payload: unknown, receivedAtMs: number): EmailDeliveryEvent[];
|
|
100
119
|
/** One message as the provider currently reports it, in our vocabulary. */
|
|
101
120
|
export interface EmailDeliverySnapshot {
|
|
102
121
|
provider: string;
|
|
@@ -108,18 +127,6 @@ export interface EmailDeliverySnapshot {
|
|
|
108
127
|
/** Furthest state the provider reports. Never richer than the event feed. */
|
|
109
128
|
status: EmailDeliveryEventType;
|
|
110
129
|
}
|
|
111
|
-
/**
|
|
112
|
-
* Turns one entry from Resend's `GET /emails` list into zero or more
|
|
113
|
-
* snapshots — one per recipient, for the same reason the event adapter fans
|
|
114
|
-
* out: the staff view is keyed on a person.
|
|
115
|
-
*
|
|
116
|
-
* An unrecognised `last_event` falls back to `sent` rather than being
|
|
117
|
-
* dropped. The message demonstrably exists and was addressed to somebody, and
|
|
118
|
-
* "we sent this and cannot characterise what happened next" is a far more
|
|
119
|
-
* useful row than no row — which is the state that sent a staffer to the
|
|
120
|
-
* vendor dashboard in the first place.
|
|
121
|
-
*/
|
|
122
|
-
export declare function normalizeResendSentEmails(raw: unknown): EmailDeliverySnapshot[];
|
|
123
130
|
/** One page of provider history, in our vocabulary. */
|
|
124
131
|
export interface EmailDeliveryHistoryPage {
|
|
125
132
|
snapshots: EmailDeliverySnapshot[];
|
|
@@ -131,25 +138,15 @@ export interface EmailDeliveryHistoryPage {
|
|
|
131
138
|
*
|
|
132
139
|
* The shape a second provider would implement. Cursor-based rather than
|
|
133
140
|
* offset- or date-based because that is the lowest common denominator, and
|
|
134
|
-
* NOT filtered by recipient:
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
141
|
+
* NOT filtered by recipient: a provider's list endpoint need not take one,
|
|
142
|
+
* so filtering is the caller's job and the import is a sweep rather than a
|
|
143
|
+
* per-person lookup. That is the right shape regardless — a staff page must
|
|
144
|
+
* not fan out to a third party on render.
|
|
138
145
|
*/
|
|
139
146
|
export type EmailDeliveryHistorySource = (options: {
|
|
140
147
|
cursor?: string | null;
|
|
141
148
|
limit?: number;
|
|
142
149
|
}) => Promise<EmailDeliveryHistoryPage>;
|
|
143
|
-
/** Resend's list endpoint. Paginates with `after=<id>`; caps at 100. */
|
|
144
|
-
export declare const RESEND_EMAILS_ENDPOINT = "https://api.resend.com/emails";
|
|
145
|
-
/**
|
|
146
|
-
* {@link EmailDeliveryHistorySource} for Resend.
|
|
147
|
-
*
|
|
148
|
-
* Needs a FULL-ACCESS key: a sending-scoped key answers every read on this
|
|
149
|
-
* endpoint with `401 restricted_api_key`, which is the correct posture for
|
|
150
|
-
* the key that sends mail and the reason this takes its own.
|
|
151
|
-
*/
|
|
152
|
-
export declare function resendDeliveryHistorySource(apiKey: string): EmailDeliveryHistorySource;
|
|
153
150
|
/** One message's content and envelope, in our vocabulary. */
|
|
154
151
|
export interface EmailDeliveryMessage {
|
|
155
152
|
provider: string;
|
|
@@ -169,7 +166,3 @@ export interface EmailDeliveryMessage {
|
|
|
169
166
|
}
|
|
170
167
|
/** A single message by id. The shape a second provider would implement. */
|
|
171
168
|
export type EmailDeliveryMessageSource = (providerMessageId: string) => Promise<EmailDeliveryMessage | null>;
|
|
172
|
-
/** Resend's `GET /emails/:id` payload, in our vocabulary. */
|
|
173
|
-
export declare function normalizeResendMessage(raw: unknown): EmailDeliveryMessage | null;
|
|
174
|
-
/** Resend's single-message endpoint. Needs the same full-access key. */
|
|
175
|
-
export declare function resendDeliveryMessageSource(apiKey: string): EmailDeliveryMessageSource;
|