@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 +8 -0
- package/dist/index.cjs +87 -8
- package/dist/index.d.cts +9 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +87 -8
- package/package.json +2 -2
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
|
-
*
|
|
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
|
|
381
|
-
* @returns `
|
|
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
|
-
|
|
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 (
|
|
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 (!
|
|
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
|
-
*
|
|
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
|
|
358
|
-
* @returns `
|
|
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
|
-
|
|
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 (
|
|
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 (!
|
|
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.
|
|
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.
|
|
56
|
+
"@upyo/core": "0.6.0-dev.269+e9149444"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"tsdown": "^0.12.7",
|