@upyo/smtp 0.5.1-dev.259 → 0.5.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 +6 -0
- package/dist/index.cjs +84 -6
- package/dist/index.js +84 -6
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -115,6 +115,12 @@ Configuration options
|
|
|
115
115
|
| `poolSize` | `number` | `5` | Maximum pool connections |
|
|
116
116
|
| `dkim` | `DkimConfig` | | DKIM signing configuration |
|
|
117
117
|
|
|
118
|
+
For non-loopback hosts, SMTP authentication requires either an implicit TLS
|
|
119
|
+
connection (`secure: true`) or a successful STARTTLS upgrade (`secure: false`).
|
|
120
|
+
If the server does not advertise STARTTLS, delivery returns a failed receipt
|
|
121
|
+
without transmitting passwords or OAuth 2.0 access tokens. Cleartext
|
|
122
|
+
authentication to loopback hosts remains available for local development.
|
|
123
|
+
|
|
118
124
|
### `SmtpAuth`
|
|
119
125
|
|
|
120
126
|
`SmtpAuth` is a discriminated union of three strategies.
|
package/dist/index.cjs
CHANGED
|
@@ -374,14 +374,92 @@ const CRLF_LENGTH = 2;
|
|
|
374
374
|
*/
|
|
375
375
|
const QUIT_TIMEOUT_MS = 5e3;
|
|
376
376
|
/**
|
|
377
|
-
*
|
|
378
|
-
* OAuth 2.0 authentication is permitted (e.g. local testing and development).
|
|
377
|
+
* Parse an IPv4 address into its four octets.
|
|
379
378
|
*
|
|
380
|
-
* @param
|
|
381
|
-
* @returns `
|
|
379
|
+
* @param address The IPv4 address to parse.
|
|
380
|
+
* @returns The parsed octets, or `null` if the address is invalid.
|
|
381
|
+
*/
|
|
382
|
+
function parseIpv4Address(address) {
|
|
383
|
+
const octets = address.split(".");
|
|
384
|
+
if (octets.length !== 4 || !octets.every((octet) => /^(?:0|[1-9]\d{0,2})$/.test(octet) && Number(octet) <= 255)) return null;
|
|
385
|
+
return octets.map(Number);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Parse an IPv6 address into its eight 16-bit groups.
|
|
389
|
+
*
|
|
390
|
+
* @param address The IPv6 address to parse.
|
|
391
|
+
* @returns The parsed groups, or `null` if the address is invalid.
|
|
392
|
+
*/
|
|
393
|
+
function parseIpv6Address(address) {
|
|
394
|
+
const compressionParts = address.split("::");
|
|
395
|
+
if (compressionParts.length > 2) return null;
|
|
396
|
+
function parseGroups(part) {
|
|
397
|
+
if (part === "") return [];
|
|
398
|
+
const tokens = part.split(":");
|
|
399
|
+
const groups = [];
|
|
400
|
+
for (const [index, token] of tokens.entries()) if (token.includes(".")) {
|
|
401
|
+
if (index !== tokens.length - 1) return null;
|
|
402
|
+
const octets = parseIpv4Address(token);
|
|
403
|
+
if (octets == null) return null;
|
|
404
|
+
groups.push(octets[0] << 8 | octets[1], octets[2] << 8 | octets[3]);
|
|
405
|
+
} else if (/^[0-9a-f]{1,4}$/.test(token)) groups.push(Number.parseInt(token, 16));
|
|
406
|
+
else return null;
|
|
407
|
+
return groups;
|
|
408
|
+
}
|
|
409
|
+
const left = parseGroups(compressionParts[0]);
|
|
410
|
+
const right = parseGroups(compressionParts[1] ?? "");
|
|
411
|
+
if (left == null || right == null) return null;
|
|
412
|
+
if (compressionParts.length === 1) return left.length === 8 ? left : null;
|
|
413
|
+
const omittedGroups = 8 - left.length - right.length;
|
|
414
|
+
if (omittedGroups < 1) return null;
|
|
415
|
+
return [
|
|
416
|
+
...left,
|
|
417
|
+
...Array.from({ length: omittedGroups }, () => 0),
|
|
418
|
+
...right
|
|
419
|
+
];
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Whether an IP address refers to the local loopback interface.
|
|
423
|
+
*
|
|
424
|
+
* @param address The IPv4 or IPv6 address to check.
|
|
425
|
+
* @returns `true` if the address is a loopback address.
|
|
426
|
+
*/
|
|
427
|
+
function isLoopbackAddress(address) {
|
|
428
|
+
let normalized = address.toLowerCase();
|
|
429
|
+
if (normalized.startsWith("[") && normalized.endsWith("]")) normalized = normalized.slice(1, -1);
|
|
430
|
+
const zoneIndex = normalized.indexOf("%");
|
|
431
|
+
if (zoneIndex >= 0) normalized = normalized.slice(0, zoneIndex);
|
|
432
|
+
const ipv4Octets = parseIpv4Address(normalized);
|
|
433
|
+
if (ipv4Octets != null) return ipv4Octets[0] === 127;
|
|
434
|
+
const ipv6Groups = parseIpv6Address(normalized);
|
|
435
|
+
if (ipv6Groups == null) return false;
|
|
436
|
+
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;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Whether a configured host name or address represents a loopback endpoint.
|
|
440
|
+
*
|
|
441
|
+
* This is a fallback for sockets that do not expose their connected peer
|
|
442
|
+
* address. A connected peer address takes precedence so a misleading host
|
|
443
|
+
* name cannot bypass the TLS requirement.
|
|
444
|
+
*
|
|
445
|
+
* @param host The configured SMTP host.
|
|
446
|
+
* @returns `true` if the host represents a loopback endpoint.
|
|
382
447
|
*/
|
|
383
448
|
function isLoopbackHost(host) {
|
|
384
|
-
|
|
449
|
+
const normalized = host.toLowerCase().replace(/\.$/, "");
|
|
450
|
+
return normalized === "localhost" || normalized.endsWith(".localhost") || isLoopbackAddress(normalized);
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Whether the SMTP connection is local enough to permit cleartext
|
|
454
|
+
* authentication during development.
|
|
455
|
+
*
|
|
456
|
+
* @param socket The connected SMTP socket, if available.
|
|
457
|
+
* @param host The configured SMTP host.
|
|
458
|
+
* @returns `true` if the connected peer or fallback host is loopback.
|
|
459
|
+
*/
|
|
460
|
+
function isLoopbackConnection(socket, host) {
|
|
461
|
+
const remoteAddress = socket?.remoteAddress;
|
|
462
|
+
return remoteAddress == null ? isLoopbackHost(host) : isLoopbackAddress(remoteAddress);
|
|
385
463
|
}
|
|
386
464
|
var SmtpConnection = class {
|
|
387
465
|
socket = null;
|
|
@@ -565,8 +643,8 @@ var SmtpConnection = class {
|
|
|
565
643
|
if (!auth) return;
|
|
566
644
|
if (this.authenticated) return;
|
|
567
645
|
if (!this.capabilities.some((cap) => cap.toUpperCase().startsWith("AUTH"))) throw new SmtpAuthError("Server does not support authentication.");
|
|
646
|
+
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
647
|
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
648
|
const mechanism = auth.method ?? selectOAuth2Mechanism(this.capabilities);
|
|
571
649
|
switch (mechanism) {
|
|
572
650
|
case "xoauth2":
|
package/dist/index.js
CHANGED
|
@@ -351,14 +351,92 @@ const CRLF_LENGTH = 2;
|
|
|
351
351
|
*/
|
|
352
352
|
const QUIT_TIMEOUT_MS = 5e3;
|
|
353
353
|
/**
|
|
354
|
-
*
|
|
355
|
-
* OAuth 2.0 authentication is permitted (e.g. local testing and development).
|
|
354
|
+
* Parse an IPv4 address into its four octets.
|
|
356
355
|
*
|
|
357
|
-
* @param
|
|
358
|
-
* @returns `
|
|
356
|
+
* @param address The IPv4 address to parse.
|
|
357
|
+
* @returns The parsed octets, or `null` if the address is invalid.
|
|
358
|
+
*/
|
|
359
|
+
function parseIpv4Address(address) {
|
|
360
|
+
const octets = address.split(".");
|
|
361
|
+
if (octets.length !== 4 || !octets.every((octet) => /^(?:0|[1-9]\d{0,2})$/.test(octet) && Number(octet) <= 255)) return null;
|
|
362
|
+
return octets.map(Number);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Parse an IPv6 address into its eight 16-bit groups.
|
|
366
|
+
*
|
|
367
|
+
* @param address The IPv6 address to parse.
|
|
368
|
+
* @returns The parsed groups, or `null` if the address is invalid.
|
|
369
|
+
*/
|
|
370
|
+
function parseIpv6Address(address) {
|
|
371
|
+
const compressionParts = address.split("::");
|
|
372
|
+
if (compressionParts.length > 2) return null;
|
|
373
|
+
function parseGroups(part) {
|
|
374
|
+
if (part === "") return [];
|
|
375
|
+
const tokens = part.split(":");
|
|
376
|
+
const groups = [];
|
|
377
|
+
for (const [index, token] of tokens.entries()) if (token.includes(".")) {
|
|
378
|
+
if (index !== tokens.length - 1) return null;
|
|
379
|
+
const octets = parseIpv4Address(token);
|
|
380
|
+
if (octets == null) return null;
|
|
381
|
+
groups.push(octets[0] << 8 | octets[1], octets[2] << 8 | octets[3]);
|
|
382
|
+
} else if (/^[0-9a-f]{1,4}$/.test(token)) groups.push(Number.parseInt(token, 16));
|
|
383
|
+
else return null;
|
|
384
|
+
return groups;
|
|
385
|
+
}
|
|
386
|
+
const left = parseGroups(compressionParts[0]);
|
|
387
|
+
const right = parseGroups(compressionParts[1] ?? "");
|
|
388
|
+
if (left == null || right == null) return null;
|
|
389
|
+
if (compressionParts.length === 1) return left.length === 8 ? left : null;
|
|
390
|
+
const omittedGroups = 8 - left.length - right.length;
|
|
391
|
+
if (omittedGroups < 1) return null;
|
|
392
|
+
return [
|
|
393
|
+
...left,
|
|
394
|
+
...Array.from({ length: omittedGroups }, () => 0),
|
|
395
|
+
...right
|
|
396
|
+
];
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Whether an IP address refers to the local loopback interface.
|
|
400
|
+
*
|
|
401
|
+
* @param address The IPv4 or IPv6 address to check.
|
|
402
|
+
* @returns `true` if the address is a loopback address.
|
|
403
|
+
*/
|
|
404
|
+
function isLoopbackAddress(address) {
|
|
405
|
+
let normalized = address.toLowerCase();
|
|
406
|
+
if (normalized.startsWith("[") && normalized.endsWith("]")) normalized = normalized.slice(1, -1);
|
|
407
|
+
const zoneIndex = normalized.indexOf("%");
|
|
408
|
+
if (zoneIndex >= 0) normalized = normalized.slice(0, zoneIndex);
|
|
409
|
+
const ipv4Octets = parseIpv4Address(normalized);
|
|
410
|
+
if (ipv4Octets != null) return ipv4Octets[0] === 127;
|
|
411
|
+
const ipv6Groups = parseIpv6Address(normalized);
|
|
412
|
+
if (ipv6Groups == null) return false;
|
|
413
|
+
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;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Whether a configured host name or address represents a loopback endpoint.
|
|
417
|
+
*
|
|
418
|
+
* This is a fallback for sockets that do not expose their connected peer
|
|
419
|
+
* address. A connected peer address takes precedence so a misleading host
|
|
420
|
+
* name cannot bypass the TLS requirement.
|
|
421
|
+
*
|
|
422
|
+
* @param host The configured SMTP host.
|
|
423
|
+
* @returns `true` if the host represents a loopback endpoint.
|
|
359
424
|
*/
|
|
360
425
|
function isLoopbackHost(host) {
|
|
361
|
-
|
|
426
|
+
const normalized = host.toLowerCase().replace(/\.$/, "");
|
|
427
|
+
return normalized === "localhost" || normalized.endsWith(".localhost") || isLoopbackAddress(normalized);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Whether the SMTP connection is local enough to permit cleartext
|
|
431
|
+
* authentication during development.
|
|
432
|
+
*
|
|
433
|
+
* @param socket The connected SMTP socket, if available.
|
|
434
|
+
* @param host The configured SMTP host.
|
|
435
|
+
* @returns `true` if the connected peer or fallback host is loopback.
|
|
436
|
+
*/
|
|
437
|
+
function isLoopbackConnection(socket, host) {
|
|
438
|
+
const remoteAddress = socket?.remoteAddress;
|
|
439
|
+
return remoteAddress == null ? isLoopbackHost(host) : isLoopbackAddress(remoteAddress);
|
|
362
440
|
}
|
|
363
441
|
var SmtpConnection = class {
|
|
364
442
|
socket = null;
|
|
@@ -542,8 +620,8 @@ var SmtpConnection = class {
|
|
|
542
620
|
if (!auth) return;
|
|
543
621
|
if (this.authenticated) return;
|
|
544
622
|
if (!this.capabilities.some((cap) => cap.toUpperCase().startsWith("AUTH"))) throw new SmtpAuthError("Server does not support authentication.");
|
|
623
|
+
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
624
|
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
625
|
const mechanism = auth.method ?? selectOAuth2Mechanism(this.capabilities);
|
|
548
626
|
switch (mechanism) {
|
|
549
627
|
case "xoauth2":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@upyo/smtp",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
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.5.
|
|
56
|
+
"@upyo/core": "0.5.2"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"tsdown": "^0.12.7",
|