@upyo/smtp 0.6.0-dev.265 → 0.6.0-dev.269

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
@@ -60,6 +60,7 @@ const transport = new SmtpTransport({
60
60
  host: "smtp.example.com",
61
61
  port: 587,
62
62
  secure: false,
63
+ requireTls: true,
63
64
  auth: {
64
65
  user: "username",
65
66
  pass: "password",
@@ -106,6 +107,7 @@ Configuration options
106
107
  | `host` | `string` | | SMTP server hostname |
107
108
  | `port` | `number` | `587` | SMTP server port |
108
109
  | `secure` | `boolean` | `true` | Use TLS/SSL connection |
110
+ | `requireTls` | `boolean` | `false` | Require a STARTTLS upgrade |
109
111
  | `auth` | `SmtpAuth` | | Authentication configuration |
110
112
  | `tls` | `SmtpTlsOptions` | | TLS configuration |
111
113
  | `connectionTimeout` | `number` | `60000` | Connection timeout (ms) |
@@ -115,6 +117,12 @@ Configuration options
115
117
  | `poolSize` | `number` | `5` | Maximum pool connections |
116
118
  | `dkim` | `DkimConfig` | | DKIM signing configuration |
117
119
 
120
+ Set `requireTls: true` with `secure: false` to issue `STARTTLS` even when the
121
+ server does not advertise it and fail delivery unless the upgrade succeeds.
122
+ Regardless of this option, SMTP authentication to non-loopback hosts requires
123
+ either an implicit TLS connection or a successful STARTTLS upgrade. Cleartext
124
+ authentication to loopback hosts remains available for local development.
125
+
118
126
  ### `SmtpAuth`
119
127
 
120
128
  `SmtpAuth` is a discriminated union of three strategies.
package/dist/index.cjs CHANGED
@@ -43,6 +43,7 @@ function createSmtpConfig(config) {
43
43
  host: config.host,
44
44
  port: config.port ?? 587,
45
45
  secure: config.secure ?? true,
46
+ requireTls: config.requireTls ?? false,
46
47
  auth: config.auth,
47
48
  tls: config.tls,
48
49
  connectionTimeout: config.connectionTimeout ?? 6e4,
@@ -374,14 +375,92 @@ const CRLF_LENGTH = 2;
374
375
  */
375
376
  const QUIT_TIMEOUT_MS = 5e3;
376
377
  /**
377
- * Whether a host refers to the local loopback interface, for which cleartext
378
- * OAuth 2.0 authentication is permitted (e.g. local testing and development).
378
+ * Parse an IPv4 address into its four octets.
379
379
  *
380
- * @param host The host to check.
381
- * @returns `true` if the host is a loopback address.
380
+ * @param address The IPv4 address to parse.
381
+ * @returns The parsed octets, or `null` if the address is invalid.
382
+ */
383
+ function parseIpv4Address(address) {
384
+ const octets = address.split(".");
385
+ if (octets.length !== 4 || !octets.every((octet) => /^(?:0|[1-9]\d{0,2})$/.test(octet) && Number(octet) <= 255)) return null;
386
+ return octets.map(Number);
387
+ }
388
+ /**
389
+ * Parse an IPv6 address into its eight 16-bit groups.
390
+ *
391
+ * @param address The IPv6 address to parse.
392
+ * @returns The parsed groups, or `null` if the address is invalid.
393
+ */
394
+ function parseIpv6Address(address) {
395
+ const compressionParts = address.split("::");
396
+ if (compressionParts.length > 2) return null;
397
+ function parseGroups(part) {
398
+ if (part === "") return [];
399
+ const tokens = part.split(":");
400
+ const groups = [];
401
+ for (const [index, token] of tokens.entries()) if (token.includes(".")) {
402
+ if (index !== tokens.length - 1) return null;
403
+ const octets = parseIpv4Address(token);
404
+ if (octets == null) return null;
405
+ groups.push(octets[0] << 8 | octets[1], octets[2] << 8 | octets[3]);
406
+ } else if (/^[0-9a-f]{1,4}$/.test(token)) groups.push(Number.parseInt(token, 16));
407
+ else return null;
408
+ return groups;
409
+ }
410
+ const left = parseGroups(compressionParts[0]);
411
+ const right = parseGroups(compressionParts[1] ?? "");
412
+ if (left == null || right == null) return null;
413
+ if (compressionParts.length === 1) return left.length === 8 ? left : null;
414
+ const omittedGroups = 8 - left.length - right.length;
415
+ if (omittedGroups < 1) return null;
416
+ return [
417
+ ...left,
418
+ ...Array.from({ length: omittedGroups }, () => 0),
419
+ ...right
420
+ ];
421
+ }
422
+ /**
423
+ * Whether an IP address refers to the local loopback interface.
424
+ *
425
+ * @param address The IPv4 or IPv6 address to check.
426
+ * @returns `true` if the address is a loopback address.
427
+ */
428
+ function isLoopbackAddress(address) {
429
+ let normalized = address.toLowerCase();
430
+ if (normalized.startsWith("[") && normalized.endsWith("]")) normalized = normalized.slice(1, -1);
431
+ const zoneIndex = normalized.indexOf("%");
432
+ if (zoneIndex >= 0) normalized = normalized.slice(0, zoneIndex);
433
+ const ipv4Octets = parseIpv4Address(normalized);
434
+ if (ipv4Octets != null) return ipv4Octets[0] === 127;
435
+ const ipv6Groups = parseIpv6Address(normalized);
436
+ if (ipv6Groups == null) return false;
437
+ return ipv6Groups.slice(0, 7).every((group) => group === 0) && ipv6Groups[7] === 1 || ipv6Groups.slice(0, 5).every((group) => group === 0) && ipv6Groups[5] === 65535 && ipv6Groups[6] >> 8 === 127;
438
+ }
439
+ /**
440
+ * Whether a configured host name or address represents a loopback endpoint.
441
+ *
442
+ * This is a fallback for sockets that do not expose their connected peer
443
+ * address. A connected peer address takes precedence so a misleading host
444
+ * name cannot bypass the TLS requirement.
445
+ *
446
+ * @param host The configured SMTP host.
447
+ * @returns `true` if the host represents a loopback endpoint.
382
448
  */
383
449
  function isLoopbackHost(host) {
384
- return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
450
+ const normalized = host.toLowerCase().replace(/\.$/, "");
451
+ return normalized === "localhost" || normalized.endsWith(".localhost") || isLoopbackAddress(normalized);
452
+ }
453
+ /**
454
+ * Whether the SMTP connection is local enough to permit cleartext
455
+ * authentication during development.
456
+ *
457
+ * @param socket The connected SMTP socket, if available.
458
+ * @param host The configured SMTP host.
459
+ * @returns `true` if the connected peer or fallback host is loopback.
460
+ */
461
+ function isLoopbackConnection(socket, host) {
462
+ const remoteAddress = socket?.remoteAddress;
463
+ return remoteAddress == null ? isLoopbackHost(host) : isLoopbackAddress(remoteAddress);
385
464
  }
386
465
  var SmtpConnection = class {
387
466
  socket = null;
@@ -565,8 +644,8 @@ var SmtpConnection = class {
565
644
  if (!auth) return;
566
645
  if (this.authenticated) return;
567
646
  if (!this.capabilities.some((cap) => cap.toUpperCase().startsWith("AUTH"))) throw new SmtpAuthError("Server does not support authentication.");
647
+ if (!(this.socket instanceof node_tls.TLSSocket) && !isLoopbackConnection(this.socket, this.config.host)) throw new SmtpAuthError("SMTP authentication requires a TLS-secured connection to protect credentials; use `secure: true` or STARTTLS.");
568
648
  if ("accessToken" in auth || "refreshToken" in auth) {
569
- if (!(this.socket instanceof node_tls.TLSSocket) && !isLoopbackHost(this.config.host)) throw new SmtpAuthError("OAuth 2.0 authentication requires a TLS-secured connection to protect the access token; use `secure: true` or STARTTLS.");
570
649
  const mechanism = auth.method ?? selectOAuth2Mechanism(this.capabilities);
571
650
  switch (mechanism) {
572
651
  case "xoauth2":
@@ -1454,7 +1533,7 @@ var SmtpTransport = class {
1454
1533
  signal?.throwIfAborted();
1455
1534
  await connection.ehlo(signal);
1456
1535
  signal?.throwIfAborted();
1457
- if (!this.config.secure && connection.capabilities.some((cap) => cap.toUpperCase().startsWith("STARTTLS"))) {
1536
+ if (connection.config.secure === false && (connection.config.requireTls === true || connection.capabilities.some((cap) => cap.toUpperCase().startsWith("STARTTLS")))) {
1458
1537
  await connection.starttls(signal);
1459
1538
  signal?.throwIfAborted();
1460
1539
  await connection.ehlo(signal);
@@ -1463,7 +1542,7 @@ var SmtpTransport = class {
1463
1542
  await connection.authenticate(signal);
1464
1543
  }
1465
1544
  async returnConnection(connection) {
1466
- if (!this.config.pool) {
1545
+ if (!connection.config.pool) {
1467
1546
  await connection.quit();
1468
1547
  return;
1469
1548
  }
package/dist/index.d.cts CHANGED
@@ -140,6 +140,15 @@ interface SmtpConfig {
140
140
  * @default true
141
141
  */
142
142
  readonly secure?: boolean;
143
+ /**
144
+ * Whether to require a successful STARTTLS upgrade for connections that do
145
+ * not use implicit TLS. When enabled, the client issues `STARTTLS` even if
146
+ * the server does not advertise the capability and fails the connection if
147
+ * the upgrade does not succeed.
148
+ * @default false
149
+ * @since 0.6.0
150
+ */
151
+ readonly requireTls?: boolean;
143
152
  /**
144
153
  * Authentication configuration for the SMTP server.
145
154
  */
package/dist/index.d.ts CHANGED
@@ -140,6 +140,15 @@ interface SmtpConfig {
140
140
  * @default true
141
141
  */
142
142
  readonly secure?: boolean;
143
+ /**
144
+ * Whether to require a successful STARTTLS upgrade for connections that do
145
+ * not use implicit TLS. When enabled, the client issues `STARTTLS` even if
146
+ * the server does not advertise the capability and fails the connection if
147
+ * the upgrade does not succeed.
148
+ * @default false
149
+ * @since 0.6.0
150
+ */
151
+ readonly requireTls?: boolean;
143
152
  /**
144
153
  * Authentication configuration for the SMTP server.
145
154
  */
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ function createSmtpConfig(config) {
20
20
  host: config.host,
21
21
  port: config.port ?? 587,
22
22
  secure: config.secure ?? true,
23
+ requireTls: config.requireTls ?? false,
23
24
  auth: config.auth,
24
25
  tls: config.tls,
25
26
  connectionTimeout: config.connectionTimeout ?? 6e4,
@@ -351,14 +352,92 @@ const CRLF_LENGTH = 2;
351
352
  */
352
353
  const QUIT_TIMEOUT_MS = 5e3;
353
354
  /**
354
- * Whether a host refers to the local loopback interface, for which cleartext
355
- * OAuth 2.0 authentication is permitted (e.g. local testing and development).
355
+ * Parse an IPv4 address into its four octets.
356
356
  *
357
- * @param host The host to check.
358
- * @returns `true` if the host is a loopback address.
357
+ * @param address The IPv4 address to parse.
358
+ * @returns The parsed octets, or `null` if the address is invalid.
359
+ */
360
+ function parseIpv4Address(address) {
361
+ const octets = address.split(".");
362
+ if (octets.length !== 4 || !octets.every((octet) => /^(?:0|[1-9]\d{0,2})$/.test(octet) && Number(octet) <= 255)) return null;
363
+ return octets.map(Number);
364
+ }
365
+ /**
366
+ * Parse an IPv6 address into its eight 16-bit groups.
367
+ *
368
+ * @param address The IPv6 address to parse.
369
+ * @returns The parsed groups, or `null` if the address is invalid.
370
+ */
371
+ function parseIpv6Address(address) {
372
+ const compressionParts = address.split("::");
373
+ if (compressionParts.length > 2) return null;
374
+ function parseGroups(part) {
375
+ if (part === "") return [];
376
+ const tokens = part.split(":");
377
+ const groups = [];
378
+ for (const [index, token] of tokens.entries()) if (token.includes(".")) {
379
+ if (index !== tokens.length - 1) return null;
380
+ const octets = parseIpv4Address(token);
381
+ if (octets == null) return null;
382
+ groups.push(octets[0] << 8 | octets[1], octets[2] << 8 | octets[3]);
383
+ } else if (/^[0-9a-f]{1,4}$/.test(token)) groups.push(Number.parseInt(token, 16));
384
+ else return null;
385
+ return groups;
386
+ }
387
+ const left = parseGroups(compressionParts[0]);
388
+ const right = parseGroups(compressionParts[1] ?? "");
389
+ if (left == null || right == null) return null;
390
+ if (compressionParts.length === 1) return left.length === 8 ? left : null;
391
+ const omittedGroups = 8 - left.length - right.length;
392
+ if (omittedGroups < 1) return null;
393
+ return [
394
+ ...left,
395
+ ...Array.from({ length: omittedGroups }, () => 0),
396
+ ...right
397
+ ];
398
+ }
399
+ /**
400
+ * Whether an IP address refers to the local loopback interface.
401
+ *
402
+ * @param address The IPv4 or IPv6 address to check.
403
+ * @returns `true` if the address is a loopback address.
404
+ */
405
+ function isLoopbackAddress(address) {
406
+ let normalized = address.toLowerCase();
407
+ if (normalized.startsWith("[") && normalized.endsWith("]")) normalized = normalized.slice(1, -1);
408
+ const zoneIndex = normalized.indexOf("%");
409
+ if (zoneIndex >= 0) normalized = normalized.slice(0, zoneIndex);
410
+ const ipv4Octets = parseIpv4Address(normalized);
411
+ if (ipv4Octets != null) return ipv4Octets[0] === 127;
412
+ const ipv6Groups = parseIpv6Address(normalized);
413
+ if (ipv6Groups == null) return false;
414
+ return ipv6Groups.slice(0, 7).every((group) => group === 0) && ipv6Groups[7] === 1 || ipv6Groups.slice(0, 5).every((group) => group === 0) && ipv6Groups[5] === 65535 && ipv6Groups[6] >> 8 === 127;
415
+ }
416
+ /**
417
+ * Whether a configured host name or address represents a loopback endpoint.
418
+ *
419
+ * This is a fallback for sockets that do not expose their connected peer
420
+ * address. A connected peer address takes precedence so a misleading host
421
+ * name cannot bypass the TLS requirement.
422
+ *
423
+ * @param host The configured SMTP host.
424
+ * @returns `true` if the host represents a loopback endpoint.
359
425
  */
360
426
  function isLoopbackHost(host) {
361
- return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
427
+ const normalized = host.toLowerCase().replace(/\.$/, "");
428
+ return normalized === "localhost" || normalized.endsWith(".localhost") || isLoopbackAddress(normalized);
429
+ }
430
+ /**
431
+ * Whether the SMTP connection is local enough to permit cleartext
432
+ * authentication during development.
433
+ *
434
+ * @param socket The connected SMTP socket, if available.
435
+ * @param host The configured SMTP host.
436
+ * @returns `true` if the connected peer or fallback host is loopback.
437
+ */
438
+ function isLoopbackConnection(socket, host) {
439
+ const remoteAddress = socket?.remoteAddress;
440
+ return remoteAddress == null ? isLoopbackHost(host) : isLoopbackAddress(remoteAddress);
362
441
  }
363
442
  var SmtpConnection = class {
364
443
  socket = null;
@@ -542,8 +621,8 @@ var SmtpConnection = class {
542
621
  if (!auth) return;
543
622
  if (this.authenticated) return;
544
623
  if (!this.capabilities.some((cap) => cap.toUpperCase().startsWith("AUTH"))) throw new SmtpAuthError("Server does not support authentication.");
624
+ if (!(this.socket instanceof TLSSocket) && !isLoopbackConnection(this.socket, this.config.host)) throw new SmtpAuthError("SMTP authentication requires a TLS-secured connection to protect credentials; use `secure: true` or STARTTLS.");
545
625
  if ("accessToken" in auth || "refreshToken" in auth) {
546
- if (!(this.socket instanceof TLSSocket) && !isLoopbackHost(this.config.host)) throw new SmtpAuthError("OAuth 2.0 authentication requires a TLS-secured connection to protect the access token; use `secure: true` or STARTTLS.");
547
626
  const mechanism = auth.method ?? selectOAuth2Mechanism(this.capabilities);
548
627
  switch (mechanism) {
549
628
  case "xoauth2":
@@ -1431,7 +1510,7 @@ var SmtpTransport = class {
1431
1510
  signal?.throwIfAborted();
1432
1511
  await connection.ehlo(signal);
1433
1512
  signal?.throwIfAborted();
1434
- if (!this.config.secure && connection.capabilities.some((cap) => cap.toUpperCase().startsWith("STARTTLS"))) {
1513
+ if (connection.config.secure === false && (connection.config.requireTls === true || connection.capabilities.some((cap) => cap.toUpperCase().startsWith("STARTTLS")))) {
1435
1514
  await connection.starttls(signal);
1436
1515
  signal?.throwIfAborted();
1437
1516
  await connection.ehlo(signal);
@@ -1440,7 +1519,7 @@ var SmtpTransport = class {
1440
1519
  await connection.authenticate(signal);
1441
1520
  }
1442
1521
  async returnConnection(connection) {
1443
- if (!this.config.pool) {
1522
+ if (!connection.config.pool) {
1444
1523
  await connection.quit();
1445
1524
  return;
1446
1525
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upyo/smtp",
3
- "version": "0.6.0-dev.265",
3
+ "version": "0.6.0-dev.269",
4
4
  "description": "SMTP transport for Upyo email library",
5
5
  "keywords": [
6
6
  "email",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "sideEffects": false,
55
55
  "peerDependencies": {
56
- "@upyo/core": "0.6.0-dev.265+77f3e71a"
56
+ "@upyo/core": "0.6.0-dev.269+e9149444"
57
57
  },
58
58
  "devDependencies": {
59
59
  "tsdown": "^0.12.7",