@emailcheck/email-validator-js 3.4.4 → 4.0.0-beta.2
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 +38 -3
- package/dist/cache-interface.d.ts +4 -2
- package/dist/cli/index.js +374 -107
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.esm.js +407 -101
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +407 -100
- package/dist/index.js.map +1 -1
- package/dist/refine-reason.d.ts +1 -0
- package/dist/serverless/adapters/aws-lambda.cjs.js.map +1 -1
- package/dist/serverless/adapters/aws-lambda.esm.js.map +1 -1
- package/dist/serverless/adapters/vercel.cjs.js.map +1 -1
- package/dist/serverless/adapters/vercel.esm.js.map +1 -1
- package/dist/serverless/index.cjs.js.map +1 -1
- package/dist/serverless/index.esm.js.map +1 -1
- package/dist/smtp-verifier.d.ts +31 -14
- package/dist/types.d.ts +103 -29
- package/package.json +12 -2
package/dist/cli/index.js
CHANGED
|
@@ -7,6 +7,7 @@ var tinyLru = require('tiny-lru');
|
|
|
7
7
|
var psl = require('psl');
|
|
8
8
|
var stringSimilarityJs = require('string-similarity-js');
|
|
9
9
|
var node_dns = require('node:dns');
|
|
10
|
+
var node_crypto = require('node:crypto');
|
|
10
11
|
var net$1 = require('node:net');
|
|
11
12
|
var tls = require('node:tls');
|
|
12
13
|
|
|
@@ -313,20 +314,11 @@ var VerificationErrorCode = /* @__PURE__ */ ((VerificationErrorCode2) => {
|
|
|
313
314
|
VerificationErrorCode2["freeEmailProvider"] = "FREE_EMAIL_PROVIDER";
|
|
314
315
|
return VerificationErrorCode2;
|
|
315
316
|
})(VerificationErrorCode || {});
|
|
316
|
-
var EmailProvider = /* @__PURE__ */ ((EmailProvider2) => {
|
|
317
|
-
EmailProvider2["gmail"] = "gmail";
|
|
318
|
-
EmailProvider2["hotmailB2b"] = "hotmail_b2b";
|
|
319
|
-
EmailProvider2["hotmailB2c"] = "hotmail_b2c";
|
|
320
|
-
EmailProvider2["proofpoint"] = "proofpoint";
|
|
321
|
-
EmailProvider2["mimecast"] = "mimecast";
|
|
322
|
-
EmailProvider2["yahoo"] = "yahoo";
|
|
323
|
-
EmailProvider2["everythingElse"] = "everything_else";
|
|
324
|
-
return EmailProvider2;
|
|
325
|
-
})(EmailProvider || {});
|
|
326
317
|
var SMTPStep = /* @__PURE__ */ ((SMTPStep2) => {
|
|
327
318
|
SMTPStep2["greeting"] = "GREETING";
|
|
328
319
|
SMTPStep2["ehlo"] = "EHLO";
|
|
329
320
|
SMTPStep2["helo"] = "HELO";
|
|
321
|
+
SMTPStep2["startTls"] = "STARTTLS";
|
|
330
322
|
SMTPStep2["mailFrom"] = "MAIL_FROM";
|
|
331
323
|
SMTPStep2["rcptTo"] = "RCPT_TO";
|
|
332
324
|
return SMTPStep2;
|
|
@@ -765,9 +757,9 @@ function parseCompositeNamePart(part) {
|
|
|
765
757
|
cleaned = pureAlpha;
|
|
766
758
|
confidence = 0.6;
|
|
767
759
|
} else {
|
|
768
|
-
const
|
|
769
|
-
if (
|
|
770
|
-
cleaned =
|
|
760
|
+
const baseMatch = part.match(/^([a-zA-Z]+)\d*$/);
|
|
761
|
+
if (baseMatch) {
|
|
762
|
+
cleaned = baseMatch[1];
|
|
771
763
|
confidence = 0.75;
|
|
772
764
|
} else {
|
|
773
765
|
cleaned = part;
|
|
@@ -781,9 +773,7 @@ function parseCompositeNamePart(part) {
|
|
|
781
773
|
confidence = Math.min(1, confidence + 0.2);
|
|
782
774
|
}
|
|
783
775
|
}
|
|
784
|
-
|
|
785
|
-
const base = baseMatch ? baseMatch[1] : part;
|
|
786
|
-
return { base, hasNumbers, cleaned, confidence };
|
|
776
|
+
return { hasNumbers, cleaned, confidence };
|
|
787
777
|
}
|
|
788
778
|
function isLikelyName(str, allowNumbers = false, allowSingleLetter = false) {
|
|
789
779
|
if (!str) return false;
|
|
@@ -1037,6 +1027,9 @@ function parseDsn(reply) {
|
|
|
1037
1027
|
if (!match) return null;
|
|
1038
1028
|
return { class: Number(match[1]), subject: Number(match[2]), detail: Number(match[3]) };
|
|
1039
1029
|
}
|
|
1030
|
+
function dsnToString(dsn) {
|
|
1031
|
+
return `${dsn.class}.${dsn.subject}.${dsn.detail}`;
|
|
1032
|
+
}
|
|
1040
1033
|
function isPolicyBlock(reply) {
|
|
1041
1034
|
const dsn = parseDsn(reply);
|
|
1042
1035
|
return dsn?.class === 5 && dsn?.subject === 7;
|
|
@@ -1054,6 +1047,9 @@ function isInvalidMailboxError(reply) {
|
|
|
1054
1047
|
if (isPolicyBlock(reply)) return false;
|
|
1055
1048
|
return true;
|
|
1056
1049
|
}
|
|
1050
|
+
function defaultProbeLocal() {
|
|
1051
|
+
return `${node_crypto.randomBytes(8).toString("hex")}-noexist`;
|
|
1052
|
+
}
|
|
1057
1053
|
async function verifyMailboxSMTP(params) {
|
|
1058
1054
|
const { local, domain, options = {} } = params;
|
|
1059
1055
|
const mxRecords = params.mxRecords ?? [];
|
|
@@ -1067,16 +1063,30 @@ async function verifyMailboxSMTP(params) {
|
|
|
1067
1063
|
const cache = options.cache;
|
|
1068
1064
|
const log = debug ? (...args) => console.log("[SMTP]", ...args) : () => {
|
|
1069
1065
|
};
|
|
1070
|
-
const
|
|
1071
|
-
|
|
1066
|
+
const startedAtMs = Date.now();
|
|
1067
|
+
const primaryMx = mxRecords[0];
|
|
1068
|
+
if (!primaryMx) {
|
|
1072
1069
|
log("No MX records found");
|
|
1073
|
-
|
|
1070
|
+
const metrics2 = makeMetrics([], 0, 0, void 0, startedAtMs);
|
|
1071
|
+
return { smtpResult: failureResult("no_mx_records", metrics2), cached: false, port: 0, portCached: false };
|
|
1074
1072
|
}
|
|
1075
|
-
log(`Verifying ${local}@${domain} via ${
|
|
1073
|
+
log(`Verifying ${local}@${domain} via ${primaryMx} (mx count=${mxRecords.length})`);
|
|
1076
1074
|
const transcript = [];
|
|
1077
1075
|
const commands = [];
|
|
1076
|
+
const probeOptions = {
|
|
1077
|
+
local,
|
|
1078
|
+
domain,
|
|
1079
|
+
timeout,
|
|
1080
|
+
tlsConfig,
|
|
1081
|
+
hostname,
|
|
1082
|
+
sequence,
|
|
1083
|
+
log,
|
|
1084
|
+
catchAllProbeLocal: options.catchAllProbeLocal,
|
|
1085
|
+
pipelining: options.pipelining ?? "auto",
|
|
1086
|
+
startTls: options.startTls ?? "auto"
|
|
1087
|
+
};
|
|
1078
1088
|
const verdictCache = cache ? getCacheStore(cache, "smtp") : null;
|
|
1079
|
-
const verdictKey = `${
|
|
1089
|
+
const verdictKey = `${primaryMx}:${local}@${domain}`;
|
|
1080
1090
|
if (verdictCache) {
|
|
1081
1091
|
const cachedResult = await safeCacheGet(verdictCache, verdictKey);
|
|
1082
1092
|
if (cachedResult) {
|
|
@@ -1085,81 +1095,91 @@ async function verifyMailboxSMTP(params) {
|
|
|
1085
1095
|
}
|
|
1086
1096
|
}
|
|
1087
1097
|
const portCache = cache ? getCacheStore(cache, "smtpPort") : null;
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
});
|
|
1103
|
-
collectTranscript(transcript, commands, probe,
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
)
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
if (probe.result !== null) {
|
|
1119
|
-
await safeCacheSet(portCache, mxHost, port);
|
|
1120
|
-
return { smtpResult, cached: false, port, portCached: false };
|
|
1098
|
+
const cachedPort = portCache ? await safeCacheGet(portCache, primaryMx) : null;
|
|
1099
|
+
const mxHostsTried = [];
|
|
1100
|
+
let mxAttempts = 0;
|
|
1101
|
+
let portAttempts = 0;
|
|
1102
|
+
let lastReason = "all_attempts_failed";
|
|
1103
|
+
let lastEnhancedStatus;
|
|
1104
|
+
let lastResponseCode;
|
|
1105
|
+
for (const mxHost of mxRecords) {
|
|
1106
|
+
mxHostsTried.push(mxHost);
|
|
1107
|
+
mxAttempts++;
|
|
1108
|
+
const portsForThisMx = mxHost === primaryMx && cachedPort ? [cachedPort, ...ports.filter((p) => p !== cachedPort)] : ports;
|
|
1109
|
+
for (const port of portsForThisMx) {
|
|
1110
|
+
portAttempts++;
|
|
1111
|
+
log(`Testing ${mxHost}:${port}`);
|
|
1112
|
+
const probe = await runProbe({ ...probeOptions, mxHost, port });
|
|
1113
|
+
collectTranscript(transcript, commands, probe, mxHost, port);
|
|
1114
|
+
lastReason = probe.reason;
|
|
1115
|
+
if (probe.enhancedStatus !== void 0) lastEnhancedStatus = probe.enhancedStatus;
|
|
1116
|
+
if (probe.responseCode !== void 0) lastResponseCode = probe.responseCode;
|
|
1117
|
+
if (probe.result !== null) {
|
|
1118
|
+
const metrics2 = makeMetrics(mxHostsTried, mxAttempts, portAttempts, mxHost, startedAtMs);
|
|
1119
|
+
const smtpResult2 = toSmtpVerificationResult(probe, {
|
|
1120
|
+
transcript: captureTranscript ? transcript : void 0,
|
|
1121
|
+
commands: captureTranscript ? commands : void 0,
|
|
1122
|
+
metrics: metrics2
|
|
1123
|
+
});
|
|
1124
|
+
await safeCacheSet(verdictCache, verdictKey, smtpResult2);
|
|
1125
|
+
if (mxHost === primaryMx) await safeCacheSet(portCache, primaryMx, port);
|
|
1126
|
+
return { smtpResult: smtpResult2, cached: false, port, portCached: cachedPort === port };
|
|
1127
|
+
}
|
|
1121
1128
|
}
|
|
1122
1129
|
}
|
|
1123
|
-
log(
|
|
1130
|
+
log(`All MX\xD7port attempts failed (mx=${mxAttempts}, port=${portAttempts})`);
|
|
1131
|
+
const metrics = makeMetrics(mxHostsTried, mxAttempts, portAttempts, void 0, startedAtMs);
|
|
1132
|
+
const smtpResult = {
|
|
1133
|
+
...failureResult(lastReason, metrics),
|
|
1134
|
+
...lastEnhancedStatus !== void 0 ? { enhancedStatus: lastEnhancedStatus } : {},
|
|
1135
|
+
...lastResponseCode !== void 0 ? { responseCode: lastResponseCode } : {},
|
|
1136
|
+
...captureTranscript ? { transcript: [...transcript], commands: [...commands] } : {}
|
|
1137
|
+
};
|
|
1138
|
+
return { smtpResult, cached: false, port: 0, portCached: false };
|
|
1139
|
+
}
|
|
1140
|
+
function makeMetrics(mxHostsTried, mxAttempts, portAttempts, mxHostUsed, startedAtMs) {
|
|
1124
1141
|
return {
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
},
|
|
1129
|
-
|
|
1130
|
-
port: 0,
|
|
1131
|
-
portCached: false
|
|
1142
|
+
mxAttempts,
|
|
1143
|
+
portAttempts,
|
|
1144
|
+
mxHostsTried: [...mxHostsTried],
|
|
1145
|
+
...mxHostUsed !== void 0 ? { mxHostUsed } : {},
|
|
1146
|
+
totalDurationMs: Date.now() - startedAtMs
|
|
1132
1147
|
};
|
|
1133
1148
|
}
|
|
1134
|
-
function collectTranscript(transcript, commands, probe, port) {
|
|
1135
|
-
|
|
1136
|
-
for (const
|
|
1149
|
+
function collectTranscript(transcript, commands, probe, mxHost, port) {
|
|
1150
|
+
const prefix = `${mxHost}:${port}`;
|
|
1151
|
+
for (const line of probe.transcript) transcript.push(`${prefix}|s| ${line}`);
|
|
1152
|
+
for (const cmd of probe.commands) commands.push(`${prefix}|c| ${cmd}`);
|
|
1137
1153
|
}
|
|
1138
|
-
function failureResult(
|
|
1154
|
+
function failureResult(reason, metrics) {
|
|
1139
1155
|
return {
|
|
1140
1156
|
canConnectSmtp: false,
|
|
1141
1157
|
hasFullInbox: false,
|
|
1142
1158
|
isCatchAll: false,
|
|
1143
1159
|
isDeliverable: false,
|
|
1144
1160
|
isDisabled: false,
|
|
1145
|
-
error,
|
|
1146
|
-
|
|
1147
|
-
|
|
1161
|
+
error: reason,
|
|
1162
|
+
checkedAt: Date.now(),
|
|
1163
|
+
metrics
|
|
1148
1164
|
};
|
|
1149
1165
|
}
|
|
1150
|
-
function toSmtpVerificationResult(
|
|
1151
|
-
const
|
|
1166
|
+
function toSmtpVerificationResult(probe, extras) {
|
|
1167
|
+
const result = probe.result;
|
|
1168
|
+
const out = {
|
|
1152
1169
|
canConnectSmtp: result !== null,
|
|
1153
|
-
hasFullInbox:
|
|
1154
|
-
isCatchAll: false,
|
|
1170
|
+
hasFullInbox: probe.reason === "over_quota",
|
|
1171
|
+
isCatchAll: probe.isCatchAll ?? false,
|
|
1155
1172
|
isDeliverable: result === true,
|
|
1156
1173
|
isDisabled: result === false,
|
|
1157
|
-
error: result === true ? void 0 :
|
|
1158
|
-
|
|
1159
|
-
|
|
1174
|
+
error: result === true ? void 0 : probe.reason,
|
|
1175
|
+
checkedAt: Date.now(),
|
|
1176
|
+
metrics: extras.metrics,
|
|
1177
|
+
...probe.enhancedStatus !== void 0 ? { enhancedStatus: probe.enhancedStatus } : {},
|
|
1178
|
+
...probe.responseCode !== void 0 ? { responseCode: probe.responseCode } : {}
|
|
1160
1179
|
};
|
|
1161
|
-
if (
|
|
1162
|
-
|
|
1180
|
+
if (extras.transcript) out.transcript = [...extras.transcript];
|
|
1181
|
+
if (extras.commands) out.commands = [...extras.commands];
|
|
1182
|
+
return out;
|
|
1163
1183
|
}
|
|
1164
1184
|
async function safeCacheGet(store, key) {
|
|
1165
1185
|
if (!store) return null;
|
|
@@ -1186,9 +1206,31 @@ class SMTPProbeConnection {
|
|
|
1186
1206
|
this.buffer = "";
|
|
1187
1207
|
this.resolved = false;
|
|
1188
1208
|
this.currentStepIndex = 0;
|
|
1189
|
-
/**
|
|
1209
|
+
/** True between sending STARTTLS and the TLS handshake completing. */
|
|
1210
|
+
this.tlsUpgrading = false;
|
|
1211
|
+
/**
|
|
1212
|
+
* True when we sent the second EHLO after a successful STARTTLS upgrade.
|
|
1213
|
+
* The EHLO response arrives while `currentStepIndex` is still on `startTls`
|
|
1214
|
+
* — this flag tells the dispatcher to advance past startTls when the
|
|
1215
|
+
* post-upgrade EHLO returns 250, instead of treating it as a STARTTLS
|
|
1216
|
+
* acknowledgement.
|
|
1217
|
+
*/
|
|
1218
|
+
this.postUpgradeReEhlo = false;
|
|
1190
1219
|
this.transcript = [];
|
|
1191
1220
|
this.commands = [];
|
|
1221
|
+
// ── EHLO capability advertisement ────────────────────────────────────────
|
|
1222
|
+
this.supportsPipelining = false;
|
|
1223
|
+
this.supportsStartTls = false;
|
|
1224
|
+
this.dualPhase = "idle";
|
|
1225
|
+
this.realOutcome = "pending";
|
|
1226
|
+
this.probeOutcome = "pending";
|
|
1227
|
+
this.dualPipelined = false;
|
|
1228
|
+
/**
|
|
1229
|
+
* Pipelined-only escape hatch. When the real RCPT is rejected mid-batched-
|
|
1230
|
+
* envelope, the probe + RSET are already on the wire; we stash the verdict
|
|
1231
|
+
* and commit it after the response loop drains.
|
|
1232
|
+
*/
|
|
1233
|
+
this.pendingDecision = null;
|
|
1192
1234
|
this.onData = (data) => {
|
|
1193
1235
|
if (this.resolved) return;
|
|
1194
1236
|
this.resetStepTimer();
|
|
@@ -1200,7 +1242,7 @@ class SMTPProbeConnection {
|
|
|
1200
1242
|
this.processLine(line);
|
|
1201
1243
|
}
|
|
1202
1244
|
};
|
|
1203
|
-
const defaultSteps = [SMTPStep.greeting, SMTPStep.ehlo, SMTPStep.mailFrom, SMTPStep.rcptTo];
|
|
1245
|
+
const defaultSteps = [SMTPStep.greeting, SMTPStep.ehlo, SMTPStep.startTls, SMTPStep.mailFrom, SMTPStep.rcptTo];
|
|
1204
1246
|
this.steps = [...p.sequence?.steps ?? defaultSteps];
|
|
1205
1247
|
this.isTLS = PORT_TLS[p.port] === true;
|
|
1206
1248
|
const servername = isIPAddress(p.mxHost) ? void 0 : p.mxHost;
|
|
@@ -1211,6 +1253,7 @@ class SMTPProbeConnection {
|
|
|
1211
1253
|
minVersion: "TLSv1.2",
|
|
1212
1254
|
...typeof p.tlsConfig === "object" ? p.tlsConfig : {}
|
|
1213
1255
|
};
|
|
1256
|
+
this.probeLocal = p.catchAllProbeLocal ? p.catchAllProbeLocal(p.local, p.domain) : defaultProbeLocal();
|
|
1214
1257
|
}
|
|
1215
1258
|
run() {
|
|
1216
1259
|
return new Promise((resolve) => {
|
|
@@ -1219,7 +1262,8 @@ class SMTPProbeConnection {
|
|
|
1219
1262
|
this.connect();
|
|
1220
1263
|
this.armConnectionTimer();
|
|
1221
1264
|
} catch (error) {
|
|
1222
|
-
this.
|
|
1265
|
+
this.p.log(`connect threw: ${error instanceof Error ? error.message : "unknown"}`);
|
|
1266
|
+
this.finish(null, "connection_error");
|
|
1223
1267
|
}
|
|
1224
1268
|
});
|
|
1225
1269
|
}
|
|
@@ -1265,49 +1309,167 @@ class SMTPProbeConnection {
|
|
|
1265
1309
|
switch (step) {
|
|
1266
1310
|
case SMTPStep.greeting:
|
|
1267
1311
|
return;
|
|
1312
|
+
// server-driven; nothing to send
|
|
1268
1313
|
case SMTPStep.ehlo:
|
|
1269
1314
|
this.send(`EHLO ${this.p.hostname}`);
|
|
1270
1315
|
return;
|
|
1271
1316
|
case SMTPStep.helo:
|
|
1272
1317
|
this.send(`HELO ${this.p.hostname}`);
|
|
1273
1318
|
return;
|
|
1319
|
+
case SMTPStep.startTls:
|
|
1320
|
+
this.executeStartTls();
|
|
1321
|
+
return;
|
|
1274
1322
|
case SMTPStep.mailFrom: {
|
|
1275
1323
|
const from = this.p.sequence?.from ?? `<${this.p.local}@${this.p.domain}>`;
|
|
1276
1324
|
this.send(`MAIL FROM:${from}`);
|
|
1277
1325
|
return;
|
|
1278
1326
|
}
|
|
1279
1327
|
case SMTPStep.rcptTo:
|
|
1280
|
-
this.
|
|
1328
|
+
this.executeEnvelope();
|
|
1281
1329
|
return;
|
|
1282
1330
|
}
|
|
1283
1331
|
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Conditional STARTTLS upgrade. Skipped (advances to next step) when:
|
|
1334
|
+
* - already TLS (implicit-TLS port like 465 or already-upgraded)
|
|
1335
|
+
* - `startTls === 'never'`
|
|
1336
|
+
* - `startTls === 'auto'` AND the MX didn't advertise STARTTLS in EHLO
|
|
1337
|
+
*
|
|
1338
|
+
* Sends `STARTTLS` and waits for 220 when:
|
|
1339
|
+
* - `startTls === 'force'` (regardless of advertisement)
|
|
1340
|
+
* - `startTls === 'auto'` AND the MX advertised it
|
|
1341
|
+
*
|
|
1342
|
+
* On 220, `tls.connect()` wraps the existing socket. After the handshake
|
|
1343
|
+
* we re-EHLO (mandatory per RFC 3207 §4.2 — pre-TLS state must be
|
|
1344
|
+
* discarded) before continuing to MAIL FROM.
|
|
1345
|
+
*/
|
|
1346
|
+
executeStartTls() {
|
|
1347
|
+
const mode = this.p.startTls;
|
|
1348
|
+
const wantsUpgrade = !this.isTLS && mode !== "never" && (mode === "force" || mode === "auto" && this.supportsStartTls);
|
|
1349
|
+
if (!wantsUpgrade) {
|
|
1350
|
+
this.nextStep();
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
this.send("STARTTLS");
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Wrap the plaintext socket with TLS in place. Called after the server
|
|
1357
|
+
* answers our STARTTLS with 220. Detaches the plaintext-socket listeners
|
|
1358
|
+
* (TLS owns the underlying transport now), re-installs them on the wrapped
|
|
1359
|
+
* socket, resets EHLO-derived capabilities, and re-issues EHLO once the
|
|
1360
|
+
* handshake completes (RFC 3207 §4.2 mandates re-EHLO after upgrade —
|
|
1361
|
+
* pre-TLS state must be discarded).
|
|
1362
|
+
*/
|
|
1363
|
+
upgradeToTls() {
|
|
1364
|
+
const plainSocket = this.socket;
|
|
1365
|
+
if (!plainSocket) {
|
|
1366
|
+
this.finish(null, "tls_upgrade_failed");
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
const detach = plainSocket.removeAllListeners;
|
|
1370
|
+
if (typeof detach === "function") {
|
|
1371
|
+
detach.call(plainSocket, "data");
|
|
1372
|
+
detach.call(plainSocket, "error");
|
|
1373
|
+
detach.call(plainSocket, "close");
|
|
1374
|
+
detach.call(plainSocket, "timeout");
|
|
1375
|
+
}
|
|
1376
|
+
try {
|
|
1377
|
+
plainSocket.setTimeout(0);
|
|
1378
|
+
} catch {
|
|
1379
|
+
}
|
|
1380
|
+
this.tlsUpgrading = true;
|
|
1381
|
+
const servername = isIPAddress(this.p.mxHost) ? void 0 : this.p.mxHost;
|
|
1382
|
+
const tlsSocket = tls__namespace.connect({ ...this.tlsOptions, socket: plainSocket, servername }, () => {
|
|
1383
|
+
this.tlsUpgrading = false;
|
|
1384
|
+
this.isTLS = true;
|
|
1385
|
+
this.buffer = "";
|
|
1386
|
+
this.supportsStartTls = false;
|
|
1387
|
+
this.supportsPipelining = false;
|
|
1388
|
+
this.postUpgradeReEhlo = true;
|
|
1389
|
+
this.send(`EHLO ${this.p.hostname}`);
|
|
1390
|
+
});
|
|
1391
|
+
this.socket = tlsSocket;
|
|
1392
|
+
this.socket.on("data", this.onData);
|
|
1393
|
+
this.socket.on("error", () => {
|
|
1394
|
+
this.finish(null, this.tlsUpgrading ? "tls_handshake_failed" : "connection_error");
|
|
1395
|
+
});
|
|
1396
|
+
this.socket.on("close", () => this.finish(null, "connection_closed"));
|
|
1397
|
+
this.socket.setTimeout(this.p.timeout, () => this.finish(null, "socket_timeout"));
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Send the dual-probe envelope (real RCPT + probe RCPT + RSET). Pipelined
|
|
1401
|
+
* when the MX advertised PIPELINING (or `pipelining: 'force'`); sequential
|
|
1402
|
+
* otherwise.
|
|
1403
|
+
*/
|
|
1404
|
+
executeEnvelope() {
|
|
1405
|
+
const wantsPipelining = this.p.pipelining === "force" || this.p.pipelining === "auto" && this.supportsPipelining;
|
|
1406
|
+
const realCmd = `RCPT TO:<${this.p.local}@${this.p.domain}>`;
|
|
1407
|
+
if (wantsPipelining) {
|
|
1408
|
+
this.dualPipelined = true;
|
|
1409
|
+
const probeCmd = `RCPT TO:<${this.probeLocal}@${this.p.domain}>`;
|
|
1410
|
+
const rsetCmd = "RSET";
|
|
1411
|
+
this.commands.push(realCmd, probeCmd, rsetCmd);
|
|
1412
|
+
this.p.log(`\u2192 ${realCmd}`);
|
|
1413
|
+
this.p.log(`\u2192 ${probeCmd}`);
|
|
1414
|
+
this.p.log(`\u2192 ${rsetCmd}`);
|
|
1415
|
+
this.socket?.write(`${realCmd}\r
|
|
1416
|
+
${probeCmd}\r
|
|
1417
|
+
${rsetCmd}\r
|
|
1418
|
+
`);
|
|
1419
|
+
this.dualPhase = "rcpt_real";
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
this.dualPipelined = false;
|
|
1423
|
+
this.send(realCmd);
|
|
1424
|
+
this.dualPhase = "rcpt_real";
|
|
1425
|
+
}
|
|
1284
1426
|
processLine(line) {
|
|
1285
1427
|
if (this.resolved) return;
|
|
1286
1428
|
this.transcript.push(line);
|
|
1287
1429
|
this.p.log(`\u2190 ${line}`);
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
if (
|
|
1293
|
-
|
|
1294
|
-
|
|
1430
|
+
const codeStr = line.slice(0, 3);
|
|
1431
|
+
const numericCode = /^\d{3}$/.test(codeStr) ? parseInt(codeStr, 10) : null;
|
|
1432
|
+
if (numericCode !== null) this.lastResponseCode = numericCode;
|
|
1433
|
+
const dsn = parseDsn(line);
|
|
1434
|
+
if (dsn) this.lastEnhancedStatus = dsnToString(dsn);
|
|
1435
|
+
if (this.dualPhase === "idle" || this.dualPhase === "rcpt_real") {
|
|
1436
|
+
if (isHighVolume(line)) {
|
|
1437
|
+
this.finish(true, "high_volume");
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
if (isOverQuota(line)) {
|
|
1441
|
+
this.isCatchAllFlag = false;
|
|
1442
|
+
this.finish(false, "over_quota");
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
if (isInvalidMailboxError(line)) {
|
|
1446
|
+
this.isCatchAllFlag = false;
|
|
1447
|
+
this.finish(false, "not_found");
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1295
1450
|
}
|
|
1296
|
-
if (
|
|
1297
|
-
this.
|
|
1451
|
+
if (MULTILINE_RE.test(line)) {
|
|
1452
|
+
const step = this.steps[this.currentStepIndex];
|
|
1453
|
+
const isEhloLike = step === SMTPStep.ehlo || step === SMTPStep.helo || step === SMTPStep.startTls && this.postUpgradeReEhlo;
|
|
1454
|
+
if (isEhloLike && line.startsWith("250-")) {
|
|
1455
|
+
const upper = line.toUpperCase();
|
|
1456
|
+
if (upper.includes("PIPELINING")) this.supportsPipelining = true;
|
|
1457
|
+
if (upper.includes("STARTTLS")) this.supportsStartTls = true;
|
|
1458
|
+
}
|
|
1298
1459
|
return;
|
|
1299
1460
|
}
|
|
1300
|
-
if (MULTILINE_RE.test(line)) return;
|
|
1301
|
-
const code = line.slice(0, 3);
|
|
1302
|
-
const numericCode = /^\d{3}$/.test(code) ? parseInt(code, 10) : null;
|
|
1303
1461
|
if (numericCode === null) {
|
|
1304
1462
|
this.finish(null, "unrecognized_response");
|
|
1305
1463
|
return;
|
|
1306
1464
|
}
|
|
1307
|
-
this.dispatch(numericCode);
|
|
1465
|
+
this.dispatch(numericCode, line);
|
|
1308
1466
|
}
|
|
1309
|
-
dispatch(code) {
|
|
1467
|
+
dispatch(code, line) {
|
|
1310
1468
|
const step = this.steps[this.currentStepIndex];
|
|
1469
|
+
if (this.dualPhase !== "idle" && step === SMTPStep.rcptTo) {
|
|
1470
|
+
this.handleEnvelopeReply(code, line);
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1311
1473
|
switch (step) {
|
|
1312
1474
|
case SMTPStep.greeting:
|
|
1313
1475
|
if (code === 220) this.nextStep();
|
|
@@ -1321,16 +1483,109 @@ class SMTPProbeConnection {
|
|
|
1321
1483
|
if (code === 250) this.nextStep();
|
|
1322
1484
|
else this.finish(null, "helo_failed");
|
|
1323
1485
|
return;
|
|
1486
|
+
case SMTPStep.startTls:
|
|
1487
|
+
if (this.postUpgradeReEhlo) {
|
|
1488
|
+
this.postUpgradeReEhlo = false;
|
|
1489
|
+
if (code === 250) this.nextStep();
|
|
1490
|
+
else this.finish(null, "ehlo_failed");
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
if (code === 220) {
|
|
1494
|
+
this.upgradeToTls();
|
|
1495
|
+
} else {
|
|
1496
|
+
this.finish(null, "tls_upgrade_failed");
|
|
1497
|
+
}
|
|
1498
|
+
return;
|
|
1324
1499
|
case SMTPStep.mailFrom:
|
|
1325
1500
|
if (code === 250) this.nextStep();
|
|
1326
1501
|
else this.finish(null, "mail_from_rejected");
|
|
1327
1502
|
return;
|
|
1328
1503
|
case SMTPStep.rcptTo:
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1504
|
+
this.handleEnvelopeReply(code, line);
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Dual-probe / pipelined-envelope reply router. Demuxes server replies for
|
|
1510
|
+
* the three queued commands (real RCPT, probe RCPT, RSET) and resolves
|
|
1511
|
+
* with the catch-all-aware verdict.
|
|
1512
|
+
*/
|
|
1513
|
+
handleEnvelopeReply(code, line) {
|
|
1514
|
+
if (this.dualPhase === "rcpt_real") {
|
|
1515
|
+
this.realOutcome = classifyRcpt(code);
|
|
1516
|
+
if (code === 552 || code === 452 || isOverQuota(line)) {
|
|
1517
|
+
this.isCatchAllFlag = false;
|
|
1518
|
+
if (this.dualPipelined) {
|
|
1519
|
+
this.pendingDecision = { result: false, reason: "over_quota" };
|
|
1520
|
+
this.dualPhase = "rcpt_probe";
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
this.finish(false, "over_quota");
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
if (this.realOutcome === "soft_reject") {
|
|
1527
|
+
if (this.dualPipelined) {
|
|
1528
|
+
this.pendingDecision = { result: null, reason: "temporary_failure" };
|
|
1529
|
+
this.dualPhase = "rcpt_probe";
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
this.finish(null, "temporary_failure");
|
|
1333
1533
|
return;
|
|
1534
|
+
}
|
|
1535
|
+
if (this.realOutcome === "hard_reject") {
|
|
1536
|
+
const reason = isInvalidMailboxError(line) ? "not_found" : "ambiguous";
|
|
1537
|
+
const result = reason === "not_found" ? false : null;
|
|
1538
|
+
this.isCatchAllFlag = false;
|
|
1539
|
+
if (this.dualPipelined) {
|
|
1540
|
+
this.pendingDecision = { result, reason };
|
|
1541
|
+
this.dualPhase = "rcpt_probe";
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
this.finish(result, reason);
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
if (this.dualPipelined) {
|
|
1548
|
+
this.dualPhase = "rcpt_probe";
|
|
1549
|
+
} else {
|
|
1550
|
+
this.send(`RCPT TO:<${this.probeLocal}@${this.p.domain}>`);
|
|
1551
|
+
this.dualPhase = "rcpt_probe";
|
|
1552
|
+
}
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
if (this.dualPhase === "rcpt_probe") {
|
|
1556
|
+
this.probeOutcome = classifyRcpt(code);
|
|
1557
|
+
if (this.dualPipelined) {
|
|
1558
|
+
this.dualPhase = "rset";
|
|
1559
|
+
} else {
|
|
1560
|
+
this.send("RSET");
|
|
1561
|
+
this.dualPhase = "rset";
|
|
1562
|
+
}
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
if (this.dualPhase === "rset") {
|
|
1566
|
+
if (this.pendingDecision) {
|
|
1567
|
+
this.finish(this.pendingDecision.result, this.pendingDecision.reason);
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
this.decideDualProbe();
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
/** Final decision after both RCPT outcomes are known. Catch-all only when both 250. */
|
|
1575
|
+
decideDualProbe() {
|
|
1576
|
+
if (this.realOutcome === "accept" && this.probeOutcome === "accept") {
|
|
1577
|
+
this.isCatchAllFlag = true;
|
|
1578
|
+
this.finish(true, "valid");
|
|
1579
|
+
} else if (this.realOutcome === "accept") {
|
|
1580
|
+
this.isCatchAllFlag = false;
|
|
1581
|
+
this.finish(true, "valid");
|
|
1582
|
+
} else if (this.realOutcome === "hard_reject") {
|
|
1583
|
+
this.isCatchAllFlag = false;
|
|
1584
|
+
this.finish(false, "not_found");
|
|
1585
|
+
} else if (this.realOutcome === "soft_reject") {
|
|
1586
|
+
this.finish(null, "temporary_failure");
|
|
1587
|
+
} else {
|
|
1588
|
+
this.finish(null, "ambiguous");
|
|
1334
1589
|
}
|
|
1335
1590
|
}
|
|
1336
1591
|
finish(result, reason) {
|
|
@@ -1349,9 +1604,22 @@ class SMTPProbeConnection {
|
|
|
1349
1604
|
}
|
|
1350
1605
|
const drain = setTimeout(() => this.socket?.destroy(), QUIT_DRAIN_MS);
|
|
1351
1606
|
drain.unref?.();
|
|
1352
|
-
this.resolveFn({
|
|
1607
|
+
this.resolveFn({
|
|
1608
|
+
result,
|
|
1609
|
+
reason,
|
|
1610
|
+
...this.lastEnhancedStatus !== void 0 ? { enhancedStatus: this.lastEnhancedStatus } : {},
|
|
1611
|
+
...this.lastResponseCode !== void 0 ? { responseCode: this.lastResponseCode } : {},
|
|
1612
|
+
...this.isCatchAllFlag !== void 0 ? { isCatchAll: this.isCatchAllFlag } : {},
|
|
1613
|
+
transcript: this.transcript,
|
|
1614
|
+
commands: this.commands
|
|
1615
|
+
});
|
|
1353
1616
|
}
|
|
1354
1617
|
}
|
|
1618
|
+
function classifyRcpt(code) {
|
|
1619
|
+
if (code === 250 || code === 251) return "accept";
|
|
1620
|
+
if (code >= 400 && code < 500) return "soft_reject";
|
|
1621
|
+
return "hard_reject";
|
|
1622
|
+
}
|
|
1355
1623
|
|
|
1356
1624
|
class ArrayTranscriptCollector {
|
|
1357
1625
|
constructor() {
|
|
@@ -2021,12 +2289,11 @@ async function verifyEmail(params) {
|
|
|
2021
2289
|
const skipMx = (params.skipMxForDisposable ?? false) && result.isDisposable;
|
|
2022
2290
|
const skipWhois = (params.skipDomainWhoisForDisposable ?? false) && result.isDisposable;
|
|
2023
2291
|
await runWhoisChecks(domain, params, result, skipWhois, log, collector);
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
}
|
|
2292
|
+
const wantsMxOrSmtp = (params.verifyMx ?? true) || (params.verifySmtp ?? false);
|
|
2293
|
+
if (wantsMxOrSmtp && skipMx) {
|
|
2294
|
+
log(`[verifyEmail] skipping MX/SMTP for disposable: ${params.emailAddress}`);
|
|
2295
|
+
} else if (wantsMxOrSmtp) {
|
|
2296
|
+
await runMxAndSmtp(local, domain, params, result, log, collector);
|
|
2030
2297
|
}
|
|
2031
2298
|
result.metadata.verificationTime = Date.now() - startTime;
|
|
2032
2299
|
if (captureTranscript) result.transcript = collector.steps;
|