@emailcheck/email-validator-js 4.0.0-beta.1 → 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 CHANGED
@@ -1485,6 +1485,41 @@ const parsed = parseSmtpError('552 5.2.2 mailbox over quota');
1485
1485
 
1486
1486
  The four flags are orthogonal — a single message can fire multiple. See `__tests__/0112-smtp-error-parser.test.ts` for the full classification matrix.
1487
1487
 
1488
+ ### Refining a coarse reason via the RFC 3463 enhanced status
1489
+
1490
+ `verifyMailboxSMTP` returns a coarse `error` reason (`not_found` / `over_quota` / `temporary_failure` / `ambiguous` / …) that's stable across MX implementations. When the MX includes an enhanced status code (RFC 3463) — e.g. `5.1.1` for "user unknown" vs. `5.7.1` for "policy block" — pipe both through `refineReasonByEnhancedStatus` to get a more specific reason:
1491
+
1492
+ ```typescript
1493
+ import { refineReasonByEnhancedStatus, verifyMailboxSMTP } from '@emailcheck/email-validator-js';
1494
+
1495
+ const { smtpResult } = await verifyMailboxSMTP({
1496
+ local: 'alice', domain: 'example.com', mxRecords: ['mx.example.com'],
1497
+ });
1498
+ const refined = refineReasonByEnhancedStatus(smtpResult.error, smtpResult.enhancedStatus);
1499
+ // e.g. 'mailbox_does_not_exist' instead of 'not_found' when the MX returned 550 5.1.1
1500
+ ```
1501
+
1502
+ Mapping (codes not in the table return the original reason unchanged):
1503
+
1504
+ | DSN code | Refined reason |
1505
+ | --------- | ------------------------------------ |
1506
+ | `5.1.1` | `mailbox_does_not_exist` |
1507
+ | `5.1.2` | `bad_destination_system` |
1508
+ | `5.1.3` | `bad_destination_address` |
1509
+ | `5.1.6` | `mailbox_moved` |
1510
+ | `5.1.10` | `recipient_address_has_null_mx` |
1511
+ | `5.2.0` | `mailbox_status_other` |
1512
+ | `5.2.1` | `mailbox_disabled` |
1513
+ | `5.2.2` | `mailbox_full` |
1514
+ | `5.2.3` | `message_too_long` |
1515
+ | `5.2.4` | `mailing_list_expansion_problem` |
1516
+ | `4.4.1` | `no_answer_from_host` |
1517
+ | `4.4.2` | `bad_connection` |
1518
+ | `5.7.0` | `security_other` |
1519
+ | `5.7.1` | `delivery_not_authorized` |
1520
+ | `5.7.25` | `no_reverse_dns` |
1521
+ | `5.7.26` | `multiple_authentication_failures` |
1522
+
1488
1523
  ## 📊 Performance & Caching
1489
1524
 
1490
1525
  The library includes intelligent caching to improve performance:
package/dist/cli/index.js CHANGED
@@ -318,6 +318,7 @@ var SMTPStep = /* @__PURE__ */ ((SMTPStep2) => {
318
318
  SMTPStep2["greeting"] = "GREETING";
319
319
  SMTPStep2["ehlo"] = "EHLO";
320
320
  SMTPStep2["helo"] = "HELO";
321
+ SMTPStep2["startTls"] = "STARTTLS";
321
322
  SMTPStep2["mailFrom"] = "MAIL_FROM";
322
323
  SMTPStep2["rcptTo"] = "RCPT_TO";
323
324
  return SMTPStep2;
@@ -1081,7 +1082,8 @@ async function verifyMailboxSMTP(params) {
1081
1082
  sequence,
1082
1083
  log,
1083
1084
  catchAllProbeLocal: options.catchAllProbeLocal,
1084
- pipelining: options.pipelining ?? "auto"
1085
+ pipelining: options.pipelining ?? "auto",
1086
+ startTls: options.startTls ?? "auto"
1085
1087
  };
1086
1088
  const verdictCache = cache ? getCacheStore(cache, "smtp") : null;
1087
1089
  const verdictKey = `${primaryMx}:${local}@${domain}`;
@@ -1204,10 +1206,21 @@ class SMTPProbeConnection {
1204
1206
  this.buffer = "";
1205
1207
  this.resolved = false;
1206
1208
  this.currentStepIndex = 0;
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;
1207
1219
  this.transcript = [];
1208
1220
  this.commands = [];
1209
1221
  // ── EHLO capability advertisement ────────────────────────────────────────
1210
1222
  this.supportsPipelining = false;
1223
+ this.supportsStartTls = false;
1211
1224
  this.dualPhase = "idle";
1212
1225
  this.realOutcome = "pending";
1213
1226
  this.probeOutcome = "pending";
@@ -1229,7 +1242,7 @@ class SMTPProbeConnection {
1229
1242
  this.processLine(line);
1230
1243
  }
1231
1244
  };
1232
- const defaultSteps = [SMTPStep.greeting, SMTPStep.ehlo, SMTPStep.mailFrom, SMTPStep.rcptTo];
1245
+ const defaultSteps = [SMTPStep.greeting, SMTPStep.ehlo, SMTPStep.startTls, SMTPStep.mailFrom, SMTPStep.rcptTo];
1233
1246
  this.steps = [...p.sequence?.steps ?? defaultSteps];
1234
1247
  this.isTLS = PORT_TLS[p.port] === true;
1235
1248
  const servername = isIPAddress(p.mxHost) ? void 0 : p.mxHost;
@@ -1249,7 +1262,8 @@ class SMTPProbeConnection {
1249
1262
  this.connect();
1250
1263
  this.armConnectionTimer();
1251
1264
  } catch (error) {
1252
- 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");
1253
1267
  }
1254
1268
  });
1255
1269
  }
@@ -1302,6 +1316,9 @@ class SMTPProbeConnection {
1302
1316
  case SMTPStep.helo:
1303
1317
  this.send(`HELO ${this.p.hostname}`);
1304
1318
  return;
1319
+ case SMTPStep.startTls:
1320
+ this.executeStartTls();
1321
+ return;
1305
1322
  case SMTPStep.mailFrom: {
1306
1323
  const from = this.p.sequence?.from ?? `<${this.p.local}@${this.p.domain}>`;
1307
1324
  this.send(`MAIL FROM:${from}`);
@@ -1312,6 +1329,73 @@ class SMTPProbeConnection {
1312
1329
  return;
1313
1330
  }
1314
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
+ }
1315
1399
  /**
1316
1400
  * Send the dual-probe envelope (real RCPT + probe RCPT + RSET). Pipelined
1317
1401
  * when the MX advertised PIPELINING (or `pipelining: 'force'`); sequential
@@ -1366,9 +1450,11 @@ ${rsetCmd}\r
1366
1450
  }
1367
1451
  if (MULTILINE_RE.test(line)) {
1368
1452
  const step = this.steps[this.currentStepIndex];
1369
- if ((step === SMTPStep.ehlo || step === SMTPStep.helo) && line.startsWith("250-")) {
1453
+ const isEhloLike = step === SMTPStep.ehlo || step === SMTPStep.helo || step === SMTPStep.startTls && this.postUpgradeReEhlo;
1454
+ if (isEhloLike && line.startsWith("250-")) {
1370
1455
  const upper = line.toUpperCase();
1371
1456
  if (upper.includes("PIPELINING")) this.supportsPipelining = true;
1457
+ if (upper.includes("STARTTLS")) this.supportsStartTls = true;
1372
1458
  }
1373
1459
  return;
1374
1460
  }
@@ -1397,6 +1483,19 @@ ${rsetCmd}\r
1397
1483
  if (code === 250) this.nextStep();
1398
1484
  else this.finish(null, "helo_failed");
1399
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;
1400
1499
  case SMTPStep.mailFrom:
1401
1500
  if (code === 250) this.nextStep();
1402
1501
  else this.finish(null, "mail_from_rejected");