@upyo/smtp 0.6.0-dev.265 → 0.6.0-dev.271
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 +215 -44
- package/dist/index.d.cts +47 -3
- package/dist/index.d.ts +47 -3
- package/dist/index.js +215 -44
- 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;
|
|
@@ -514,6 +593,12 @@ var SmtpConnection = class {
|
|
|
514
593
|
}
|
|
515
594
|
async ehlo(signal) {
|
|
516
595
|
const response = await this.sendCommand(`EHLO ${this.config.localName}`, signal);
|
|
596
|
+
if (response.code === 500 || response.code === 502) {
|
|
597
|
+
const heloResponse = await this.sendCommand(`HELO ${this.config.localName}`, signal);
|
|
598
|
+
if (heloResponse.code !== 250) throw new Error(`HELO failed: ${heloResponse.message}`);
|
|
599
|
+
this.capabilities = [];
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
517
602
|
if (response.code !== 250) throw new Error(`EHLO failed: ${response.message}`);
|
|
518
603
|
this.capabilities = response.raw.split("\r\n").filter((line) => line.startsWith("250-") || line.startsWith("250 ")).map((line) => line.substring(4)).filter((line) => line.length > 0);
|
|
519
604
|
}
|
|
@@ -565,8 +650,8 @@ var SmtpConnection = class {
|
|
|
565
650
|
if (!auth) return;
|
|
566
651
|
if (this.authenticated) return;
|
|
567
652
|
if (!this.capabilities.some((cap) => cap.toUpperCase().startsWith("AUTH"))) throw new SmtpAuthError("Server does not support authentication.");
|
|
653
|
+
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
654
|
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
655
|
const mechanism = auth.method ?? selectOAuth2Mechanism(this.capabilities);
|
|
571
656
|
switch (mechanism) {
|
|
572
657
|
case "xoauth2":
|
|
@@ -670,10 +755,22 @@ var SmtpConnection = class {
|
|
|
670
755
|
async sendMessage(message, signal) {
|
|
671
756
|
const mailResponse = await this.sendCommand(`MAIL FROM:<${message.envelope.from}>`, signal);
|
|
672
757
|
if (mailResponse.code !== 250) throw new SmtpResponseError(`MAIL FROM failed: ${mailResponse.message}`, mailResponse.code, "MAIL FROM", mailResponse.message);
|
|
758
|
+
const rejectedRecipients = [];
|
|
673
759
|
for (const recipient of message.envelope.to) {
|
|
674
760
|
signal?.throwIfAborted();
|
|
675
761
|
const rcptResponse = await this.sendCommand(`RCPT TO:<${recipient}>`, signal);
|
|
676
|
-
if (rcptResponse.code
|
|
762
|
+
if (rcptResponse.code === 421) throw new SmtpResponseError(`RCPT TO failed for ${recipient}: ${rcptResponse.message}`, rcptResponse.code, "RCPT TO", rcptResponse.message);
|
|
763
|
+
if (rcptResponse.code !== 250 && rcptResponse.code !== 251) rejectedRecipients.push({
|
|
764
|
+
recipient,
|
|
765
|
+
code: rcptResponse.code,
|
|
766
|
+
response: rcptResponse.message,
|
|
767
|
+
retryable: rcptResponse.code >= 400 && rcptResponse.code < 500
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
if (rejectedRecipients.length > 0 && rejectedRecipients.length === message.envelope.to.length) {
|
|
771
|
+
const rejection = rejectedRecipients.find((item) => item.retryable) ?? rejectedRecipients[0];
|
|
772
|
+
const details = rejectedRecipients.map((item) => `${item.recipient}: ${item.code} ${item.response}`).join("; ");
|
|
773
|
+
throw new SmtpResponseError(`RCPT TO failed for every recipient: ${details}`, rejection.code, "RCPT TO", rejection.response, rejectedRecipients);
|
|
677
774
|
}
|
|
678
775
|
const dataResponse = await this.sendCommand("DATA", signal);
|
|
679
776
|
if (dataResponse.code !== 354) throw new SmtpResponseError(`DATA failed: ${dataResponse.message}`, dataResponse.code, "DATA", dataResponse.message);
|
|
@@ -681,7 +778,10 @@ var SmtpConnection = class {
|
|
|
681
778
|
const finalResponse = await this.sendCommand(`${content}\r\n.`, signal);
|
|
682
779
|
if (finalResponse.code !== 250) throw new SmtpResponseError(`Message send failed: ${finalResponse.message}`, finalResponse.code, "DATA_END", finalResponse.message);
|
|
683
780
|
const messageId = this.extractMessageId(finalResponse.message);
|
|
684
|
-
return
|
|
781
|
+
return {
|
|
782
|
+
messageId,
|
|
783
|
+
rejectedRecipients
|
|
784
|
+
};
|
|
685
785
|
}
|
|
686
786
|
extractMessageId(response) {
|
|
687
787
|
const match = response.match(/(?:Message-ID:|id=)[\s<]*([^>\s]+)/i);
|
|
@@ -737,6 +837,8 @@ var SmtpResponseError = class extends Error {
|
|
|
737
837
|
* The textual SMTP reply returned by the server.
|
|
738
838
|
*/
|
|
739
839
|
response;
|
|
840
|
+
/** Recipient-level failures collected for an unsuccessful transaction. */
|
|
841
|
+
rejectedRecipients;
|
|
740
842
|
/**
|
|
741
843
|
* Creates an SMTP response error.
|
|
742
844
|
*
|
|
@@ -744,13 +846,16 @@ var SmtpResponseError = class extends Error {
|
|
|
744
846
|
* @param code The numeric SMTP reply code.
|
|
745
847
|
* @param command The SMTP command that produced the reply.
|
|
746
848
|
* @param response The textual SMTP reply returned by the server.
|
|
849
|
+
* @param rejectedRecipients Recipient-level failures collected for the
|
|
850
|
+
* transaction.
|
|
747
851
|
*/
|
|
748
|
-
constructor(message, code, command, response) {
|
|
852
|
+
constructor(message, code, command, response, rejectedRecipients) {
|
|
749
853
|
super(message);
|
|
750
854
|
this.name = "SmtpResponseError";
|
|
751
855
|
this.code = code;
|
|
752
856
|
this.command = command;
|
|
753
857
|
this.response = response;
|
|
858
|
+
this.rejectedRecipients = rejectedRecipients;
|
|
754
859
|
}
|
|
755
860
|
};
|
|
756
861
|
/**
|
|
@@ -1052,6 +1157,15 @@ function arrayBufferToBase64(buffer) {
|
|
|
1052
1157
|
|
|
1053
1158
|
//#endregion
|
|
1054
1159
|
//#region src/message-converter.ts
|
|
1160
|
+
/**
|
|
1161
|
+
* Converts a message to its SMTP envelope and wire representation.
|
|
1162
|
+
*
|
|
1163
|
+
* @param message The message to convert.
|
|
1164
|
+
* @param dkimConfig Optional DKIM signing configuration.
|
|
1165
|
+
* @returns The converted SMTP message.
|
|
1166
|
+
* @throws {RangeError} If a header contains a token that cannot be folded
|
|
1167
|
+
* within the RFC 5322 hard line-length limit.
|
|
1168
|
+
*/
|
|
1055
1169
|
async function convertMessage(message, dkimConfig) {
|
|
1056
1170
|
const envelope = {
|
|
1057
1171
|
from: message.sender.address,
|
|
@@ -1083,11 +1197,11 @@ async function buildRawMessage(message) {
|
|
|
1083
1197
|
const hasHtml = "html" in message.content;
|
|
1084
1198
|
const hasText = "text" in message.content;
|
|
1085
1199
|
const isMultipart = hasAttachments || hasHtml && hasText;
|
|
1086
|
-
lines.push(
|
|
1087
|
-
lines.push(
|
|
1088
|
-
if (message.ccRecipients.length > 0) lines.push(
|
|
1089
|
-
if (message.replyRecipients.length > 0) lines.push(
|
|
1090
|
-
lines.push(
|
|
1200
|
+
lines.push(foldHeader("From", encodeAddress(message.sender)));
|
|
1201
|
+
lines.push(foldHeader("To", message.recipients.map(encodeAddress).join(", ")));
|
|
1202
|
+
if (message.ccRecipients.length > 0) lines.push(foldHeader("Cc", message.ccRecipients.map(encodeAddress).join(", ")));
|
|
1203
|
+
if (message.replyRecipients.length > 0) lines.push(foldHeader("Reply-To", message.replyRecipients.map(encodeAddress).join(", ")));
|
|
1204
|
+
lines.push(foldHeader("Subject", encodeHeaderValue(message.subject, true)));
|
|
1091
1205
|
lines.push(`Date: ${(/* @__PURE__ */ new Date()).toUTCString()}`);
|
|
1092
1206
|
lines.push(`Message-ID: <${generateMessageId()}>`);
|
|
1093
1207
|
if (message.priority !== "normal") {
|
|
@@ -1095,7 +1209,7 @@ async function buildRawMessage(message) {
|
|
|
1095
1209
|
lines.push(`X-Priority: ${priorityValue}`);
|
|
1096
1210
|
lines.push(`X-MSMail-Priority: ${message.priority === "high" ? "High" : "Low"}`);
|
|
1097
1211
|
}
|
|
1098
|
-
for (const [key, value] of message.headers) lines.push(
|
|
1212
|
+
for (const [key, value] of message.headers) lines.push(foldHeader(key, encodeHeaderValue(value)));
|
|
1099
1213
|
lines.push("MIME-Version: 1.0");
|
|
1100
1214
|
if (isMultipart) {
|
|
1101
1215
|
lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
|
|
@@ -1134,12 +1248,12 @@ async function buildRawMessage(message) {
|
|
|
1134
1248
|
for (const attachment of message.attachments) {
|
|
1135
1249
|
lines.push("");
|
|
1136
1250
|
lines.push(`--${boundary}`);
|
|
1137
|
-
lines.push(
|
|
1251
|
+
lines.push(foldHeader("Content-Type", `${attachment.contentType}; ${encodeMimeParameter("name", attachment.filename)}`));
|
|
1138
1252
|
lines.push("Content-Transfer-Encoding: base64");
|
|
1139
1253
|
if (attachment.inline) {
|
|
1140
|
-
lines.push(
|
|
1254
|
+
lines.push(foldHeader("Content-Disposition", `inline; ${encodeMimeParameter("filename", attachment.filename)}`));
|
|
1141
1255
|
lines.push(`Content-ID: <${attachment.contentId}>`);
|
|
1142
|
-
} else lines.push(
|
|
1256
|
+
} else lines.push(foldHeader("Content-Disposition", `attachment; ${encodeMimeParameter("filename", attachment.filename)}`));
|
|
1143
1257
|
lines.push("");
|
|
1144
1258
|
lines.push(encodeBase64(await attachment.content));
|
|
1145
1259
|
}
|
|
@@ -1168,32 +1282,85 @@ function generateMessageId() {
|
|
|
1168
1282
|
}
|
|
1169
1283
|
function encodeAddress(address) {
|
|
1170
1284
|
if (address.name == null) return address.address;
|
|
1171
|
-
const encodedDisplayName = encodeHeaderValue(address.name);
|
|
1285
|
+
const encodedDisplayName = encodeHeaderValue(address.name, true);
|
|
1172
1286
|
return `${encodedDisplayName} <${address.address}>`;
|
|
1173
1287
|
}
|
|
1174
|
-
function encodeHeaderValue(value) {
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
const
|
|
1288
|
+
function encodeHeaderValue(value, encodeLongAsciiWords = false) {
|
|
1289
|
+
const hasLongWord = value.split(/\s+/).some((word) => word.length > 60);
|
|
1290
|
+
if (!/^[\x20-\x7E]*$/.test(value) || encodeLongAsciiWords && hasLongWord) {
|
|
1291
|
+
const encodeWord = (text) => {
|
|
1292
|
+
const utf8Bytes = new TextEncoder().encode(text);
|
|
1293
|
+
const base64 = node_buffer.Buffer.from(utf8Bytes).toString("base64");
|
|
1294
|
+
return `=?UTF-8?B?${base64}?=`;
|
|
1295
|
+
};
|
|
1178
1296
|
const maxEncodedLength = 75;
|
|
1179
|
-
const encodedWord =
|
|
1297
|
+
const encodedWord = encodeWord(value);
|
|
1180
1298
|
if (encodedWord.length <= maxEncodedLength) return encodedWord;
|
|
1181
1299
|
const words = [];
|
|
1182
|
-
let
|
|
1183
|
-
for (
|
|
1184
|
-
const
|
|
1185
|
-
|
|
1186
|
-
if (testWord.length <= maxEncodedLength) currentBase64 += chunk;
|
|
1300
|
+
let currentText = "";
|
|
1301
|
+
for (const character of value) {
|
|
1302
|
+
const candidate = currentText + character;
|
|
1303
|
+
if (encodeWord(candidate).length <= maxEncodedLength) currentText = candidate;
|
|
1187
1304
|
else {
|
|
1188
|
-
if (
|
|
1189
|
-
|
|
1305
|
+
if (currentText.length > 0) words.push(encodeWord(currentText));
|
|
1306
|
+
currentText = character;
|
|
1190
1307
|
}
|
|
1191
1308
|
}
|
|
1192
|
-
if (
|
|
1309
|
+
if (currentText.length > 0) words.push(encodeWord(currentText));
|
|
1193
1310
|
return words.join(" ");
|
|
1194
1311
|
}
|
|
1195
1312
|
return value;
|
|
1196
1313
|
}
|
|
1314
|
+
function encodeMimeParameter(name, value) {
|
|
1315
|
+
const escapedValue = value.replace(/[\\"]/g, "\\$&");
|
|
1316
|
+
const quotedParameter = `${name}="${escapedValue}"`;
|
|
1317
|
+
if (/^[\x20-\x7E]*$/.test(value) && quotedParameter.length <= 60) return quotedParameter;
|
|
1318
|
+
const encodedBytes = Array.from(new TextEncoder().encode(value), (byte) => {
|
|
1319
|
+
const character = String.fromCharCode(byte);
|
|
1320
|
+
return /^[A-Za-z0-9!#$&+.^_`|~-]$/.test(character) ? character : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
|
|
1321
|
+
});
|
|
1322
|
+
const segments = [];
|
|
1323
|
+
let segment = "";
|
|
1324
|
+
for (const encodedByte of encodedBytes) {
|
|
1325
|
+
if (segment.length + encodedByte.length > 45) {
|
|
1326
|
+
segments.push(segment);
|
|
1327
|
+
segment = "";
|
|
1328
|
+
}
|
|
1329
|
+
segment += encodedByte;
|
|
1330
|
+
}
|
|
1331
|
+
if (segment.length > 0 || segments.length === 0) segments.push(segment);
|
|
1332
|
+
return segments.map((part, index) => `${name}*${index}*=${index === 0 ? "UTF-8''" : ""}${part}`).join("; ");
|
|
1333
|
+
}
|
|
1334
|
+
function foldHeader(name, value) {
|
|
1335
|
+
const recommendedLineLength = 78;
|
|
1336
|
+
const lines = [];
|
|
1337
|
+
let prefix = `${name}: `;
|
|
1338
|
+
let remaining = value;
|
|
1339
|
+
while (prefix.length + remaining.length > recommendedLineLength) {
|
|
1340
|
+
const availableLength = recommendedLineLength - prefix.length;
|
|
1341
|
+
let breakIndex = -1;
|
|
1342
|
+
for (let index = Math.min(availableLength, remaining.length - 1); index >= 0; index--) if (remaining[index] === " " || remaining[index] === " ") {
|
|
1343
|
+
breakIndex = index;
|
|
1344
|
+
break;
|
|
1345
|
+
}
|
|
1346
|
+
if (breakIndex < 0) {
|
|
1347
|
+
for (let index = Math.max(availableLength + 1, 0); index < remaining.length; index++) if (remaining[index] === " " || remaining[index] === " ") {
|
|
1348
|
+
breakIndex = index;
|
|
1349
|
+
break;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
if (breakIndex < 0) break;
|
|
1353
|
+
let whitespaceEnd = breakIndex + 1;
|
|
1354
|
+
while (whitespaceEnd < remaining.length && (remaining[whitespaceEnd] === " " || remaining[whitespaceEnd] === " ")) whitespaceEnd++;
|
|
1355
|
+
if (whitespaceEnd === remaining.length) break;
|
|
1356
|
+
lines.push(prefix + remaining.slice(0, breakIndex));
|
|
1357
|
+
prefix = remaining.slice(breakIndex, whitespaceEnd);
|
|
1358
|
+
remaining = remaining.slice(whitespaceEnd);
|
|
1359
|
+
}
|
|
1360
|
+
lines.push(prefix + remaining);
|
|
1361
|
+
if (lines.some((line) => line.length > 998)) throw new RangeError(`Header field ${name} contains a token too long to fold.`);
|
|
1362
|
+
return lines.join("\r\n");
|
|
1363
|
+
}
|
|
1197
1364
|
function encodeQuotedPrintable(text) {
|
|
1198
1365
|
const utf8Bytes = new TextEncoder().encode(text);
|
|
1199
1366
|
let result = "";
|
|
@@ -1324,12 +1491,13 @@ var SmtpTransport = class {
|
|
|
1324
1491
|
options?.signal?.throwIfAborted();
|
|
1325
1492
|
const smtpMessage = await convertMessage(message, this.config.dkim);
|
|
1326
1493
|
options?.signal?.throwIfAborted();
|
|
1327
|
-
const
|
|
1494
|
+
const result = await connection.sendMessage(smtpMessage, options?.signal);
|
|
1328
1495
|
await this.returnConnection(connection);
|
|
1329
1496
|
return {
|
|
1330
1497
|
successful: true,
|
|
1331
|
-
messageId,
|
|
1332
|
-
provider: "smtp"
|
|
1498
|
+
messageId: result.messageId,
|
|
1499
|
+
provider: "smtp",
|
|
1500
|
+
rejectedRecipients: result.rejectedRecipients
|
|
1333
1501
|
};
|
|
1334
1502
|
} catch (error) {
|
|
1335
1503
|
if (connection != null) await this.discardConnection(connection);
|
|
@@ -1393,11 +1561,12 @@ var SmtpTransport = class {
|
|
|
1393
1561
|
try {
|
|
1394
1562
|
const smtpMessage = await convertMessage(message, this.config.dkim);
|
|
1395
1563
|
options?.signal?.throwIfAborted();
|
|
1396
|
-
const
|
|
1564
|
+
const result = await connection.sendMessage(smtpMessage, options?.signal);
|
|
1397
1565
|
yield {
|
|
1398
1566
|
successful: true,
|
|
1399
|
-
messageId,
|
|
1400
|
-
provider: "smtp"
|
|
1567
|
+
messageId: result.messageId,
|
|
1568
|
+
provider: "smtp",
|
|
1569
|
+
rejectedRecipients: result.rejectedRecipients
|
|
1401
1570
|
};
|
|
1402
1571
|
} catch (error) {
|
|
1403
1572
|
options?.signal?.throwIfAborted();
|
|
@@ -1414,11 +1583,12 @@ var SmtpTransport = class {
|
|
|
1414
1583
|
try {
|
|
1415
1584
|
const smtpMessage = await convertMessage(message, this.config.dkim);
|
|
1416
1585
|
options?.signal?.throwIfAborted();
|
|
1417
|
-
const
|
|
1586
|
+
const result = await connection.sendMessage(smtpMessage, options?.signal);
|
|
1418
1587
|
yield {
|
|
1419
1588
|
successful: true,
|
|
1420
|
-
messageId,
|
|
1421
|
-
provider: "smtp"
|
|
1589
|
+
messageId: result.messageId,
|
|
1590
|
+
provider: "smtp",
|
|
1591
|
+
rejectedRecipients: result.rejectedRecipients
|
|
1422
1592
|
};
|
|
1423
1593
|
} catch (error) {
|
|
1424
1594
|
options?.signal?.throwIfAborted();
|
|
@@ -1454,7 +1624,7 @@ var SmtpTransport = class {
|
|
|
1454
1624
|
signal?.throwIfAborted();
|
|
1455
1625
|
await connection.ehlo(signal);
|
|
1456
1626
|
signal?.throwIfAborted();
|
|
1457
|
-
if (
|
|
1627
|
+
if (connection.config.secure === false && (connection.config.requireTls === true || connection.capabilities.some((cap) => cap.toUpperCase().startsWith("STARTTLS")))) {
|
|
1458
1628
|
await connection.starttls(signal);
|
|
1459
1629
|
signal?.throwIfAborted();
|
|
1460
1630
|
await connection.ehlo(signal);
|
|
@@ -1463,7 +1633,7 @@ var SmtpTransport = class {
|
|
|
1463
1633
|
await connection.authenticate(signal);
|
|
1464
1634
|
}
|
|
1465
1635
|
async returnConnection(connection) {
|
|
1466
|
-
if (!
|
|
1636
|
+
if (!connection.config.pool) {
|
|
1467
1637
|
await connection.quit();
|
|
1468
1638
|
return;
|
|
1469
1639
|
}
|
|
@@ -1527,7 +1697,8 @@ function createSmtpFailure(message, error) {
|
|
|
1527
1697
|
attempts: 1,
|
|
1528
1698
|
providerDetails: {
|
|
1529
1699
|
command: error.command,
|
|
1530
|
-
response: error.response
|
|
1700
|
+
response: error.response,
|
|
1701
|
+
rejectedRecipients: error.rejectedRecipients
|
|
1531
1702
|
}
|
|
1532
1703
|
});
|
|
1533
1704
|
}
|
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
|
*/
|
|
@@ -408,6 +417,41 @@ interface SmtpTlsOptions {
|
|
|
408
417
|
* used internally by the SMTP transport implementation.
|
|
409
418
|
*/
|
|
410
419
|
//#endregion
|
|
420
|
+
//#region src/smtp-receipt.d.ts
|
|
421
|
+
/**
|
|
422
|
+
* An SMTP envelope recipient that the server rejected during an otherwise
|
|
423
|
+
* successful delivery.
|
|
424
|
+
*
|
|
425
|
+
* @since 0.5.3
|
|
426
|
+
*/
|
|
427
|
+
interface SmtpRejectedRecipient {
|
|
428
|
+
/** The rejected recipient address. */
|
|
429
|
+
readonly recipient: string;
|
|
430
|
+
/** The three-digit SMTP reply code. */
|
|
431
|
+
readonly code: number;
|
|
432
|
+
/** The SMTP server's reply text. */
|
|
433
|
+
readonly response: string;
|
|
434
|
+
/** Whether retrying delivery to this recipient may succeed. */
|
|
435
|
+
readonly retryable: boolean;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* A receipt returned by {@link SmtpTransport}.
|
|
439
|
+
*
|
|
440
|
+
* Successful receipts list any recipients rejected before the message was
|
|
441
|
+
* delivered to the remaining accepted recipients. Callers can retry delivery
|
|
442
|
+
* to entries marked as retryable without redelivering to accepted recipients.
|
|
443
|
+
*
|
|
444
|
+
* @since 0.5.3
|
|
445
|
+
*/
|
|
446
|
+
type SmtpReceipt = (Extract<Receipt<"smtp">, {
|
|
447
|
+
readonly successful: true;
|
|
448
|
+
}> & {
|
|
449
|
+
/** Recipients excluded from an otherwise successful delivery. */
|
|
450
|
+
readonly rejectedRecipients: readonly SmtpRejectedRecipient[];
|
|
451
|
+
}) | Extract<Receipt<"smtp">, {
|
|
452
|
+
readonly successful: false;
|
|
453
|
+
}>;
|
|
454
|
+
//#endregion
|
|
411
455
|
//#region src/smtp-transport.d.ts
|
|
412
456
|
/**
|
|
413
457
|
* SMTP transport implementation for sending emails via SMTP protocol.
|
|
@@ -495,7 +539,7 @@ declare class SmtpTransport implements Transport<"smtp">, AsyncDisposable {
|
|
|
495
539
|
* @throws {DOMException} If the operation is aborted through
|
|
496
540
|
* `options.signal`.
|
|
497
541
|
*/
|
|
498
|
-
send(message: Message, options?: TransportOptions): Promise<
|
|
542
|
+
send(message: Message, options?: TransportOptions): Promise<SmtpReceipt>;
|
|
499
543
|
/**
|
|
500
544
|
* Sends multiple email messages efficiently using a single SMTP connection.
|
|
501
545
|
*
|
|
@@ -526,7 +570,7 @@ declare class SmtpTransport implements Transport<"smtp">, AsyncDisposable {
|
|
|
526
570
|
* @throws {DOMException} If the operation is aborted through
|
|
527
571
|
* `options.signal`.
|
|
528
572
|
*/
|
|
529
|
-
sendMany(messages: Iterable<Message> | AsyncIterable<Message>, options?: TransportOptions): AsyncIterable<
|
|
573
|
+
sendMany(messages: Iterable<Message> | AsyncIterable<Message>, options?: TransportOptions): AsyncIterable<SmtpReceipt>;
|
|
530
574
|
private getConnection;
|
|
531
575
|
private connectAndSetup;
|
|
532
576
|
private returnConnection;
|
|
@@ -591,4 +635,4 @@ declare class SmtpAuthError extends Error {
|
|
|
591
635
|
*/
|
|
592
636
|
|
|
593
637
|
//#endregion
|
|
594
|
-
export { DkimAlgorithm, DkimCanonicalization, DkimConfig, DkimSignature, DkimSigningFailureAction, OAuth2TokenProvider, SmtpAuth, SmtpAuthError, SmtpConfig, SmtpOAuth2Auth, SmtpOAuth2RefreshAuth, SmtpOAuth2TokenAuth, SmtpTlsOptions, SmtpTransport, SmtpUserPassAuth };
|
|
638
|
+
export { DkimAlgorithm, DkimCanonicalization, DkimConfig, DkimSignature, DkimSigningFailureAction, OAuth2TokenProvider, SmtpAuth, SmtpAuthError, SmtpConfig, SmtpOAuth2Auth, SmtpOAuth2RefreshAuth, SmtpOAuth2TokenAuth, SmtpReceipt, SmtpRejectedRecipient, SmtpTlsOptions, SmtpTransport, SmtpUserPassAuth };
|
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
|
*/
|
|
@@ -408,6 +417,41 @@ interface SmtpTlsOptions {
|
|
|
408
417
|
* used internally by the SMTP transport implementation.
|
|
409
418
|
*/
|
|
410
419
|
//#endregion
|
|
420
|
+
//#region src/smtp-receipt.d.ts
|
|
421
|
+
/**
|
|
422
|
+
* An SMTP envelope recipient that the server rejected during an otherwise
|
|
423
|
+
* successful delivery.
|
|
424
|
+
*
|
|
425
|
+
* @since 0.5.3
|
|
426
|
+
*/
|
|
427
|
+
interface SmtpRejectedRecipient {
|
|
428
|
+
/** The rejected recipient address. */
|
|
429
|
+
readonly recipient: string;
|
|
430
|
+
/** The three-digit SMTP reply code. */
|
|
431
|
+
readonly code: number;
|
|
432
|
+
/** The SMTP server's reply text. */
|
|
433
|
+
readonly response: string;
|
|
434
|
+
/** Whether retrying delivery to this recipient may succeed. */
|
|
435
|
+
readonly retryable: boolean;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* A receipt returned by {@link SmtpTransport}.
|
|
439
|
+
*
|
|
440
|
+
* Successful receipts list any recipients rejected before the message was
|
|
441
|
+
* delivered to the remaining accepted recipients. Callers can retry delivery
|
|
442
|
+
* to entries marked as retryable without redelivering to accepted recipients.
|
|
443
|
+
*
|
|
444
|
+
* @since 0.5.3
|
|
445
|
+
*/
|
|
446
|
+
type SmtpReceipt = (Extract<Receipt<"smtp">, {
|
|
447
|
+
readonly successful: true;
|
|
448
|
+
}> & {
|
|
449
|
+
/** Recipients excluded from an otherwise successful delivery. */
|
|
450
|
+
readonly rejectedRecipients: readonly SmtpRejectedRecipient[];
|
|
451
|
+
}) | Extract<Receipt<"smtp">, {
|
|
452
|
+
readonly successful: false;
|
|
453
|
+
}>;
|
|
454
|
+
//#endregion
|
|
411
455
|
//#region src/smtp-transport.d.ts
|
|
412
456
|
/**
|
|
413
457
|
* SMTP transport implementation for sending emails via SMTP protocol.
|
|
@@ -495,7 +539,7 @@ declare class SmtpTransport implements Transport<"smtp">, AsyncDisposable {
|
|
|
495
539
|
* @throws {DOMException} If the operation is aborted through
|
|
496
540
|
* `options.signal`.
|
|
497
541
|
*/
|
|
498
|
-
send(message: Message, options?: TransportOptions): Promise<
|
|
542
|
+
send(message: Message, options?: TransportOptions): Promise<SmtpReceipt>;
|
|
499
543
|
/**
|
|
500
544
|
* Sends multiple email messages efficiently using a single SMTP connection.
|
|
501
545
|
*
|
|
@@ -526,7 +570,7 @@ declare class SmtpTransport implements Transport<"smtp">, AsyncDisposable {
|
|
|
526
570
|
* @throws {DOMException} If the operation is aborted through
|
|
527
571
|
* `options.signal`.
|
|
528
572
|
*/
|
|
529
|
-
sendMany(messages: Iterable<Message> | AsyncIterable<Message>, options?: TransportOptions): AsyncIterable<
|
|
573
|
+
sendMany(messages: Iterable<Message> | AsyncIterable<Message>, options?: TransportOptions): AsyncIterable<SmtpReceipt>;
|
|
530
574
|
private getConnection;
|
|
531
575
|
private connectAndSetup;
|
|
532
576
|
private returnConnection;
|
|
@@ -591,4 +635,4 @@ declare class SmtpAuthError extends Error {
|
|
|
591
635
|
*/
|
|
592
636
|
|
|
593
637
|
//#endregion
|
|
594
|
-
export { DkimAlgorithm, DkimCanonicalization, DkimConfig, DkimSignature, DkimSigningFailureAction, OAuth2TokenProvider, SmtpAuth, SmtpAuthError, SmtpConfig, SmtpOAuth2Auth, SmtpOAuth2RefreshAuth, SmtpOAuth2TokenAuth, SmtpTlsOptions, SmtpTransport, SmtpUserPassAuth };
|
|
638
|
+
export { DkimAlgorithm, DkimCanonicalization, DkimConfig, DkimSignature, DkimSigningFailureAction, OAuth2TokenProvider, SmtpAuth, SmtpAuthError, SmtpConfig, SmtpOAuth2Auth, SmtpOAuth2RefreshAuth, SmtpOAuth2TokenAuth, SmtpReceipt, SmtpRejectedRecipient, SmtpTlsOptions, SmtpTransport, SmtpUserPassAuth };
|
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;
|
|
@@ -491,6 +570,12 @@ var SmtpConnection = class {
|
|
|
491
570
|
}
|
|
492
571
|
async ehlo(signal) {
|
|
493
572
|
const response = await this.sendCommand(`EHLO ${this.config.localName}`, signal);
|
|
573
|
+
if (response.code === 500 || response.code === 502) {
|
|
574
|
+
const heloResponse = await this.sendCommand(`HELO ${this.config.localName}`, signal);
|
|
575
|
+
if (heloResponse.code !== 250) throw new Error(`HELO failed: ${heloResponse.message}`);
|
|
576
|
+
this.capabilities = [];
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
494
579
|
if (response.code !== 250) throw new Error(`EHLO failed: ${response.message}`);
|
|
495
580
|
this.capabilities = response.raw.split("\r\n").filter((line) => line.startsWith("250-") || line.startsWith("250 ")).map((line) => line.substring(4)).filter((line) => line.length > 0);
|
|
496
581
|
}
|
|
@@ -542,8 +627,8 @@ var SmtpConnection = class {
|
|
|
542
627
|
if (!auth) return;
|
|
543
628
|
if (this.authenticated) return;
|
|
544
629
|
if (!this.capabilities.some((cap) => cap.toUpperCase().startsWith("AUTH"))) throw new SmtpAuthError("Server does not support authentication.");
|
|
630
|
+
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
631
|
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
632
|
const mechanism = auth.method ?? selectOAuth2Mechanism(this.capabilities);
|
|
548
633
|
switch (mechanism) {
|
|
549
634
|
case "xoauth2":
|
|
@@ -647,10 +732,22 @@ var SmtpConnection = class {
|
|
|
647
732
|
async sendMessage(message, signal) {
|
|
648
733
|
const mailResponse = await this.sendCommand(`MAIL FROM:<${message.envelope.from}>`, signal);
|
|
649
734
|
if (mailResponse.code !== 250) throw new SmtpResponseError(`MAIL FROM failed: ${mailResponse.message}`, mailResponse.code, "MAIL FROM", mailResponse.message);
|
|
735
|
+
const rejectedRecipients = [];
|
|
650
736
|
for (const recipient of message.envelope.to) {
|
|
651
737
|
signal?.throwIfAborted();
|
|
652
738
|
const rcptResponse = await this.sendCommand(`RCPT TO:<${recipient}>`, signal);
|
|
653
|
-
if (rcptResponse.code
|
|
739
|
+
if (rcptResponse.code === 421) throw new SmtpResponseError(`RCPT TO failed for ${recipient}: ${rcptResponse.message}`, rcptResponse.code, "RCPT TO", rcptResponse.message);
|
|
740
|
+
if (rcptResponse.code !== 250 && rcptResponse.code !== 251) rejectedRecipients.push({
|
|
741
|
+
recipient,
|
|
742
|
+
code: rcptResponse.code,
|
|
743
|
+
response: rcptResponse.message,
|
|
744
|
+
retryable: rcptResponse.code >= 400 && rcptResponse.code < 500
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
if (rejectedRecipients.length > 0 && rejectedRecipients.length === message.envelope.to.length) {
|
|
748
|
+
const rejection = rejectedRecipients.find((item) => item.retryable) ?? rejectedRecipients[0];
|
|
749
|
+
const details = rejectedRecipients.map((item) => `${item.recipient}: ${item.code} ${item.response}`).join("; ");
|
|
750
|
+
throw new SmtpResponseError(`RCPT TO failed for every recipient: ${details}`, rejection.code, "RCPT TO", rejection.response, rejectedRecipients);
|
|
654
751
|
}
|
|
655
752
|
const dataResponse = await this.sendCommand("DATA", signal);
|
|
656
753
|
if (dataResponse.code !== 354) throw new SmtpResponseError(`DATA failed: ${dataResponse.message}`, dataResponse.code, "DATA", dataResponse.message);
|
|
@@ -658,7 +755,10 @@ var SmtpConnection = class {
|
|
|
658
755
|
const finalResponse = await this.sendCommand(`${content}\r\n.`, signal);
|
|
659
756
|
if (finalResponse.code !== 250) throw new SmtpResponseError(`Message send failed: ${finalResponse.message}`, finalResponse.code, "DATA_END", finalResponse.message);
|
|
660
757
|
const messageId = this.extractMessageId(finalResponse.message);
|
|
661
|
-
return
|
|
758
|
+
return {
|
|
759
|
+
messageId,
|
|
760
|
+
rejectedRecipients
|
|
761
|
+
};
|
|
662
762
|
}
|
|
663
763
|
extractMessageId(response) {
|
|
664
764
|
const match = response.match(/(?:Message-ID:|id=)[\s<]*([^>\s]+)/i);
|
|
@@ -714,6 +814,8 @@ var SmtpResponseError = class extends Error {
|
|
|
714
814
|
* The textual SMTP reply returned by the server.
|
|
715
815
|
*/
|
|
716
816
|
response;
|
|
817
|
+
/** Recipient-level failures collected for an unsuccessful transaction. */
|
|
818
|
+
rejectedRecipients;
|
|
717
819
|
/**
|
|
718
820
|
* Creates an SMTP response error.
|
|
719
821
|
*
|
|
@@ -721,13 +823,16 @@ var SmtpResponseError = class extends Error {
|
|
|
721
823
|
* @param code The numeric SMTP reply code.
|
|
722
824
|
* @param command The SMTP command that produced the reply.
|
|
723
825
|
* @param response The textual SMTP reply returned by the server.
|
|
826
|
+
* @param rejectedRecipients Recipient-level failures collected for the
|
|
827
|
+
* transaction.
|
|
724
828
|
*/
|
|
725
|
-
constructor(message, code, command, response) {
|
|
829
|
+
constructor(message, code, command, response, rejectedRecipients) {
|
|
726
830
|
super(message);
|
|
727
831
|
this.name = "SmtpResponseError";
|
|
728
832
|
this.code = code;
|
|
729
833
|
this.command = command;
|
|
730
834
|
this.response = response;
|
|
835
|
+
this.rejectedRecipients = rejectedRecipients;
|
|
731
836
|
}
|
|
732
837
|
};
|
|
733
838
|
/**
|
|
@@ -1029,6 +1134,15 @@ function arrayBufferToBase64(buffer) {
|
|
|
1029
1134
|
|
|
1030
1135
|
//#endregion
|
|
1031
1136
|
//#region src/message-converter.ts
|
|
1137
|
+
/**
|
|
1138
|
+
* Converts a message to its SMTP envelope and wire representation.
|
|
1139
|
+
*
|
|
1140
|
+
* @param message The message to convert.
|
|
1141
|
+
* @param dkimConfig Optional DKIM signing configuration.
|
|
1142
|
+
* @returns The converted SMTP message.
|
|
1143
|
+
* @throws {RangeError} If a header contains a token that cannot be folded
|
|
1144
|
+
* within the RFC 5322 hard line-length limit.
|
|
1145
|
+
*/
|
|
1032
1146
|
async function convertMessage(message, dkimConfig) {
|
|
1033
1147
|
const envelope = {
|
|
1034
1148
|
from: message.sender.address,
|
|
@@ -1060,11 +1174,11 @@ async function buildRawMessage(message) {
|
|
|
1060
1174
|
const hasHtml = "html" in message.content;
|
|
1061
1175
|
const hasText = "text" in message.content;
|
|
1062
1176
|
const isMultipart = hasAttachments || hasHtml && hasText;
|
|
1063
|
-
lines.push(
|
|
1064
|
-
lines.push(
|
|
1065
|
-
if (message.ccRecipients.length > 0) lines.push(
|
|
1066
|
-
if (message.replyRecipients.length > 0) lines.push(
|
|
1067
|
-
lines.push(
|
|
1177
|
+
lines.push(foldHeader("From", encodeAddress(message.sender)));
|
|
1178
|
+
lines.push(foldHeader("To", message.recipients.map(encodeAddress).join(", ")));
|
|
1179
|
+
if (message.ccRecipients.length > 0) lines.push(foldHeader("Cc", message.ccRecipients.map(encodeAddress).join(", ")));
|
|
1180
|
+
if (message.replyRecipients.length > 0) lines.push(foldHeader("Reply-To", message.replyRecipients.map(encodeAddress).join(", ")));
|
|
1181
|
+
lines.push(foldHeader("Subject", encodeHeaderValue(message.subject, true)));
|
|
1068
1182
|
lines.push(`Date: ${(/* @__PURE__ */ new Date()).toUTCString()}`);
|
|
1069
1183
|
lines.push(`Message-ID: <${generateMessageId()}>`);
|
|
1070
1184
|
if (message.priority !== "normal") {
|
|
@@ -1072,7 +1186,7 @@ async function buildRawMessage(message) {
|
|
|
1072
1186
|
lines.push(`X-Priority: ${priorityValue}`);
|
|
1073
1187
|
lines.push(`X-MSMail-Priority: ${message.priority === "high" ? "High" : "Low"}`);
|
|
1074
1188
|
}
|
|
1075
|
-
for (const [key, value] of message.headers) lines.push(
|
|
1189
|
+
for (const [key, value] of message.headers) lines.push(foldHeader(key, encodeHeaderValue(value)));
|
|
1076
1190
|
lines.push("MIME-Version: 1.0");
|
|
1077
1191
|
if (isMultipart) {
|
|
1078
1192
|
lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
|
|
@@ -1111,12 +1225,12 @@ async function buildRawMessage(message) {
|
|
|
1111
1225
|
for (const attachment of message.attachments) {
|
|
1112
1226
|
lines.push("");
|
|
1113
1227
|
lines.push(`--${boundary}`);
|
|
1114
|
-
lines.push(
|
|
1228
|
+
lines.push(foldHeader("Content-Type", `${attachment.contentType}; ${encodeMimeParameter("name", attachment.filename)}`));
|
|
1115
1229
|
lines.push("Content-Transfer-Encoding: base64");
|
|
1116
1230
|
if (attachment.inline) {
|
|
1117
|
-
lines.push(
|
|
1231
|
+
lines.push(foldHeader("Content-Disposition", `inline; ${encodeMimeParameter("filename", attachment.filename)}`));
|
|
1118
1232
|
lines.push(`Content-ID: <${attachment.contentId}>`);
|
|
1119
|
-
} else lines.push(
|
|
1233
|
+
} else lines.push(foldHeader("Content-Disposition", `attachment; ${encodeMimeParameter("filename", attachment.filename)}`));
|
|
1120
1234
|
lines.push("");
|
|
1121
1235
|
lines.push(encodeBase64(await attachment.content));
|
|
1122
1236
|
}
|
|
@@ -1145,32 +1259,85 @@ function generateMessageId() {
|
|
|
1145
1259
|
}
|
|
1146
1260
|
function encodeAddress(address) {
|
|
1147
1261
|
if (address.name == null) return address.address;
|
|
1148
|
-
const encodedDisplayName = encodeHeaderValue(address.name);
|
|
1262
|
+
const encodedDisplayName = encodeHeaderValue(address.name, true);
|
|
1149
1263
|
return `${encodedDisplayName} <${address.address}>`;
|
|
1150
1264
|
}
|
|
1151
|
-
function encodeHeaderValue(value) {
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
const
|
|
1265
|
+
function encodeHeaderValue(value, encodeLongAsciiWords = false) {
|
|
1266
|
+
const hasLongWord = value.split(/\s+/).some((word) => word.length > 60);
|
|
1267
|
+
if (!/^[\x20-\x7E]*$/.test(value) || encodeLongAsciiWords && hasLongWord) {
|
|
1268
|
+
const encodeWord = (text) => {
|
|
1269
|
+
const utf8Bytes = new TextEncoder().encode(text);
|
|
1270
|
+
const base64 = Buffer.from(utf8Bytes).toString("base64");
|
|
1271
|
+
return `=?UTF-8?B?${base64}?=`;
|
|
1272
|
+
};
|
|
1155
1273
|
const maxEncodedLength = 75;
|
|
1156
|
-
const encodedWord =
|
|
1274
|
+
const encodedWord = encodeWord(value);
|
|
1157
1275
|
if (encodedWord.length <= maxEncodedLength) return encodedWord;
|
|
1158
1276
|
const words = [];
|
|
1159
|
-
let
|
|
1160
|
-
for (
|
|
1161
|
-
const
|
|
1162
|
-
|
|
1163
|
-
if (testWord.length <= maxEncodedLength) currentBase64 += chunk;
|
|
1277
|
+
let currentText = "";
|
|
1278
|
+
for (const character of value) {
|
|
1279
|
+
const candidate = currentText + character;
|
|
1280
|
+
if (encodeWord(candidate).length <= maxEncodedLength) currentText = candidate;
|
|
1164
1281
|
else {
|
|
1165
|
-
if (
|
|
1166
|
-
|
|
1282
|
+
if (currentText.length > 0) words.push(encodeWord(currentText));
|
|
1283
|
+
currentText = character;
|
|
1167
1284
|
}
|
|
1168
1285
|
}
|
|
1169
|
-
if (
|
|
1286
|
+
if (currentText.length > 0) words.push(encodeWord(currentText));
|
|
1170
1287
|
return words.join(" ");
|
|
1171
1288
|
}
|
|
1172
1289
|
return value;
|
|
1173
1290
|
}
|
|
1291
|
+
function encodeMimeParameter(name, value) {
|
|
1292
|
+
const escapedValue = value.replace(/[\\"]/g, "\\$&");
|
|
1293
|
+
const quotedParameter = `${name}="${escapedValue}"`;
|
|
1294
|
+
if (/^[\x20-\x7E]*$/.test(value) && quotedParameter.length <= 60) return quotedParameter;
|
|
1295
|
+
const encodedBytes = Array.from(new TextEncoder().encode(value), (byte) => {
|
|
1296
|
+
const character = String.fromCharCode(byte);
|
|
1297
|
+
return /^[A-Za-z0-9!#$&+.^_`|~-]$/.test(character) ? character : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
|
|
1298
|
+
});
|
|
1299
|
+
const segments = [];
|
|
1300
|
+
let segment = "";
|
|
1301
|
+
for (const encodedByte of encodedBytes) {
|
|
1302
|
+
if (segment.length + encodedByte.length > 45) {
|
|
1303
|
+
segments.push(segment);
|
|
1304
|
+
segment = "";
|
|
1305
|
+
}
|
|
1306
|
+
segment += encodedByte;
|
|
1307
|
+
}
|
|
1308
|
+
if (segment.length > 0 || segments.length === 0) segments.push(segment);
|
|
1309
|
+
return segments.map((part, index) => `${name}*${index}*=${index === 0 ? "UTF-8''" : ""}${part}`).join("; ");
|
|
1310
|
+
}
|
|
1311
|
+
function foldHeader(name, value) {
|
|
1312
|
+
const recommendedLineLength = 78;
|
|
1313
|
+
const lines = [];
|
|
1314
|
+
let prefix = `${name}: `;
|
|
1315
|
+
let remaining = value;
|
|
1316
|
+
while (prefix.length + remaining.length > recommendedLineLength) {
|
|
1317
|
+
const availableLength = recommendedLineLength - prefix.length;
|
|
1318
|
+
let breakIndex = -1;
|
|
1319
|
+
for (let index = Math.min(availableLength, remaining.length - 1); index >= 0; index--) if (remaining[index] === " " || remaining[index] === " ") {
|
|
1320
|
+
breakIndex = index;
|
|
1321
|
+
break;
|
|
1322
|
+
}
|
|
1323
|
+
if (breakIndex < 0) {
|
|
1324
|
+
for (let index = Math.max(availableLength + 1, 0); index < remaining.length; index++) if (remaining[index] === " " || remaining[index] === " ") {
|
|
1325
|
+
breakIndex = index;
|
|
1326
|
+
break;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
if (breakIndex < 0) break;
|
|
1330
|
+
let whitespaceEnd = breakIndex + 1;
|
|
1331
|
+
while (whitespaceEnd < remaining.length && (remaining[whitespaceEnd] === " " || remaining[whitespaceEnd] === " ")) whitespaceEnd++;
|
|
1332
|
+
if (whitespaceEnd === remaining.length) break;
|
|
1333
|
+
lines.push(prefix + remaining.slice(0, breakIndex));
|
|
1334
|
+
prefix = remaining.slice(breakIndex, whitespaceEnd);
|
|
1335
|
+
remaining = remaining.slice(whitespaceEnd);
|
|
1336
|
+
}
|
|
1337
|
+
lines.push(prefix + remaining);
|
|
1338
|
+
if (lines.some((line) => line.length > 998)) throw new RangeError(`Header field ${name} contains a token too long to fold.`);
|
|
1339
|
+
return lines.join("\r\n");
|
|
1340
|
+
}
|
|
1174
1341
|
function encodeQuotedPrintable(text) {
|
|
1175
1342
|
const utf8Bytes = new TextEncoder().encode(text);
|
|
1176
1343
|
let result = "";
|
|
@@ -1301,12 +1468,13 @@ var SmtpTransport = class {
|
|
|
1301
1468
|
options?.signal?.throwIfAborted();
|
|
1302
1469
|
const smtpMessage = await convertMessage(message, this.config.dkim);
|
|
1303
1470
|
options?.signal?.throwIfAborted();
|
|
1304
|
-
const
|
|
1471
|
+
const result = await connection.sendMessage(smtpMessage, options?.signal);
|
|
1305
1472
|
await this.returnConnection(connection);
|
|
1306
1473
|
return {
|
|
1307
1474
|
successful: true,
|
|
1308
|
-
messageId,
|
|
1309
|
-
provider: "smtp"
|
|
1475
|
+
messageId: result.messageId,
|
|
1476
|
+
provider: "smtp",
|
|
1477
|
+
rejectedRecipients: result.rejectedRecipients
|
|
1310
1478
|
};
|
|
1311
1479
|
} catch (error) {
|
|
1312
1480
|
if (connection != null) await this.discardConnection(connection);
|
|
@@ -1370,11 +1538,12 @@ var SmtpTransport = class {
|
|
|
1370
1538
|
try {
|
|
1371
1539
|
const smtpMessage = await convertMessage(message, this.config.dkim);
|
|
1372
1540
|
options?.signal?.throwIfAborted();
|
|
1373
|
-
const
|
|
1541
|
+
const result = await connection.sendMessage(smtpMessage, options?.signal);
|
|
1374
1542
|
yield {
|
|
1375
1543
|
successful: true,
|
|
1376
|
-
messageId,
|
|
1377
|
-
provider: "smtp"
|
|
1544
|
+
messageId: result.messageId,
|
|
1545
|
+
provider: "smtp",
|
|
1546
|
+
rejectedRecipients: result.rejectedRecipients
|
|
1378
1547
|
};
|
|
1379
1548
|
} catch (error) {
|
|
1380
1549
|
options?.signal?.throwIfAborted();
|
|
@@ -1391,11 +1560,12 @@ var SmtpTransport = class {
|
|
|
1391
1560
|
try {
|
|
1392
1561
|
const smtpMessage = await convertMessage(message, this.config.dkim);
|
|
1393
1562
|
options?.signal?.throwIfAborted();
|
|
1394
|
-
const
|
|
1563
|
+
const result = await connection.sendMessage(smtpMessage, options?.signal);
|
|
1395
1564
|
yield {
|
|
1396
1565
|
successful: true,
|
|
1397
|
-
messageId,
|
|
1398
|
-
provider: "smtp"
|
|
1566
|
+
messageId: result.messageId,
|
|
1567
|
+
provider: "smtp",
|
|
1568
|
+
rejectedRecipients: result.rejectedRecipients
|
|
1399
1569
|
};
|
|
1400
1570
|
} catch (error) {
|
|
1401
1571
|
options?.signal?.throwIfAborted();
|
|
@@ -1431,7 +1601,7 @@ var SmtpTransport = class {
|
|
|
1431
1601
|
signal?.throwIfAborted();
|
|
1432
1602
|
await connection.ehlo(signal);
|
|
1433
1603
|
signal?.throwIfAborted();
|
|
1434
|
-
if (
|
|
1604
|
+
if (connection.config.secure === false && (connection.config.requireTls === true || connection.capabilities.some((cap) => cap.toUpperCase().startsWith("STARTTLS")))) {
|
|
1435
1605
|
await connection.starttls(signal);
|
|
1436
1606
|
signal?.throwIfAborted();
|
|
1437
1607
|
await connection.ehlo(signal);
|
|
@@ -1440,7 +1610,7 @@ var SmtpTransport = class {
|
|
|
1440
1610
|
await connection.authenticate(signal);
|
|
1441
1611
|
}
|
|
1442
1612
|
async returnConnection(connection) {
|
|
1443
|
-
if (!
|
|
1613
|
+
if (!connection.config.pool) {
|
|
1444
1614
|
await connection.quit();
|
|
1445
1615
|
return;
|
|
1446
1616
|
}
|
|
@@ -1504,7 +1674,8 @@ function createSmtpFailure(message, error) {
|
|
|
1504
1674
|
attempts: 1,
|
|
1505
1675
|
providerDetails: {
|
|
1506
1676
|
command: error.command,
|
|
1507
|
-
response: error.response
|
|
1677
|
+
response: error.response,
|
|
1678
|
+
rejectedRecipients: error.rejectedRecipients
|
|
1508
1679
|
}
|
|
1509
1680
|
});
|
|
1510
1681
|
}
|
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.271",
|
|
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.271+8deab0ad"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"tsdown": "^0.12.7",
|