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