@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/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 baseMatch2 = part.match(/^([a-zA-Z]+)\d*$/);
769
- if (baseMatch2) {
770
- cleaned = baseMatch2[1];
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
- const baseMatch = part.match(/^([a-zA-Z]+[a-zA-Z0-9]*?)\d*$/);
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 mxHost = mxRecords[0];
1071
- if (!mxHost) {
1066
+ const startedAtMs = Date.now();
1067
+ const primaryMx = mxRecords[0];
1068
+ if (!primaryMx) {
1072
1069
  log("No MX records found");
1073
- return { smtpResult: failureResult("No MX records found"), cached: false, port: 0, portCached: false };
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 ${mxHost}`);
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 = `${mxHost}:${local}@${domain}`;
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
- if (portCache) {
1089
- const cachedPort = await safeCacheGet(portCache, mxHost);
1090
- if (cachedPort) {
1091
- log(`Using cached port: ${cachedPort}`);
1092
- const probe = await runProbe({
1093
- mxHost,
1094
- port: cachedPort,
1095
- local,
1096
- domain,
1097
- timeout,
1098
- tlsConfig,
1099
- hostname,
1100
- sequence,
1101
- log
1102
- });
1103
- collectTranscript(transcript, commands, probe, cachedPort);
1104
- const smtpResult = toSmtpVerificationResult(
1105
- probe.result,
1106
- captureTranscript ? { transcript, commands } : void 0
1107
- );
1108
- await safeCacheSet(verdictCache, verdictKey, smtpResult);
1109
- return { smtpResult, cached: false, port: cachedPort, portCached: true };
1110
- }
1111
- }
1112
- for (const port of ports) {
1113
- log(`Testing port ${port}`);
1114
- const probe = await runProbe({ mxHost, port, local, domain, timeout, tlsConfig, hostname, sequence, log });
1115
- collectTranscript(transcript, commands, probe, port);
1116
- const smtpResult = toSmtpVerificationResult(probe.result, captureTranscript ? { transcript, commands } : void 0);
1117
- await safeCacheSet(verdictCache, verdictKey, smtpResult);
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("All ports failed");
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
- smtpResult: {
1126
- ...failureResult("All SMTP connection attempts failed"),
1127
- ...captureTranscript ? { transcript: [...transcript], commands: [...commands] } : {}
1128
- },
1129
- cached: false,
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
- for (const line of probe.transcript) transcript.push(`${port}|s| ${line}`);
1136
- for (const cmd of probe.commands) commands.push(`${port}|c| ${cmd}`);
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(error) {
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
- providerUsed: EmailProvider.everythingElse,
1147
- checkedAt: Date.now()
1161
+ error: reason,
1162
+ checkedAt: Date.now(),
1163
+ metrics
1148
1164
  };
1149
1165
  }
1150
- function toSmtpVerificationResult(result, capture) {
1151
- const base = {
1166
+ function toSmtpVerificationResult(probe, extras) {
1167
+ const result = probe.result;
1168
+ const out = {
1152
1169
  canConnectSmtp: result !== null,
1153
- hasFullInbox: false,
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 : result === null ? "ambiguous" : "not_found",
1158
- providerUsed: EmailProvider.everythingElse,
1159
- checkedAt: Date.now()
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 (!capture) return base;
1162
- return { ...base, transcript: [...capture.transcript], commands: [...capture.commands] };
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
- /** Server lines + client commands captured unconditionally (cost is trivial). */
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.finish(null, `connect_throw:${error instanceof Error ? error.message : "unknown"}`);
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.send(`RCPT TO:<${this.p.local}@${this.p.domain}>`);
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
- if (isHighVolume(line)) {
1289
- this.finish(true, "high_volume");
1290
- return;
1291
- }
1292
- if (isOverQuota(line)) {
1293
- this.finish(false, "over_quota");
1294
- return;
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 (isInvalidMailboxError(line)) {
1297
- this.finish(false, "not_found");
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
- if (code === 250 || code === 251) this.finish(true, "valid");
1330
- else if (code === 552 || code === 452) this.finish(false, "over_quota");
1331
- else if (code >= 400 && code < 500) this.finish(null, "temporary_failure");
1332
- else this.finish(null, "ambiguous");
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({ result, transcript: this.transcript, commands: this.commands });
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
- if ((params.verifyMx ?? true) || (params.verifySmtp ?? false)) {
2025
- if (skipMx) {
2026
- log(`[verifyEmail] skipping MX/SMTP for disposable: ${params.emailAddress}`);
2027
- } else {
2028
- await runMxAndSmtp(local, domain, params, result, log, collector);
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;