mailifier 0.1.0
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/LICENSE +21 -0
- package/README.md +105 -0
- package/dist/address.d.ts +17 -0
- package/dist/address.js +144 -0
- package/dist/address.js.map +1 -0
- package/dist/client.d.ts +19 -0
- package/dist/client.js +53 -0
- package/dist/client.js.map +1 -0
- package/dist/dns.d.ts +23 -0
- package/dist/dns.js +52 -0
- package/dist/dns.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/local.d.ts +29 -0
- package/dist/local.js +117 -0
- package/dist/local.js.map +1 -0
- package/dist/mailifier.mjs +384 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +55 -0
- package/dist/main.js.map +1 -0
- package/dist/server.d.ts +22 -0
- package/dist/server.js +89 -0
- package/dist/server.js.map +1 -0
- package/dist/smtp.d.ts +68 -0
- package/dist/smtp.js +251 -0
- package/dist/smtp.js.map +1 -0
- package/dist/verdict.d.ts +29 -0
- package/dist/verdict.js +14 -0
- package/dist/verdict.js.map +1 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wren Automation
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# mailifier
|
|
2
|
+
|
|
3
|
+
Ask a mail server whether an address exists. No mail is ever sent.
|
|
4
|
+
|
|
5
|
+
This is the handshake the paid verification services run, from a host of yours:
|
|
6
|
+
MX lookup → connect on port 25 → `EHLO` → `MAIL FROM` → `RCPT TO <the address>` →
|
|
7
|
+
`QUIT`. The server's answer to `RCPT` is the verdict. There is no `DATA`, so nothing
|
|
8
|
+
is ever delivered.
|
|
9
|
+
|
|
10
|
+
Zero runtime dependencies. Node 22+.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
pnpm add mailifier
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Use it
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { LocalChecker, SmtpProbe } from "mailifier";
|
|
22
|
+
|
|
23
|
+
// Stage 1: free, no sockets. Syntax, typosquats, disposable providers, MX routing.
|
|
24
|
+
const local = await new LocalChecker().check("jane@acme.com");
|
|
25
|
+
if (!local.passed) return local.failure; // "no_mx", "syntax: …", "disposable_domain"
|
|
26
|
+
|
|
27
|
+
// Stage 2: ask the mail server.
|
|
28
|
+
const probe = new SmtpProbe({ helo: "probe.example.com" });
|
|
29
|
+
const verdict = await probe.verify("jane@acme.com");
|
|
30
|
+
// { result: "valid", raw: { reason: "accepted", mx: "…", code: 250, transcript: [ … ] } }
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## The four answers
|
|
34
|
+
|
|
35
|
+
| result | means |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `valid` | the server accepted the address |
|
|
38
|
+
| `invalid` | the server refused it by name, or the domain takes no mail at all |
|
|
39
|
+
| `catch_all` | the domain accepts everything, so acceptance proves nothing |
|
|
40
|
+
| `risky` | nobody would tell us: greylisting, a refused connection, a policy block |
|
|
41
|
+
|
|
42
|
+
Only `valid` and `invalid` are facts. `catch_all` is common on Microsoft 365 tenants.
|
|
43
|
+
`risky` is about the conversation, never about the address — retry it later.
|
|
44
|
+
|
|
45
|
+
## Running it somewhere that can
|
|
46
|
+
|
|
47
|
+
Most clouds block outbound port 25 until you ask them to stop. So the usual shape is
|
|
48
|
+
one small host that can talk SMTP, and everything else asking it over HTTP.
|
|
49
|
+
|
|
50
|
+
On that host:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
PROBE_TOKEN=… PROBE_HELO=probe.example.com npx mailifier
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Everywhere else:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { RemoteProbe } from "mailifier";
|
|
60
|
+
const probe = new RemoteProbe(process.env.PROBE_URL, process.env.PROBE_TOKEN);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Both satisfy `MailboxProbe`, so the calling code never knows which one it has.
|
|
64
|
+
|
|
65
|
+
The server is bearer-authenticated, caps calls in flight, and runs a canary before
|
|
66
|
+
every verdict: if port 25 is not open from this host it answers **503**, never a
|
|
67
|
+
`risky` verdict. A closed port is the host's problem, not the mailbox's — and a
|
|
68
|
+
verifier that quietly reports `risky` for a working mailbox is worse than one that
|
|
69
|
+
stops.
|
|
70
|
+
|
|
71
|
+
`GET /healthz` → `{ ok, in_flight, port_25 }`.
|
|
72
|
+
|
|
73
|
+
The published package also carries `dist/mailifier.mjs`: the whole server in one file,
|
|
74
|
+
to drop on a box that has node and no npm. `pnpm bundle` rebuilds it.
|
|
75
|
+
|
|
76
|
+
## Being a good guest
|
|
77
|
+
|
|
78
|
+
- One conversation per MX at a time, with a gap between them (`perHostGapMs`, 1.5s).
|
|
79
|
+
- `helo` should be a name whose forward and reverse DNS agree, and `MAIL FROM` is
|
|
80
|
+
`postmaster@` that name, so a curious postmaster can look you up. Use a name you
|
|
81
|
+
do not send real mail from.
|
|
82
|
+
- At most three MX hosts tried per address.
|
|
83
|
+
- DNS goes over HTTPS (Cloudflare), so it works in a container with no resolver.
|
|
84
|
+
|
|
85
|
+
## Interfaces
|
|
86
|
+
|
|
87
|
+
`MailboxProbe` is the only thing worth depending on:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
interface MailboxProbe {
|
|
91
|
+
readonly name: string;
|
|
92
|
+
verify(email: string): Promise<Verdict>;
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Deliberately not on it: whether a verdict may be trusted, and whether it costs money.
|
|
97
|
+
That is your policy about a probe, not something a probe knows about itself.
|
|
98
|
+
|
|
99
|
+
Every network edge is injectable — `dial`, `resolver`, `fetch`, `sleep` — so the tests
|
|
100
|
+
run with no sockets and no DNS.
|
|
101
|
+
|
|
102
|
+
## Testing against it
|
|
103
|
+
|
|
104
|
+
`src/smtp.test.ts` shows the pattern: hand `probeMailbox` a scripted `Conversation`
|
|
105
|
+
and assert the verdict it reads out of the replies.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an address is, before anyone dials a mail server: normalization, pragmatic
|
|
3
|
+
* syntax rules, the freemail and role-account lists. Rules are pragmatic, not full
|
|
4
|
+
* RFC 5321 — addresses that need quoting do not survive real business mail anyway.
|
|
5
|
+
*/
|
|
6
|
+
/** Providers where the domain identifies a person, not a business. Advisory, never a failure. */
|
|
7
|
+
export declare const FREEMAIL_DOMAINS: ReadonlySet<string>;
|
|
8
|
+
/** Functional mailboxes (info@, hello@): a role, not a person. Advisory, never a failure. */
|
|
9
|
+
export declare const ROLE_LOCALPARTS: ReadonlySet<string>;
|
|
10
|
+
export declare function normalizeEmail(raw: string): string;
|
|
11
|
+
/** Reason the (already normalized) address is undeliverable, or null. */
|
|
12
|
+
export declare function emailSyntaxError(email: string): string | null;
|
|
13
|
+
export declare function validDomain(domain: string): boolean;
|
|
14
|
+
export declare function emailDomain(email: string): string;
|
|
15
|
+
export declare function isFreemail(domain: string): boolean;
|
|
16
|
+
/** True for a functional mailbox by its local part alone; plus-tags stripped. A bare word is never an address. */
|
|
17
|
+
export declare function isRoleLocalpart(email: string): boolean;
|
package/dist/address.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an address is, before anyone dials a mail server: normalization, pragmatic
|
|
3
|
+
* syntax rules, the freemail and role-account lists. Rules are pragmatic, not full
|
|
4
|
+
* RFC 5321 — addresses that need quoting do not survive real business mail anyway.
|
|
5
|
+
*/
|
|
6
|
+
/** Providers where the domain identifies a person, not a business. Advisory, never a failure. */
|
|
7
|
+
export const FREEMAIL_DOMAINS = new Set([
|
|
8
|
+
"gmail.com",
|
|
9
|
+
"googlemail.com",
|
|
10
|
+
"yahoo.com",
|
|
11
|
+
"yahoo.co.uk",
|
|
12
|
+
"ymail.com",
|
|
13
|
+
"aol.com",
|
|
14
|
+
"outlook.com",
|
|
15
|
+
"hotmail.com",
|
|
16
|
+
"hotmail.co.uk",
|
|
17
|
+
"live.com",
|
|
18
|
+
"msn.com",
|
|
19
|
+
"icloud.com",
|
|
20
|
+
"me.com",
|
|
21
|
+
"mac.com",
|
|
22
|
+
"proton.me",
|
|
23
|
+
"protonmail.com",
|
|
24
|
+
"pm.me",
|
|
25
|
+
"gmx.com",
|
|
26
|
+
"gmx.net",
|
|
27
|
+
"mail.com",
|
|
28
|
+
"zoho.com",
|
|
29
|
+
"yandex.com",
|
|
30
|
+
"netscape.net",
|
|
31
|
+
"juno.com",
|
|
32
|
+
"netzero.net",
|
|
33
|
+
// Consumer ISPs, national and regional.
|
|
34
|
+
"att.net",
|
|
35
|
+
"bellsouth.net",
|
|
36
|
+
"centurylink.net",
|
|
37
|
+
"centurytel.net",
|
|
38
|
+
"charter.net",
|
|
39
|
+
"comcast.net",
|
|
40
|
+
"cox.net",
|
|
41
|
+
"earthlink.net",
|
|
42
|
+
"embarqmail.com",
|
|
43
|
+
"frontier.com",
|
|
44
|
+
"frontiernet.net",
|
|
45
|
+
"gpcom.net",
|
|
46
|
+
"hughes.net",
|
|
47
|
+
"mchsi.com",
|
|
48
|
+
"midconetwork.com",
|
|
49
|
+
"optimum.net",
|
|
50
|
+
"optonline.net",
|
|
51
|
+
"ptd.net",
|
|
52
|
+
"q.com",
|
|
53
|
+
"roadrunner.com",
|
|
54
|
+
"rr.com",
|
|
55
|
+
"sbcglobal.net",
|
|
56
|
+
"suddenlink.net",
|
|
57
|
+
"twc.com",
|
|
58
|
+
"verizon.net",
|
|
59
|
+
"windstream.net",
|
|
60
|
+
"wowway.com",
|
|
61
|
+
"zoominternet.net",
|
|
62
|
+
]);
|
|
63
|
+
/** Functional mailboxes (info@, hello@): a role, not a person. Advisory, never a failure. */
|
|
64
|
+
export const ROLE_LOCALPARTS = new Set([
|
|
65
|
+
"abuse",
|
|
66
|
+
"admin",
|
|
67
|
+
"administrator",
|
|
68
|
+
"billing",
|
|
69
|
+
"contact",
|
|
70
|
+
"help",
|
|
71
|
+
"hello",
|
|
72
|
+
"hr",
|
|
73
|
+
"info",
|
|
74
|
+
"jobs",
|
|
75
|
+
"mail",
|
|
76
|
+
"marketing",
|
|
77
|
+
"no-reply",
|
|
78
|
+
"noreply",
|
|
79
|
+
"office",
|
|
80
|
+
"postmaster",
|
|
81
|
+
"privacy",
|
|
82
|
+
"sales",
|
|
83
|
+
"security",
|
|
84
|
+
"support",
|
|
85
|
+
"team",
|
|
86
|
+
"webmaster",
|
|
87
|
+
]);
|
|
88
|
+
const LOCAL_RE = /^[a-z0-9!#$%&'*+/=?^_`{|}~.-]+$/;
|
|
89
|
+
const LABEL_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
90
|
+
// IDN labels arrive punycode-encoded ("xn--mnchen-3ya" = münchen).
|
|
91
|
+
const PUNYCODE_RE = /^xn--[a-z0-9-]+$/;
|
|
92
|
+
export function normalizeEmail(raw) {
|
|
93
|
+
let email = raw.trim().toLowerCase();
|
|
94
|
+
if (email.startsWith("mailto:"))
|
|
95
|
+
email = email.slice("mailto:".length);
|
|
96
|
+
return email.replace(/^[<>]+|[<>]+$/g, "").trim();
|
|
97
|
+
}
|
|
98
|
+
/** Reason the (already normalized) address is undeliverable, or null. */
|
|
99
|
+
export function emailSyntaxError(email) {
|
|
100
|
+
const parts = email.split("@");
|
|
101
|
+
if (parts.length !== 2)
|
|
102
|
+
return "must contain exactly one @";
|
|
103
|
+
const [local, domain] = parts;
|
|
104
|
+
if (!local)
|
|
105
|
+
return "empty local part";
|
|
106
|
+
if (local.length > 64)
|
|
107
|
+
return "local part longer than 64 chars";
|
|
108
|
+
if (email.length > 254)
|
|
109
|
+
return "address longer than 254 chars";
|
|
110
|
+
if (local.startsWith(".") || local.endsWith(".") || local.includes("..")) {
|
|
111
|
+
return "misplaced dot in local part";
|
|
112
|
+
}
|
|
113
|
+
if (!LOCAL_RE.test(local))
|
|
114
|
+
return "illegal character in local part";
|
|
115
|
+
if (!validDomain(domain))
|
|
116
|
+
return "invalid domain";
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
export function validDomain(domain) {
|
|
120
|
+
if (!domain || domain.length > 253)
|
|
121
|
+
return false;
|
|
122
|
+
const labels = domain.replace(/\.+$/, "").split(".");
|
|
123
|
+
if (labels.length < 2)
|
|
124
|
+
return false;
|
|
125
|
+
if (!labels.every((l) => LABEL_RE.test(l) || PUNYCODE_RE.test(l)))
|
|
126
|
+
return false;
|
|
127
|
+
const tld = labels[labels.length - 1];
|
|
128
|
+
return tld.length >= 2 && (/^[a-z]+$/.test(tld) || PUNYCODE_RE.test(tld));
|
|
129
|
+
}
|
|
130
|
+
export function emailDomain(email) {
|
|
131
|
+
return email.slice(email.lastIndexOf("@") + 1);
|
|
132
|
+
}
|
|
133
|
+
export function isFreemail(domain) {
|
|
134
|
+
return FREEMAIL_DOMAINS.has(domain.toLowerCase());
|
|
135
|
+
}
|
|
136
|
+
/** True for a functional mailbox by its local part alone; plus-tags stripped. A bare word is never an address. */
|
|
137
|
+
export function isRoleLocalpart(email) {
|
|
138
|
+
const at = email.indexOf("@");
|
|
139
|
+
if (at < 0)
|
|
140
|
+
return false;
|
|
141
|
+
const local = email.slice(0, at).trim().toLowerCase();
|
|
142
|
+
return ROLE_LOCALPARTS.has(local.split("+", 1)[0]);
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=address.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"address.js","sourceRoot":"","sources":["../src/address.ts"],"names":[],"mappings":"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"}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A probe that asks another host: `POST /verify {email}` to a `makeProbeServer` running
|
|
3
|
+
* somewhere port 25 is open, bearer-authenticated. The verdict is that host's own
|
|
4
|
+
* SmtpProbe verdict, so an answer reads the same whichever machine ran the handshake.
|
|
5
|
+
*/
|
|
6
|
+
import type { FetchLike } from "./dns.js";
|
|
7
|
+
import { type MailboxProbe, type Verdict } from "./verdict.js";
|
|
8
|
+
/** The far side did not answer, or answered with something other than a verdict: stop, do not guess. */
|
|
9
|
+
export declare class RemoteProbeError extends Error {
|
|
10
|
+
name: string;
|
|
11
|
+
}
|
|
12
|
+
export declare class RemoteProbe implements MailboxProbe {
|
|
13
|
+
private readonly token;
|
|
14
|
+
private readonly fetchImpl;
|
|
15
|
+
readonly name = "smtp";
|
|
16
|
+
private readonly base;
|
|
17
|
+
constructor(baseUrl: string, token: string, fetchImpl?: FetchLike);
|
|
18
|
+
verify(email: string): Promise<Verdict>;
|
|
19
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A probe that asks another host: `POST /verify {email}` to a `makeProbeServer` running
|
|
3
|
+
* somewhere port 25 is open, bearer-authenticated. The verdict is that host's own
|
|
4
|
+
* SmtpProbe verdict, so an answer reads the same whichever machine ran the handshake.
|
|
5
|
+
*/
|
|
6
|
+
import { MAILBOX_RESULTS } from "./verdict.js";
|
|
7
|
+
/** The far side did not answer, or answered with something other than a verdict: stop, do not guess. */
|
|
8
|
+
export class RemoteProbeError extends Error {
|
|
9
|
+
name = "RemoteProbeError";
|
|
10
|
+
}
|
|
11
|
+
export class RemoteProbe {
|
|
12
|
+
token;
|
|
13
|
+
fetchImpl;
|
|
14
|
+
name = "smtp";
|
|
15
|
+
base;
|
|
16
|
+
constructor(baseUrl, token, fetchImpl = fetch) {
|
|
17
|
+
this.token = token;
|
|
18
|
+
this.fetchImpl = fetchImpl;
|
|
19
|
+
this.base = baseUrl.replace(/\/+$/, "");
|
|
20
|
+
}
|
|
21
|
+
async verify(email) {
|
|
22
|
+
let resp;
|
|
23
|
+
try {
|
|
24
|
+
resp = await this.fetchImpl(`${this.base}/verify`, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" },
|
|
27
|
+
body: JSON.stringify({ email }),
|
|
28
|
+
// A probe walks up to three MX hosts with a socket timeout each.
|
|
29
|
+
signal: AbortSignal.timeout(60_000),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
// The token rides in a header, never in a message; keep the error to its name.
|
|
34
|
+
throw new RemoteProbeError(`probe host unreachable: ${err instanceof Error ? err.name : "Error"}`);
|
|
35
|
+
}
|
|
36
|
+
if (!resp.ok) {
|
|
37
|
+
// The server's own error text (ours, short, never a secret); anything else is dropped.
|
|
38
|
+
const detail = await resp
|
|
39
|
+
.json()
|
|
40
|
+
.then((d) => d && typeof d === "object" ? d.error : null)
|
|
41
|
+
.catch(() => null);
|
|
42
|
+
throw new RemoteProbeError(`probe host HTTP ${resp.status}${typeof detail === "string" ? `: ${detail}` : ""}`);
|
|
43
|
+
}
|
|
44
|
+
const data = (await resp.json());
|
|
45
|
+
if (!MAILBOX_RESULTS.includes(data.result))
|
|
46
|
+
throw new RemoteProbeError(`unexpected verdict: ${JSON.stringify(data.result)}`);
|
|
47
|
+
return {
|
|
48
|
+
result: data.result,
|
|
49
|
+
raw: (data.raw ?? {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EAAE,eAAe,EAAuD,MAAM,cAAc,CAAC;AAEpG,wGAAwG;AACxG,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IAChC,IAAI,GAAG,kBAAkB,CAAC;CACpC;AAED,MAAM,OAAO,WAAW;IAMH;IACA;IANV,IAAI,GAAG,MAAM,CAAC;IACN,IAAI,CAAS;IAE9B,YACE,OAAe,EACE,KAAa,EACb,YAAuB,KAAK;QAD5B,UAAK,GAAL,KAAK,CAAQ;QACb,cAAS,GAAT,SAAS,CAAmB;QAE7C,IAAI,CAAC,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC1C,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,KAAa;QACxB,IAAI,IAAc,CAAC;QACnB,IAAI,CAAC;YACH,IAAI,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,GAAG,IAAI,CAAC,IAAI,SAAS,EAAE;gBACjD,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE,EAAE,aAAa,EAAE,UAAU,IAAI,CAAC,KAAK,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;gBACtF,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,CAAC;gBAC/B,iEAAiE;gBACjE,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,MAAM,CAAC;aACpC,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,+EAA+E;YAC/E,MAAM,IAAI,gBAAgB,CACxB,2BAA2B,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,EAAE,CACvE,CAAC;QACJ,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC;YACb,uFAAuF;YACvF,MAAM,MAAM,GAAG,MAAM,IAAI;iBACtB,IAAI,EAAE;iBACN,IAAI,CAAC,CAAC,CAAU,EAAE,EAAE,CACnB,CAAC,IAAI,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAE,CAAyB,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CACrE;iBACA,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;YACrB,MAAM,IAAI,gBAAgB,CACxB,mBAAmB,IAAI,CAAC,MAAM,GAAG,OAAO,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CACnF,CAAC;QACJ,CAAC;QACD,MAAM,IAAI,GAAG,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE,CAAwC,CAAC;QACxE,IAAI,CAAC,eAAe,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAuB,CAAC;YACzD,MAAM,IAAI,gBAAgB,CAAC,uBAAuB,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QACnF,OAAO;YACL,MAAM,EAAE,IAAI,CAAC,MAAuB;YACpC,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,IAAI,EAAE,CAA4B;SACjD,CAAC;IACJ,CAAC;CACF"}
|
package/dist/dns.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DNS over HTTPS (Cloudflare's resolver), the little that mailbox checking needs: MX,
|
|
3
|
+
* A, AAAA. Over HTTPS so it works from a container with no resolver of its own, and so
|
|
4
|
+
* a wrong answer needs more than a spoofed UDP packet.
|
|
5
|
+
*/
|
|
6
|
+
export declare const DOH_ENDPOINT = "https://cloudflare-dns.com/dns-query";
|
|
7
|
+
export type DnsType = "MX" | "A" | "AAAA";
|
|
8
|
+
/** The resolver could not be reached or answered non-2xx: resolver trouble, not evidence about the name. */
|
|
9
|
+
export declare class DohError extends Error {
|
|
10
|
+
name: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The resolver answered (HTTP 200) but the DNS status was neither NOERROR nor
|
|
14
|
+
* NXDOMAIN (SERVFAIL, REFUSED) or the body wasn't DNS-JSON. Callers must not treat
|
|
15
|
+
* it as a definitive empty answer.
|
|
16
|
+
*/
|
|
17
|
+
export declare class DohStatusError extends DohError {
|
|
18
|
+
name: string;
|
|
19
|
+
}
|
|
20
|
+
/** The lookup seam: every probe takes one, so tests never touch the network. */
|
|
21
|
+
export type Resolver = (name: string, rtype: DnsType) => Promise<string[]>;
|
|
22
|
+
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
23
|
+
export declare function resolve(name: string, rtype: DnsType, fetchImpl?: FetchLike): Promise<string[]>;
|
package/dist/dns.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DNS over HTTPS (Cloudflare's resolver), the little that mailbox checking needs: MX,
|
|
3
|
+
* A, AAAA. Over HTTPS so it works from a container with no resolver of its own, and so
|
|
4
|
+
* a wrong answer needs more than a spoofed UDP packet.
|
|
5
|
+
*/
|
|
6
|
+
export const DOH_ENDPOINT = "https://cloudflare-dns.com/dns-query";
|
|
7
|
+
const TYPE_CODES = { MX: 15, A: 1, AAAA: 28 };
|
|
8
|
+
// DNS RCODEs carried in the DNS-JSON `Status` field (RFC 1035 §4.1.1).
|
|
9
|
+
const NOERROR = 0;
|
|
10
|
+
const NXDOMAIN = 3;
|
|
11
|
+
/** The resolver could not be reached or answered non-2xx: resolver trouble, not evidence about the name. */
|
|
12
|
+
export class DohError extends Error {
|
|
13
|
+
name = "DohError";
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The resolver answered (HTTP 200) but the DNS status was neither NOERROR nor
|
|
17
|
+
* NXDOMAIN (SERVFAIL, REFUSED) or the body wasn't DNS-JSON. Callers must not treat
|
|
18
|
+
* it as a definitive empty answer.
|
|
19
|
+
*/
|
|
20
|
+
export class DohStatusError extends DohError {
|
|
21
|
+
name = "DohStatusError";
|
|
22
|
+
}
|
|
23
|
+
export async function resolve(name, rtype, fetchImpl = fetch) {
|
|
24
|
+
const url = `${DOH_ENDPOINT}?${new URLSearchParams({ name, type: rtype })}`;
|
|
25
|
+
let resp;
|
|
26
|
+
try {
|
|
27
|
+
resp = await fetchImpl(url, {
|
|
28
|
+
headers: { accept: "application/dns-json" },
|
|
29
|
+
signal: AbortSignal.timeout(10_000),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
throw new DohError(`resolver unreachable for ${rtype} ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
34
|
+
}
|
|
35
|
+
if (!resp.ok)
|
|
36
|
+
throw new DohError(`HTTP ${resp.status} for ${rtype} ${name}`);
|
|
37
|
+
let body;
|
|
38
|
+
try {
|
|
39
|
+
body = (await resp.json());
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new DohStatusError(`non-JSON response for ${rtype} ${name}`);
|
|
43
|
+
}
|
|
44
|
+
if (body.Status === NXDOMAIN)
|
|
45
|
+
return [];
|
|
46
|
+
if (body.Status !== NOERROR)
|
|
47
|
+
throw new DohStatusError(`DNS status ${body.Status} for ${rtype} ${name}`);
|
|
48
|
+
return (body.Answer ?? [])
|
|
49
|
+
.filter((a) => a.type === TYPE_CODES[rtype])
|
|
50
|
+
.map((a) => a.data);
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=dns.js.map
|
package/dist/dns.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dns.js","sourceRoot":"","sources":["../src/dns.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,CAAC,MAAM,YAAY,GAAG,sCAAsC,CAAC;AAEnE,MAAM,UAAU,GAA4B,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC;AACvE,uEAAuE;AACvE,MAAM,OAAO,GAAG,CAAC,CAAC;AAClB,MAAM,QAAQ,GAAG,CAAC,CAAC;AAEnB,4GAA4G;AAC5G,MAAM,OAAO,QAAS,SAAQ,KAAK;IACxB,IAAI,GAAG,UAAU,CAAC;CAC5B;AACD;;;;GAIG;AACH,MAAM,OAAO,cAAe,SAAQ,QAAQ;IACjC,IAAI,GAAG,gBAAgB,CAAC;CAClC;AAMD,MAAM,CAAC,KAAK,UAAU,OAAO,CAC3B,IAAY,EACZ,KAAc,EACd,YAAuB,KAAK;IAE5B,MAAM,GAAG,GAAG,GAAG,YAAY,IAAI,IAAI,eAAe,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC;IAC5E,IAAI,IAAc,CAAC;IACnB,IAAI,CAAC;QACH,IAAI,GAAG,MAAM,SAAS,CAAC,GAAG,EAAE;YAC1B,OAAO,EAAE,EAAE,MAAM,EAAE,sBAAsB,EAAE;YAC3C,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,MAAM,CAAC;SACpC,CAAC,CAAC;IACL,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,IAAI,QAAQ,CAChB,4BAA4B,KAAK,IAAI,IAAI,KAAK,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CACjG,CAAC;IACJ,CAAC;IACD,IAAI,CAAC,IAAI,CAAC,EAAE;QAAE,MAAM,IAAI,QAAQ,CAAC,QAAQ,IAAI,CAAC,MAAM,QAAQ,KAAK,IAAI,IAAI,EAAE,CAAC,CAAC;IAC7E,IAAI,IAAsE,CAAC;IAC3E,IAAI,CAAC;QACH,IAAI,GAAG,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE,CAAgB,CAAC;IAC5C,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,cAAc,CAAC,yBAAyB,KAAK,IAAI,IAAI,EAAE,CAAC,CAAC;IACrE,CAAC;IACD,IAAI,IAAI,CAAC,MAAM,KAAK,QAAQ;QAAE,OAAO,EAAE,CAAC;IACxC,IAAI,IAAI,CAAC,MAAM,KAAK,OAAO;QACzB,MAAM,IAAI,cAAc,CAAC,cAAc,IAAI,CAAC,MAAM,QAAQ,KAAK,IAAI,IAAI,EAAE,CAAC,CAAC;IAC7E,OAAO,CAAC,IAAI,CAAC,MAAM,IAAI,EAAE,CAAC;SACvB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,UAAU,CAAC,KAAK,CAAC,CAAC;SAC3C,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAc,CAAC,CAAC;AAClC,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mailifier: ask a mail server whether an address exists, without sending anything.
|
|
3
|
+
*
|
|
4
|
+
* Start at `verdict.ts` — `MailboxProbe` is the interface everything else implements,
|
|
5
|
+
* and the one thing a caller should depend on.
|
|
6
|
+
*/
|
|
7
|
+
export * from "./address.js";
|
|
8
|
+
export * from "./client.js";
|
|
9
|
+
export * from "./dns.js";
|
|
10
|
+
export * from "./local.js";
|
|
11
|
+
export * from "./server.js";
|
|
12
|
+
export * from "./smtp.js";
|
|
13
|
+
export * from "./verdict.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mailifier: ask a mail server whether an address exists, without sending anything.
|
|
3
|
+
*
|
|
4
|
+
* Start at `verdict.ts` — `MailboxProbe` is the interface everything else implements,
|
|
5
|
+
* and the one thing a caller should depend on.
|
|
6
|
+
*/
|
|
7
|
+
export * from "./address.js";
|
|
8
|
+
export * from "./client.js";
|
|
9
|
+
export * from "./dns.js";
|
|
10
|
+
export * from "./local.js";
|
|
11
|
+
export * from "./server.js";
|
|
12
|
+
export * from "./smtp.js";
|
|
13
|
+
export * from "./verdict.js";
|
|
14
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,WAAW,CAAC;AAC1B,cAAc,cAAc,CAAC"}
|
package/dist/local.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type Resolver } from "./dns.js";
|
|
2
|
+
/** Registered typosquats of the big providers: mail often ACCEPTS there, so "deliverable" is the wrong signal. */
|
|
3
|
+
export declare const TYPO_DOMAINS: ReadonlySet<string>;
|
|
4
|
+
export declare const DISPOSABLE_DOMAINS: ReadonlySet<string>;
|
|
5
|
+
export type MxPath = "mx" | "a" | "aaaa";
|
|
6
|
+
export type LocalFlag = "role_account" | "freemail" | "mx_fallback" | "mx_unresolved";
|
|
7
|
+
export interface LocalCheck {
|
|
8
|
+
email: string;
|
|
9
|
+
/** null = passed stage 1 */
|
|
10
|
+
failure: string | null;
|
|
11
|
+
flags: readonly LocalFlag[];
|
|
12
|
+
/** Resolved MX hosts (lowercased, priority-stripped); empty whenever no real MX was found. */
|
|
13
|
+
mxHosts: readonly string[];
|
|
14
|
+
/** Which record type supplied deliverability. */
|
|
15
|
+
mxPath: MxPath | null;
|
|
16
|
+
passed: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface LocalCheckerLike {
|
|
19
|
+
check(email: string): Promise<LocalCheck>;
|
|
20
|
+
}
|
|
21
|
+
/** Checks one email at a time; MX lookups are cached per domain, since imports cluster on shared domains. */
|
|
22
|
+
export declare class LocalChecker implements LocalCheckerLike {
|
|
23
|
+
private readonly resolver;
|
|
24
|
+
private readonly cache;
|
|
25
|
+
constructor(resolver?: Resolver);
|
|
26
|
+
check(email: string): Promise<LocalCheck>;
|
|
27
|
+
private mxStatus;
|
|
28
|
+
private lookup;
|
|
29
|
+
}
|
package/dist/local.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 1: everything knowable without dialling anyone, run before a probe opens a
|
|
3
|
+
* socket. Cheap, and it catches most of what a paid service would charge for.
|
|
4
|
+
*
|
|
5
|
+
* Failures are definitive (an `invalid` nobody needs to confirm): broken syntax, known
|
|
6
|
+
* typosquat domains, disposable providers, domains with no mail routing. Flags are
|
|
7
|
+
* advisory: role accounts and freemail are common for SMBs; the caller decides what to do with them.
|
|
8
|
+
* A DNS lookup that errors, or that the resolver couldn't answer authoritatively
|
|
9
|
+
* (SERVFAIL/REFUSED), flags mx_unresolved and passes: only an authoritative empty
|
|
10
|
+
* answer may mean "no mail routing".
|
|
11
|
+
*/
|
|
12
|
+
import { emailDomain, emailSyntaxError, isFreemail, isRoleLocalpart } from "./address.js";
|
|
13
|
+
import { DohError, resolve } from "./dns.js";
|
|
14
|
+
/** Registered typosquats of the big providers: mail often ACCEPTS there, so "deliverable" is the wrong signal. */
|
|
15
|
+
export const TYPO_DOMAINS = new Set([
|
|
16
|
+
"gmial.com",
|
|
17
|
+
"gmal.com",
|
|
18
|
+
"gamil.com",
|
|
19
|
+
"gmai.com",
|
|
20
|
+
"gmil.com",
|
|
21
|
+
"gnail.com",
|
|
22
|
+
"gmaill.com",
|
|
23
|
+
"hotmial.com",
|
|
24
|
+
"hotmal.com",
|
|
25
|
+
"hotnail.com",
|
|
26
|
+
"yahooo.com",
|
|
27
|
+
"yaho.com",
|
|
28
|
+
"outlok.com",
|
|
29
|
+
]);
|
|
30
|
+
export const DISPOSABLE_DOMAINS = new Set([
|
|
31
|
+
"mailinator.com",
|
|
32
|
+
"guerrillamail.com",
|
|
33
|
+
"sharklasers.com",
|
|
34
|
+
"10minutemail.com",
|
|
35
|
+
"temp-mail.org",
|
|
36
|
+
"tempmail.com",
|
|
37
|
+
"yopmail.com",
|
|
38
|
+
"throwawaymail.com",
|
|
39
|
+
"getnada.com",
|
|
40
|
+
"maildrop.cc",
|
|
41
|
+
"trashmail.com",
|
|
42
|
+
"dispostable.com",
|
|
43
|
+
"fakeinbox.com",
|
|
44
|
+
]);
|
|
45
|
+
const check = (email, failure, flags = [], mxHosts = [], mxPath = null) => ({ email, failure, flags, mxHosts, mxPath, passed: failure === null });
|
|
46
|
+
/** Checks one email at a time; MX lookups are cached per domain, since imports cluster on shared domains. */
|
|
47
|
+
export class LocalChecker {
|
|
48
|
+
resolver;
|
|
49
|
+
cache = new Map();
|
|
50
|
+
constructor(resolver = (n, t) => resolve(n, t)) {
|
|
51
|
+
this.resolver = resolver;
|
|
52
|
+
}
|
|
53
|
+
async check(email) {
|
|
54
|
+
const reason = emailSyntaxError(email);
|
|
55
|
+
if (reason)
|
|
56
|
+
return check(email, `syntax: ${reason}`);
|
|
57
|
+
const domain = emailDomain(email);
|
|
58
|
+
if (TYPO_DOMAINS.has(domain))
|
|
59
|
+
return check(email, "typo_domain");
|
|
60
|
+
if (DISPOSABLE_DOMAINS.has(domain))
|
|
61
|
+
return check(email, "disposable_domain");
|
|
62
|
+
const flags = [];
|
|
63
|
+
if (isRoleLocalpart(email))
|
|
64
|
+
flags.push("role_account");
|
|
65
|
+
if (isFreemail(domain))
|
|
66
|
+
flags.push("freemail");
|
|
67
|
+
const mx = await this.mxStatus(domain);
|
|
68
|
+
return check(email, mx.failure, [...flags, ...mx.flags], mx.hosts, mx.path);
|
|
69
|
+
}
|
|
70
|
+
mxStatus(domain) {
|
|
71
|
+
let status = this.cache.get(domain);
|
|
72
|
+
if (!status) {
|
|
73
|
+
status = this.lookup(domain);
|
|
74
|
+
this.cache.set(domain, status);
|
|
75
|
+
}
|
|
76
|
+
return status;
|
|
77
|
+
}
|
|
78
|
+
async lookup(domain) {
|
|
79
|
+
let records;
|
|
80
|
+
try {
|
|
81
|
+
records = await this.resolver(domain, "MX");
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
// A resolver hiccup is not evidence of anything about the domain: fail open.
|
|
85
|
+
if (err instanceof DohError)
|
|
86
|
+
return { failure: null, flags: ["mx_unresolved"], hosts: [], path: null };
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
// RFC 7505 null MX: "0 ." declares the domain never receives mail.
|
|
90
|
+
const targets = records
|
|
91
|
+
.map((r) => r.trim().split(/\s+/).at(-1) ?? "")
|
|
92
|
+
.filter((_, i) => records[i]?.trim());
|
|
93
|
+
if (targets.length && targets.every((t) => t === "." || t === ""))
|
|
94
|
+
return { failure: "null_mx", flags: [], hosts: [], path: null };
|
|
95
|
+
if (targets.length) {
|
|
96
|
+
const hosts = [
|
|
97
|
+
...new Set(targets.map((t) => t.replace(/\.+$/, "").toLowerCase()).filter(Boolean)),
|
|
98
|
+
].sort();
|
|
99
|
+
return { failure: null, flags: [], hosts, path: "mx" };
|
|
100
|
+
}
|
|
101
|
+
// No MX: mail falls back to the domain's own address record, A then AAAA (RFC 5321 §5.1).
|
|
102
|
+
// Real routing but a weaker signal, so flagged; only a domain with none of MX/A/AAAA is undeliverable.
|
|
103
|
+
try {
|
|
104
|
+
if ((await this.resolver(domain, "A")).length)
|
|
105
|
+
return { failure: null, flags: ["mx_fallback"], hosts: [], path: "a" };
|
|
106
|
+
if ((await this.resolver(domain, "AAAA")).length)
|
|
107
|
+
return { failure: null, flags: ["mx_fallback"], hosts: [], path: "aaaa" };
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
if (err instanceof DohError)
|
|
111
|
+
return { failure: null, flags: ["mx_unresolved"], hosts: [], path: null };
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
return { failure: "no_mx", flags: [], hosts: [], path: null };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=local.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local.js","sourceRoot":"","sources":["../src/local.ts"],"names":[],"mappings":"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"}
|