@emailcheck/email-validator-js 3.4.4 → 4.0.0-beta.1

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 CHANGED
@@ -91,7 +91,7 @@ pnpm add @emailcheck/email-validator-js
91
91
  ```
92
92
 
93
93
  ### Requirements (consumers)
94
- - Node.js >= 18 (runtime target the published bundle is plain Node.js + ESM/CJS)
94
+ - Node.js >= 22 (currently-supported LTS lines: 22 Maintenance, 24 Active)
95
95
  - TypeScript >= 4.0 (for TypeScript users)
96
96
 
97
97
  ### Requirements (contributing)
@@ -864,7 +864,7 @@ bun run examples/high-level/advanced-usage.ts
864
864
  bun run examples/integrations/algolia.ts
865
865
  ```
866
866
 
867
- **After installation in your own project (Node 20.10+):**
867
+ **After installation in your own project (Node 22+):**
868
868
 
869
869
  ```bash
870
870
  node --experimental-strip-types examples/smtp/usage.ts
@@ -1386,7 +1386,7 @@ app.http('validateEmail', {
1386
1386
 
1387
1387
  ### Edge MX support via the built-in DoH resolver
1388
1388
 
1389
- A built-in `DoHResolver` ships with the package — works in any runtime with `fetch` (Cloudflare Workers, Vercel Edge, Deno, browsers, Node 18+):
1389
+ A built-in `DoHResolver` ships with the package — works in any runtime with `fetch` (Cloudflare Workers, Vercel Edge, Deno, browsers, Node 22+):
1390
1390
 
1391
1391
  ```typescript
1392
1392
  import {
@@ -5,9 +5,11 @@
5
5
  import type { DisposableEmailResult, DomainValidResult, FreeEmailResult, SmtpVerificationResult } from './types';
6
6
  import type { ParsedWhoisResult } from './whois-parser';
7
7
  /**
8
- * Generic cache interface that can be implemented by any cache store
8
+ * Generic cache interface that can be implemented by any cache store.
9
+ * Default type parameter is `unknown` — callers should narrow it
10
+ * (`CacheStore<DisposableEmailResult>`) rather than relying on the default.
9
11
  */
10
- export interface CacheStore<T = any> {
12
+ export interface CacheStore<T = unknown> {
11
13
  /**
12
14
  * Get a value from cache
13
15
  * @param key - The cache key
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,16 +314,6 @@ 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";
@@ -765,9 +756,9 @@ function parseCompositeNamePart(part) {
765
756
  cleaned = pureAlpha;
766
757
  confidence = 0.6;
767
758
  } else {
768
- const baseMatch2 = part.match(/^([a-zA-Z]+)\d*$/);
769
- if (baseMatch2) {
770
- cleaned = baseMatch2[1];
759
+ const baseMatch = part.match(/^([a-zA-Z]+)\d*$/);
760
+ if (baseMatch) {
761
+ cleaned = baseMatch[1];
771
762
  confidence = 0.75;
772
763
  } else {
773
764
  cleaned = part;
@@ -781,9 +772,7 @@ function parseCompositeNamePart(part) {
781
772
  confidence = Math.min(1, confidence + 0.2);
782
773
  }
783
774
  }
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 };
775
+ return { hasNumbers, cleaned, confidence };
787
776
  }
788
777
  function isLikelyName(str, allowNumbers = false, allowSingleLetter = false) {
789
778
  if (!str) return false;
@@ -1037,6 +1026,9 @@ function parseDsn(reply) {
1037
1026
  if (!match) return null;
1038
1027
  return { class: Number(match[1]), subject: Number(match[2]), detail: Number(match[3]) };
1039
1028
  }
1029
+ function dsnToString(dsn) {
1030
+ return `${dsn.class}.${dsn.subject}.${dsn.detail}`;
1031
+ }
1040
1032
  function isPolicyBlock(reply) {
1041
1033
  const dsn = parseDsn(reply);
1042
1034
  return dsn?.class === 5 && dsn?.subject === 7;
@@ -1054,6 +1046,9 @@ function isInvalidMailboxError(reply) {
1054
1046
  if (isPolicyBlock(reply)) return false;
1055
1047
  return true;
1056
1048
  }
1049
+ function defaultProbeLocal() {
1050
+ return `${node_crypto.randomBytes(8).toString("hex")}-noexist`;
1051
+ }
1057
1052
  async function verifyMailboxSMTP(params) {
1058
1053
  const { local, domain, options = {} } = params;
1059
1054
  const mxRecords = params.mxRecords ?? [];
@@ -1067,16 +1062,29 @@ async function verifyMailboxSMTP(params) {
1067
1062
  const cache = options.cache;
1068
1063
  const log = debug ? (...args) => console.log("[SMTP]", ...args) : () => {
1069
1064
  };
1070
- const mxHost = mxRecords[0];
1071
- if (!mxHost) {
1065
+ const startedAtMs = Date.now();
1066
+ const primaryMx = mxRecords[0];
1067
+ if (!primaryMx) {
1072
1068
  log("No MX records found");
1073
- return { smtpResult: failureResult("No MX records found"), cached: false, port: 0, portCached: false };
1069
+ const metrics2 = makeMetrics([], 0, 0, void 0, startedAtMs);
1070
+ return { smtpResult: failureResult("no_mx_records", metrics2), cached: false, port: 0, portCached: false };
1074
1071
  }
1075
- log(`Verifying ${local}@${domain} via ${mxHost}`);
1072
+ log(`Verifying ${local}@${domain} via ${primaryMx} (mx count=${mxRecords.length})`);
1076
1073
  const transcript = [];
1077
1074
  const commands = [];
1075
+ const probeOptions = {
1076
+ local,
1077
+ domain,
1078
+ timeout,
1079
+ tlsConfig,
1080
+ hostname,
1081
+ sequence,
1082
+ log,
1083
+ catchAllProbeLocal: options.catchAllProbeLocal,
1084
+ pipelining: options.pipelining ?? "auto"
1085
+ };
1078
1086
  const verdictCache = cache ? getCacheStore(cache, "smtp") : null;
1079
- const verdictKey = `${mxHost}:${local}@${domain}`;
1087
+ const verdictKey = `${primaryMx}:${local}@${domain}`;
1080
1088
  if (verdictCache) {
1081
1089
  const cachedResult = await safeCacheGet(verdictCache, verdictKey);
1082
1090
  if (cachedResult) {
@@ -1085,81 +1093,91 @@ async function verifyMailboxSMTP(params) {
1085
1093
  }
1086
1094
  }
1087
1095
  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 };
1096
+ const cachedPort = portCache ? await safeCacheGet(portCache, primaryMx) : null;
1097
+ const mxHostsTried = [];
1098
+ let mxAttempts = 0;
1099
+ let portAttempts = 0;
1100
+ let lastReason = "all_attempts_failed";
1101
+ let lastEnhancedStatus;
1102
+ let lastResponseCode;
1103
+ for (const mxHost of mxRecords) {
1104
+ mxHostsTried.push(mxHost);
1105
+ mxAttempts++;
1106
+ const portsForThisMx = mxHost === primaryMx && cachedPort ? [cachedPort, ...ports.filter((p) => p !== cachedPort)] : ports;
1107
+ for (const port of portsForThisMx) {
1108
+ portAttempts++;
1109
+ log(`Testing ${mxHost}:${port}`);
1110
+ const probe = await runProbe({ ...probeOptions, mxHost, port });
1111
+ collectTranscript(transcript, commands, probe, mxHost, port);
1112
+ lastReason = probe.reason;
1113
+ if (probe.enhancedStatus !== void 0) lastEnhancedStatus = probe.enhancedStatus;
1114
+ if (probe.responseCode !== void 0) lastResponseCode = probe.responseCode;
1115
+ if (probe.result !== null) {
1116
+ const metrics2 = makeMetrics(mxHostsTried, mxAttempts, portAttempts, mxHost, startedAtMs);
1117
+ const smtpResult2 = toSmtpVerificationResult(probe, {
1118
+ transcript: captureTranscript ? transcript : void 0,
1119
+ commands: captureTranscript ? commands : void 0,
1120
+ metrics: metrics2
1121
+ });
1122
+ await safeCacheSet(verdictCache, verdictKey, smtpResult2);
1123
+ if (mxHost === primaryMx) await safeCacheSet(portCache, primaryMx, port);
1124
+ return { smtpResult: smtpResult2, cached: false, port, portCached: cachedPort === port };
1125
+ }
1121
1126
  }
1122
1127
  }
1123
- log("All ports failed");
1128
+ log(`All MX\xD7port attempts failed (mx=${mxAttempts}, port=${portAttempts})`);
1129
+ const metrics = makeMetrics(mxHostsTried, mxAttempts, portAttempts, void 0, startedAtMs);
1130
+ const smtpResult = {
1131
+ ...failureResult(lastReason, metrics),
1132
+ ...lastEnhancedStatus !== void 0 ? { enhancedStatus: lastEnhancedStatus } : {},
1133
+ ...lastResponseCode !== void 0 ? { responseCode: lastResponseCode } : {},
1134
+ ...captureTranscript ? { transcript: [...transcript], commands: [...commands] } : {}
1135
+ };
1136
+ return { smtpResult, cached: false, port: 0, portCached: false };
1137
+ }
1138
+ function makeMetrics(mxHostsTried, mxAttempts, portAttempts, mxHostUsed, startedAtMs) {
1124
1139
  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
1140
+ mxAttempts,
1141
+ portAttempts,
1142
+ mxHostsTried: [...mxHostsTried],
1143
+ ...mxHostUsed !== void 0 ? { mxHostUsed } : {},
1144
+ totalDurationMs: Date.now() - startedAtMs
1132
1145
  };
1133
1146
  }
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}`);
1147
+ function collectTranscript(transcript, commands, probe, mxHost, port) {
1148
+ const prefix = `${mxHost}:${port}`;
1149
+ for (const line of probe.transcript) transcript.push(`${prefix}|s| ${line}`);
1150
+ for (const cmd of probe.commands) commands.push(`${prefix}|c| ${cmd}`);
1137
1151
  }
1138
- function failureResult(error) {
1152
+ function failureResult(reason, metrics) {
1139
1153
  return {
1140
1154
  canConnectSmtp: false,
1141
1155
  hasFullInbox: false,
1142
1156
  isCatchAll: false,
1143
1157
  isDeliverable: false,
1144
1158
  isDisabled: false,
1145
- error,
1146
- providerUsed: EmailProvider.everythingElse,
1147
- checkedAt: Date.now()
1159
+ error: reason,
1160
+ checkedAt: Date.now(),
1161
+ metrics
1148
1162
  };
1149
1163
  }
1150
- function toSmtpVerificationResult(result, capture) {
1151
- const base = {
1164
+ function toSmtpVerificationResult(probe, extras) {
1165
+ const result = probe.result;
1166
+ const out = {
1152
1167
  canConnectSmtp: result !== null,
1153
- hasFullInbox: false,
1154
- isCatchAll: false,
1168
+ hasFullInbox: probe.reason === "over_quota",
1169
+ isCatchAll: probe.isCatchAll ?? false,
1155
1170
  isDeliverable: result === true,
1156
1171
  isDisabled: result === false,
1157
- error: result === true ? void 0 : result === null ? "ambiguous" : "not_found",
1158
- providerUsed: EmailProvider.everythingElse,
1159
- checkedAt: Date.now()
1172
+ error: result === true ? void 0 : probe.reason,
1173
+ checkedAt: Date.now(),
1174
+ metrics: extras.metrics,
1175
+ ...probe.enhancedStatus !== void 0 ? { enhancedStatus: probe.enhancedStatus } : {},
1176
+ ...probe.responseCode !== void 0 ? { responseCode: probe.responseCode } : {}
1160
1177
  };
1161
- if (!capture) return base;
1162
- return { ...base, transcript: [...capture.transcript], commands: [...capture.commands] };
1178
+ if (extras.transcript) out.transcript = [...extras.transcript];
1179
+ if (extras.commands) out.commands = [...extras.commands];
1180
+ return out;
1163
1181
  }
1164
1182
  async function safeCacheGet(store, key) {
1165
1183
  if (!store) return null;
@@ -1186,9 +1204,20 @@ class SMTPProbeConnection {
1186
1204
  this.buffer = "";
1187
1205
  this.resolved = false;
1188
1206
  this.currentStepIndex = 0;
1189
- /** Server lines + client commands captured unconditionally (cost is trivial). */
1190
1207
  this.transcript = [];
1191
1208
  this.commands = [];
1209
+ // ── EHLO capability advertisement ────────────────────────────────────────
1210
+ this.supportsPipelining = false;
1211
+ this.dualPhase = "idle";
1212
+ this.realOutcome = "pending";
1213
+ this.probeOutcome = "pending";
1214
+ this.dualPipelined = false;
1215
+ /**
1216
+ * Pipelined-only escape hatch. When the real RCPT is rejected mid-batched-
1217
+ * envelope, the probe + RSET are already on the wire; we stash the verdict
1218
+ * and commit it after the response loop drains.
1219
+ */
1220
+ this.pendingDecision = null;
1192
1221
  this.onData = (data) => {
1193
1222
  if (this.resolved) return;
1194
1223
  this.resetStepTimer();
@@ -1211,6 +1240,7 @@ class SMTPProbeConnection {
1211
1240
  minVersion: "TLSv1.2",
1212
1241
  ...typeof p.tlsConfig === "object" ? p.tlsConfig : {}
1213
1242
  };
1243
+ this.probeLocal = p.catchAllProbeLocal ? p.catchAllProbeLocal(p.local, p.domain) : defaultProbeLocal();
1214
1244
  }
1215
1245
  run() {
1216
1246
  return new Promise((resolve) => {
@@ -1265,6 +1295,7 @@ class SMTPProbeConnection {
1265
1295
  switch (step) {
1266
1296
  case SMTPStep.greeting:
1267
1297
  return;
1298
+ // server-driven; nothing to send
1268
1299
  case SMTPStep.ehlo:
1269
1300
  this.send(`EHLO ${this.p.hostname}`);
1270
1301
  return;
@@ -1277,37 +1308,82 @@ class SMTPProbeConnection {
1277
1308
  return;
1278
1309
  }
1279
1310
  case SMTPStep.rcptTo:
1280
- this.send(`RCPT TO:<${this.p.local}@${this.p.domain}>`);
1311
+ this.executeEnvelope();
1281
1312
  return;
1282
1313
  }
1283
1314
  }
1315
+ /**
1316
+ * Send the dual-probe envelope (real RCPT + probe RCPT + RSET). Pipelined
1317
+ * when the MX advertised PIPELINING (or `pipelining: 'force'`); sequential
1318
+ * otherwise.
1319
+ */
1320
+ executeEnvelope() {
1321
+ const wantsPipelining = this.p.pipelining === "force" || this.p.pipelining === "auto" && this.supportsPipelining;
1322
+ const realCmd = `RCPT TO:<${this.p.local}@${this.p.domain}>`;
1323
+ if (wantsPipelining) {
1324
+ this.dualPipelined = true;
1325
+ const probeCmd = `RCPT TO:<${this.probeLocal}@${this.p.domain}>`;
1326
+ const rsetCmd = "RSET";
1327
+ this.commands.push(realCmd, probeCmd, rsetCmd);
1328
+ this.p.log(`\u2192 ${realCmd}`);
1329
+ this.p.log(`\u2192 ${probeCmd}`);
1330
+ this.p.log(`\u2192 ${rsetCmd}`);
1331
+ this.socket?.write(`${realCmd}\r
1332
+ ${probeCmd}\r
1333
+ ${rsetCmd}\r
1334
+ `);
1335
+ this.dualPhase = "rcpt_real";
1336
+ return;
1337
+ }
1338
+ this.dualPipelined = false;
1339
+ this.send(realCmd);
1340
+ this.dualPhase = "rcpt_real";
1341
+ }
1284
1342
  processLine(line) {
1285
1343
  if (this.resolved) return;
1286
1344
  this.transcript.push(line);
1287
1345
  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;
1346
+ const codeStr = line.slice(0, 3);
1347
+ const numericCode = /^\d{3}$/.test(codeStr) ? parseInt(codeStr, 10) : null;
1348
+ if (numericCode !== null) this.lastResponseCode = numericCode;
1349
+ const dsn = parseDsn(line);
1350
+ if (dsn) this.lastEnhancedStatus = dsnToString(dsn);
1351
+ if (this.dualPhase === "idle" || this.dualPhase === "rcpt_real") {
1352
+ if (isHighVolume(line)) {
1353
+ this.finish(true, "high_volume");
1354
+ return;
1355
+ }
1356
+ if (isOverQuota(line)) {
1357
+ this.isCatchAllFlag = false;
1358
+ this.finish(false, "over_quota");
1359
+ return;
1360
+ }
1361
+ if (isInvalidMailboxError(line)) {
1362
+ this.isCatchAllFlag = false;
1363
+ this.finish(false, "not_found");
1364
+ return;
1365
+ }
1295
1366
  }
1296
- if (isInvalidMailboxError(line)) {
1297
- this.finish(false, "not_found");
1367
+ if (MULTILINE_RE.test(line)) {
1368
+ const step = this.steps[this.currentStepIndex];
1369
+ if ((step === SMTPStep.ehlo || step === SMTPStep.helo) && line.startsWith("250-")) {
1370
+ const upper = line.toUpperCase();
1371
+ if (upper.includes("PIPELINING")) this.supportsPipelining = true;
1372
+ }
1298
1373
  return;
1299
1374
  }
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
1375
  if (numericCode === null) {
1304
1376
  this.finish(null, "unrecognized_response");
1305
1377
  return;
1306
1378
  }
1307
- this.dispatch(numericCode);
1379
+ this.dispatch(numericCode, line);
1308
1380
  }
1309
- dispatch(code) {
1381
+ dispatch(code, line) {
1310
1382
  const step = this.steps[this.currentStepIndex];
1383
+ if (this.dualPhase !== "idle" && step === SMTPStep.rcptTo) {
1384
+ this.handleEnvelopeReply(code, line);
1385
+ return;
1386
+ }
1311
1387
  switch (step) {
1312
1388
  case SMTPStep.greeting:
1313
1389
  if (code === 220) this.nextStep();
@@ -1326,11 +1402,91 @@ class SMTPProbeConnection {
1326
1402
  else this.finish(null, "mail_from_rejected");
1327
1403
  return;
1328
1404
  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");
1405
+ this.handleEnvelopeReply(code, line);
1406
+ return;
1407
+ }
1408
+ }
1409
+ /**
1410
+ * Dual-probe / pipelined-envelope reply router. Demuxes server replies for
1411
+ * the three queued commands (real RCPT, probe RCPT, RSET) and resolves
1412
+ * with the catch-all-aware verdict.
1413
+ */
1414
+ handleEnvelopeReply(code, line) {
1415
+ if (this.dualPhase === "rcpt_real") {
1416
+ this.realOutcome = classifyRcpt(code);
1417
+ if (code === 552 || code === 452 || isOverQuota(line)) {
1418
+ this.isCatchAllFlag = false;
1419
+ if (this.dualPipelined) {
1420
+ this.pendingDecision = { result: false, reason: "over_quota" };
1421
+ this.dualPhase = "rcpt_probe";
1422
+ return;
1423
+ }
1424
+ this.finish(false, "over_quota");
1425
+ return;
1426
+ }
1427
+ if (this.realOutcome === "soft_reject") {
1428
+ if (this.dualPipelined) {
1429
+ this.pendingDecision = { result: null, reason: "temporary_failure" };
1430
+ this.dualPhase = "rcpt_probe";
1431
+ return;
1432
+ }
1433
+ this.finish(null, "temporary_failure");
1434
+ return;
1435
+ }
1436
+ if (this.realOutcome === "hard_reject") {
1437
+ const reason = isInvalidMailboxError(line) ? "not_found" : "ambiguous";
1438
+ const result = reason === "not_found" ? false : null;
1439
+ this.isCatchAllFlag = false;
1440
+ if (this.dualPipelined) {
1441
+ this.pendingDecision = { result, reason };
1442
+ this.dualPhase = "rcpt_probe";
1443
+ return;
1444
+ }
1445
+ this.finish(result, reason);
1446
+ return;
1447
+ }
1448
+ if (this.dualPipelined) {
1449
+ this.dualPhase = "rcpt_probe";
1450
+ } else {
1451
+ this.send(`RCPT TO:<${this.probeLocal}@${this.p.domain}>`);
1452
+ this.dualPhase = "rcpt_probe";
1453
+ }
1454
+ return;
1455
+ }
1456
+ if (this.dualPhase === "rcpt_probe") {
1457
+ this.probeOutcome = classifyRcpt(code);
1458
+ if (this.dualPipelined) {
1459
+ this.dualPhase = "rset";
1460
+ } else {
1461
+ this.send("RSET");
1462
+ this.dualPhase = "rset";
1463
+ }
1464
+ return;
1465
+ }
1466
+ if (this.dualPhase === "rset") {
1467
+ if (this.pendingDecision) {
1468
+ this.finish(this.pendingDecision.result, this.pendingDecision.reason);
1333
1469
  return;
1470
+ }
1471
+ this.decideDualProbe();
1472
+ return;
1473
+ }
1474
+ }
1475
+ /** Final decision after both RCPT outcomes are known. Catch-all only when both 250. */
1476
+ decideDualProbe() {
1477
+ if (this.realOutcome === "accept" && this.probeOutcome === "accept") {
1478
+ this.isCatchAllFlag = true;
1479
+ this.finish(true, "valid");
1480
+ } else if (this.realOutcome === "accept") {
1481
+ this.isCatchAllFlag = false;
1482
+ this.finish(true, "valid");
1483
+ } else if (this.realOutcome === "hard_reject") {
1484
+ this.isCatchAllFlag = false;
1485
+ this.finish(false, "not_found");
1486
+ } else if (this.realOutcome === "soft_reject") {
1487
+ this.finish(null, "temporary_failure");
1488
+ } else {
1489
+ this.finish(null, "ambiguous");
1334
1490
  }
1335
1491
  }
1336
1492
  finish(result, reason) {
@@ -1349,9 +1505,22 @@ class SMTPProbeConnection {
1349
1505
  }
1350
1506
  const drain = setTimeout(() => this.socket?.destroy(), QUIT_DRAIN_MS);
1351
1507
  drain.unref?.();
1352
- this.resolveFn({ result, transcript: this.transcript, commands: this.commands });
1508
+ this.resolveFn({
1509
+ result,
1510
+ reason,
1511
+ ...this.lastEnhancedStatus !== void 0 ? { enhancedStatus: this.lastEnhancedStatus } : {},
1512
+ ...this.lastResponseCode !== void 0 ? { responseCode: this.lastResponseCode } : {},
1513
+ ...this.isCatchAllFlag !== void 0 ? { isCatchAll: this.isCatchAllFlag } : {},
1514
+ transcript: this.transcript,
1515
+ commands: this.commands
1516
+ });
1353
1517
  }
1354
1518
  }
1519
+ function classifyRcpt(code) {
1520
+ if (code === 250 || code === 251) return "accept";
1521
+ if (code >= 400 && code < 500) return "soft_reject";
1522
+ return "hard_reject";
1523
+ }
1355
1524
 
1356
1525
  class ArrayTranscriptCollector {
1357
1526
  constructor() {
@@ -2021,12 +2190,11 @@ async function verifyEmail(params) {
2021
2190
  const skipMx = (params.skipMxForDisposable ?? false) && result.isDisposable;
2022
2191
  const skipWhois = (params.skipDomainWhoisForDisposable ?? false) && result.isDisposable;
2023
2192
  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
- }
2193
+ const wantsMxOrSmtp = (params.verifyMx ?? true) || (params.verifySmtp ?? false);
2194
+ if (wantsMxOrSmtp && skipMx) {
2195
+ log(`[verifyEmail] skipping MX/SMTP for disposable: ${params.emailAddress}`);
2196
+ } else if (wantsMxOrSmtp) {
2197
+ await runMxAndSmtp(local, domain, params, result, log, collector);
2030
2198
  }
2031
2199
  result.metadata.verificationTime = Date.now() - startTime;
2032
2200
  if (captureTranscript) result.transcript = collector.steps;