@upyo/smtp 0.6.0-dev.269 → 0.6.0-dev.272
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 +1 -0
- package/dist/index.cjs +221 -50
- package/dist/index.d.cts +38 -3
- package/dist/index.d.ts +38 -3
- package/dist/index.js +221 -50
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ Features
|
|
|
26
26
|
- HTML and plain text email support
|
|
27
27
|
- File attachments (regular and inline)
|
|
28
28
|
- Multiple recipients (To, CC, BCC)
|
|
29
|
+
- SMTP PIPELINING for faster multi-recipient delivery
|
|
29
30
|
- Custom headers
|
|
30
31
|
- Priority levels
|
|
31
32
|
- Comprehensive testing utilities
|
package/dist/index.cjs
CHANGED
|
@@ -361,6 +361,16 @@ var OAuth2TokenManager = class {
|
|
|
361
361
|
|
|
362
362
|
//#endregion
|
|
363
363
|
//#region src/smtp-connection.ts
|
|
364
|
+
var SmtpPipelineTerminatedError = class extends Error {
|
|
365
|
+
responseIndex;
|
|
366
|
+
response;
|
|
367
|
+
constructor(responseIndex, response) {
|
|
368
|
+
super("SMTP pipeline terminated by the server.");
|
|
369
|
+
this.name = "SmtpPipelineTerminatedError";
|
|
370
|
+
this.responseIndex = responseIndex;
|
|
371
|
+
this.response = response;
|
|
372
|
+
}
|
|
373
|
+
};
|
|
364
374
|
/**
|
|
365
375
|
* The maximum length of an SMTP command line, including the terminating CRLF,
|
|
366
376
|
* as specified by RFC 5321 §4.5.3.1.4.
|
|
@@ -513,30 +523,59 @@ var SmtpConnection = class {
|
|
|
513
523
|
});
|
|
514
524
|
}
|
|
515
525
|
sendCommand(command, signal) {
|
|
526
|
+
return this.sendCommands([command], signal).then((responses) => responses[0]);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Sends a group of commands in one write and reads one reply per command.
|
|
530
|
+
*
|
|
531
|
+
* SMTP multiline replies are kept together and complete replies are matched
|
|
532
|
+
* to commands by their position in the returned array, as required for
|
|
533
|
+
* command pipelining by RFC 2920.
|
|
534
|
+
*/
|
|
535
|
+
sendCommands(commands, signal) {
|
|
516
536
|
if (!this.socket) throw new Error("Not connected");
|
|
517
537
|
signal?.throwIfAborted();
|
|
518
538
|
return new Promise((resolve, reject) => {
|
|
519
539
|
let buffer = "";
|
|
520
|
-
|
|
540
|
+
let responseLines = [];
|
|
541
|
+
const responses = [];
|
|
542
|
+
const startTimeout = () => setTimeout(() => {
|
|
543
|
+
cleanup();
|
|
521
544
|
reject(/* @__PURE__ */ new Error("Command timeout"));
|
|
522
545
|
}, this.config.socketTimeout);
|
|
546
|
+
let timeout = startTimeout();
|
|
547
|
+
const resetTimeout = () => {
|
|
548
|
+
clearTimeout(timeout);
|
|
549
|
+
timeout = startTimeout();
|
|
550
|
+
};
|
|
523
551
|
const onData = (data) => {
|
|
524
552
|
buffer += data.toString();
|
|
525
553
|
const lines = buffer.split("\r\n");
|
|
526
554
|
const incompleteLine = lines.pop() || "";
|
|
527
|
-
for (
|
|
528
|
-
|
|
555
|
+
for (const line of lines) {
|
|
556
|
+
responseLines.push(line);
|
|
529
557
|
if (line.length >= 4 && line[3] === " ") {
|
|
530
558
|
const code = parseInt(line.substring(0, 3), 10);
|
|
531
559
|
const message = line.substring(4);
|
|
532
|
-
const
|
|
533
|
-
cleanup();
|
|
534
|
-
resolve({
|
|
560
|
+
const response = {
|
|
535
561
|
code,
|
|
536
562
|
message,
|
|
537
|
-
raw:
|
|
538
|
-
}
|
|
539
|
-
|
|
563
|
+
raw: responseLines.join("\r\n")
|
|
564
|
+
};
|
|
565
|
+
const responseIndex = responses.length;
|
|
566
|
+
responses.push(response);
|
|
567
|
+
responseLines = [];
|
|
568
|
+
if (responses.length === commands.length) {
|
|
569
|
+
cleanup();
|
|
570
|
+
resolve(responses);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (response.code === 421) {
|
|
574
|
+
cleanup();
|
|
575
|
+
reject(new SmtpPipelineTerminatedError(responseIndex, response));
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
resetTimeout();
|
|
540
579
|
}
|
|
541
580
|
}
|
|
542
581
|
buffer = incompleteLine;
|
|
@@ -545,14 +584,35 @@ var SmtpConnection = class {
|
|
|
545
584
|
cleanup();
|
|
546
585
|
reject(error);
|
|
547
586
|
};
|
|
587
|
+
const onClose = () => {
|
|
588
|
+
cleanup();
|
|
589
|
+
const responseIndex = responses.findIndex((response) => response.code >= 400);
|
|
590
|
+
if (responseIndex >= 0) {
|
|
591
|
+
reject(new SmtpPipelineTerminatedError(responseIndex, responses[responseIndex]));
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
reject(/* @__PURE__ */ new Error("Connection closed before all command responses."));
|
|
595
|
+
};
|
|
596
|
+
const onAbort = () => {
|
|
597
|
+
cleanup();
|
|
598
|
+
try {
|
|
599
|
+
signal?.throwIfAborted();
|
|
600
|
+
} catch (error) {
|
|
601
|
+
reject(error);
|
|
602
|
+
}
|
|
603
|
+
};
|
|
548
604
|
const cleanup = () => {
|
|
549
605
|
clearTimeout(timeout);
|
|
550
606
|
this.socket?.off("data", onData);
|
|
551
607
|
this.socket?.off("error", onError);
|
|
608
|
+
this.socket?.off("close", onClose);
|
|
609
|
+
signal?.removeEventListener("abort", onAbort);
|
|
552
610
|
};
|
|
553
611
|
this.socket.on("data", onData);
|
|
554
612
|
this.socket.on("error", onError);
|
|
555
|
-
this.socket.
|
|
613
|
+
this.socket.on("close", onClose);
|
|
614
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
615
|
+
this.socket.write(commands.map((command) => `${command}\r\n`).join(""));
|
|
556
616
|
});
|
|
557
617
|
}
|
|
558
618
|
greeting(signal) {
|
|
@@ -593,8 +653,14 @@ var SmtpConnection = class {
|
|
|
593
653
|
}
|
|
594
654
|
async ehlo(signal) {
|
|
595
655
|
const response = await this.sendCommand(`EHLO ${this.config.localName}`, signal);
|
|
656
|
+
if (response.code === 500 || response.code === 502) {
|
|
657
|
+
const heloResponse = await this.sendCommand(`HELO ${this.config.localName}`, signal);
|
|
658
|
+
if (heloResponse.code !== 250) throw new Error(`HELO failed: ${heloResponse.message}`);
|
|
659
|
+
this.capabilities = [];
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
596
662
|
if (response.code !== 250) throw new Error(`EHLO failed: ${response.message}`);
|
|
597
|
-
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);
|
|
663
|
+
this.capabilities = response.raw.split("\r\n").filter((line) => line.startsWith("250-") || line.startsWith("250 ")).slice(1).map((line) => line.substring(4)).filter((line) => line.length > 0);
|
|
598
664
|
}
|
|
599
665
|
async starttls(signal) {
|
|
600
666
|
if (!this.socket) throw new Error("Not connected");
|
|
@@ -747,12 +813,43 @@ var SmtpConnection = class {
|
|
|
747
813
|
throw new SmtpAuthError(`${mechanism} authentication failed: ${response.message}`);
|
|
748
814
|
}
|
|
749
815
|
async sendMessage(message, signal) {
|
|
750
|
-
const
|
|
816
|
+
const mailCommand = `MAIL FROM:<${message.envelope.from}>`;
|
|
817
|
+
const recipientCommands = message.envelope.to.map((recipient) => `RCPT TO:<${recipient}>`);
|
|
818
|
+
const pipelining = this.capabilities.some((capability) => /^PIPELINING(?:\s|$)/i.test(capability));
|
|
819
|
+
let mailResponse;
|
|
820
|
+
let recipientResponses;
|
|
821
|
+
if (pipelining) try {
|
|
822
|
+
const envelopeResponses = await this.sendCommands([mailCommand, ...recipientCommands], signal);
|
|
823
|
+
mailResponse = envelopeResponses[0];
|
|
824
|
+
recipientResponses = envelopeResponses.slice(1);
|
|
825
|
+
} catch (error) {
|
|
826
|
+
if (!(error instanceof SmtpPipelineTerminatedError)) throw error;
|
|
827
|
+
const { response, responseIndex } = error;
|
|
828
|
+
if (responseIndex === 0) throw new SmtpResponseError(`MAIL FROM failed: ${response.message}`, response.code, "MAIL FROM", response.message);
|
|
829
|
+
const recipient = message.envelope.to[responseIndex - 1];
|
|
830
|
+
throw new SmtpResponseError(`RCPT TO failed for ${recipient}: ${response.message}`, response.code, "RCPT TO", response.message);
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
mailResponse = await this.sendCommand(mailCommand, signal);
|
|
834
|
+
recipientResponses = [];
|
|
835
|
+
}
|
|
751
836
|
if (mailResponse.code !== 250) throw new SmtpResponseError(`MAIL FROM failed: ${mailResponse.message}`, mailResponse.code, "MAIL FROM", mailResponse.message);
|
|
752
|
-
|
|
837
|
+
const rejectedRecipients = [];
|
|
838
|
+
for (const [index, recipient] of message.envelope.to.entries()) {
|
|
753
839
|
signal?.throwIfAborted();
|
|
754
|
-
const rcptResponse = await this.sendCommand(
|
|
755
|
-
if (rcptResponse.code
|
|
840
|
+
const rcptResponse = pipelining ? recipientResponses[index] : await this.sendCommand(recipientCommands[index], signal);
|
|
841
|
+
if (rcptResponse.code === 421) throw new SmtpResponseError(`RCPT TO failed for ${recipient}: ${rcptResponse.message}`, rcptResponse.code, "RCPT TO", rcptResponse.message);
|
|
842
|
+
if (rcptResponse.code !== 250 && rcptResponse.code !== 251) rejectedRecipients.push({
|
|
843
|
+
recipient,
|
|
844
|
+
code: rcptResponse.code,
|
|
845
|
+
response: rcptResponse.message,
|
|
846
|
+
retryable: rcptResponse.code >= 400 && rcptResponse.code < 500
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
if (rejectedRecipients.length > 0 && rejectedRecipients.length === message.envelope.to.length) {
|
|
850
|
+
const rejection = rejectedRecipients.find((item) => item.retryable) ?? rejectedRecipients[0];
|
|
851
|
+
const details = rejectedRecipients.map((item) => `${item.recipient}: ${item.code} ${item.response}`).join("; ");
|
|
852
|
+
throw new SmtpResponseError(`RCPT TO failed for every recipient: ${details}`, rejection.code, "RCPT TO", rejection.response, rejectedRecipients);
|
|
756
853
|
}
|
|
757
854
|
const dataResponse = await this.sendCommand("DATA", signal);
|
|
758
855
|
if (dataResponse.code !== 354) throw new SmtpResponseError(`DATA failed: ${dataResponse.message}`, dataResponse.code, "DATA", dataResponse.message);
|
|
@@ -760,7 +857,10 @@ var SmtpConnection = class {
|
|
|
760
857
|
const finalResponse = await this.sendCommand(`${content}\r\n.`, signal);
|
|
761
858
|
if (finalResponse.code !== 250) throw new SmtpResponseError(`Message send failed: ${finalResponse.message}`, finalResponse.code, "DATA_END", finalResponse.message);
|
|
762
859
|
const messageId = this.extractMessageId(finalResponse.message);
|
|
763
|
-
return
|
|
860
|
+
return {
|
|
861
|
+
messageId,
|
|
862
|
+
rejectedRecipients
|
|
863
|
+
};
|
|
764
864
|
}
|
|
765
865
|
extractMessageId(response) {
|
|
766
866
|
const match = response.match(/(?:Message-ID:|id=)[\s<]*([^>\s]+)/i);
|
|
@@ -816,6 +916,8 @@ var SmtpResponseError = class extends Error {
|
|
|
816
916
|
* The textual SMTP reply returned by the server.
|
|
817
917
|
*/
|
|
818
918
|
response;
|
|
919
|
+
/** Recipient-level failures collected for an unsuccessful transaction. */
|
|
920
|
+
rejectedRecipients;
|
|
819
921
|
/**
|
|
820
922
|
* Creates an SMTP response error.
|
|
821
923
|
*
|
|
@@ -823,13 +925,16 @@ var SmtpResponseError = class extends Error {
|
|
|
823
925
|
* @param code The numeric SMTP reply code.
|
|
824
926
|
* @param command The SMTP command that produced the reply.
|
|
825
927
|
* @param response The textual SMTP reply returned by the server.
|
|
928
|
+
* @param rejectedRecipients Recipient-level failures collected for the
|
|
929
|
+
* transaction.
|
|
826
930
|
*/
|
|
827
|
-
constructor(message, code, command, response) {
|
|
931
|
+
constructor(message, code, command, response, rejectedRecipients) {
|
|
828
932
|
super(message);
|
|
829
933
|
this.name = "SmtpResponseError";
|
|
830
934
|
this.code = code;
|
|
831
935
|
this.command = command;
|
|
832
936
|
this.response = response;
|
|
937
|
+
this.rejectedRecipients = rejectedRecipients;
|
|
833
938
|
}
|
|
834
939
|
};
|
|
835
940
|
/**
|
|
@@ -1131,6 +1236,15 @@ function arrayBufferToBase64(buffer) {
|
|
|
1131
1236
|
|
|
1132
1237
|
//#endregion
|
|
1133
1238
|
//#region src/message-converter.ts
|
|
1239
|
+
/**
|
|
1240
|
+
* Converts a message to its SMTP envelope and wire representation.
|
|
1241
|
+
*
|
|
1242
|
+
* @param message The message to convert.
|
|
1243
|
+
* @param dkimConfig Optional DKIM signing configuration.
|
|
1244
|
+
* @returns The converted SMTP message.
|
|
1245
|
+
* @throws {RangeError} If a header contains a token that cannot be folded
|
|
1246
|
+
* within the RFC 5322 hard line-length limit.
|
|
1247
|
+
*/
|
|
1134
1248
|
async function convertMessage(message, dkimConfig) {
|
|
1135
1249
|
const envelope = {
|
|
1136
1250
|
from: message.sender.address,
|
|
@@ -1162,11 +1276,11 @@ async function buildRawMessage(message) {
|
|
|
1162
1276
|
const hasHtml = "html" in message.content;
|
|
1163
1277
|
const hasText = "text" in message.content;
|
|
1164
1278
|
const isMultipart = hasAttachments || hasHtml && hasText;
|
|
1165
|
-
lines.push(
|
|
1166
|
-
lines.push(
|
|
1167
|
-
if (message.ccRecipients.length > 0) lines.push(
|
|
1168
|
-
if (message.replyRecipients.length > 0) lines.push(
|
|
1169
|
-
lines.push(
|
|
1279
|
+
lines.push(foldHeader("From", encodeAddress(message.sender)));
|
|
1280
|
+
lines.push(foldHeader("To", message.recipients.map(encodeAddress).join(", ")));
|
|
1281
|
+
if (message.ccRecipients.length > 0) lines.push(foldHeader("Cc", message.ccRecipients.map(encodeAddress).join(", ")));
|
|
1282
|
+
if (message.replyRecipients.length > 0) lines.push(foldHeader("Reply-To", message.replyRecipients.map(encodeAddress).join(", ")));
|
|
1283
|
+
lines.push(foldHeader("Subject", encodeHeaderValue(message.subject, true)));
|
|
1170
1284
|
lines.push(`Date: ${(/* @__PURE__ */ new Date()).toUTCString()}`);
|
|
1171
1285
|
lines.push(`Message-ID: <${generateMessageId()}>`);
|
|
1172
1286
|
if (message.priority !== "normal") {
|
|
@@ -1174,7 +1288,7 @@ async function buildRawMessage(message) {
|
|
|
1174
1288
|
lines.push(`X-Priority: ${priorityValue}`);
|
|
1175
1289
|
lines.push(`X-MSMail-Priority: ${message.priority === "high" ? "High" : "Low"}`);
|
|
1176
1290
|
}
|
|
1177
|
-
for (const [key, value] of message.headers) lines.push(
|
|
1291
|
+
for (const [key, value] of message.headers) lines.push(foldHeader(key, encodeHeaderValue(value)));
|
|
1178
1292
|
lines.push("MIME-Version: 1.0");
|
|
1179
1293
|
if (isMultipart) {
|
|
1180
1294
|
lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
|
|
@@ -1213,12 +1327,12 @@ async function buildRawMessage(message) {
|
|
|
1213
1327
|
for (const attachment of message.attachments) {
|
|
1214
1328
|
lines.push("");
|
|
1215
1329
|
lines.push(`--${boundary}`);
|
|
1216
|
-
lines.push(
|
|
1330
|
+
lines.push(foldHeader("Content-Type", `${attachment.contentType}; ${encodeMimeParameter("name", attachment.filename)}`));
|
|
1217
1331
|
lines.push("Content-Transfer-Encoding: base64");
|
|
1218
1332
|
if (attachment.inline) {
|
|
1219
|
-
lines.push(
|
|
1333
|
+
lines.push(foldHeader("Content-Disposition", `inline; ${encodeMimeParameter("filename", attachment.filename)}`));
|
|
1220
1334
|
lines.push(`Content-ID: <${attachment.contentId}>`);
|
|
1221
|
-
} else lines.push(
|
|
1335
|
+
} else lines.push(foldHeader("Content-Disposition", `attachment; ${encodeMimeParameter("filename", attachment.filename)}`));
|
|
1222
1336
|
lines.push("");
|
|
1223
1337
|
lines.push(encodeBase64(await attachment.content));
|
|
1224
1338
|
}
|
|
@@ -1247,32 +1361,85 @@ function generateMessageId() {
|
|
|
1247
1361
|
}
|
|
1248
1362
|
function encodeAddress(address) {
|
|
1249
1363
|
if (address.name == null) return address.address;
|
|
1250
|
-
const encodedDisplayName = encodeHeaderValue(address.name);
|
|
1364
|
+
const encodedDisplayName = encodeHeaderValue(address.name, true);
|
|
1251
1365
|
return `${encodedDisplayName} <${address.address}>`;
|
|
1252
1366
|
}
|
|
1253
|
-
function encodeHeaderValue(value) {
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
const
|
|
1367
|
+
function encodeHeaderValue(value, encodeLongAsciiWords = false) {
|
|
1368
|
+
const hasLongWord = value.split(/\s+/).some((word) => word.length > 60);
|
|
1369
|
+
if (!/^[\x20-\x7E]*$/.test(value) || encodeLongAsciiWords && hasLongWord) {
|
|
1370
|
+
const encodeWord = (text) => {
|
|
1371
|
+
const utf8Bytes = new TextEncoder().encode(text);
|
|
1372
|
+
const base64 = node_buffer.Buffer.from(utf8Bytes).toString("base64");
|
|
1373
|
+
return `=?UTF-8?B?${base64}?=`;
|
|
1374
|
+
};
|
|
1257
1375
|
const maxEncodedLength = 75;
|
|
1258
|
-
const encodedWord =
|
|
1376
|
+
const encodedWord = encodeWord(value);
|
|
1259
1377
|
if (encodedWord.length <= maxEncodedLength) return encodedWord;
|
|
1260
1378
|
const words = [];
|
|
1261
|
-
let
|
|
1262
|
-
for (
|
|
1263
|
-
const
|
|
1264
|
-
|
|
1265
|
-
if (testWord.length <= maxEncodedLength) currentBase64 += chunk;
|
|
1379
|
+
let currentText = "";
|
|
1380
|
+
for (const character of value) {
|
|
1381
|
+
const candidate = currentText + character;
|
|
1382
|
+
if (encodeWord(candidate).length <= maxEncodedLength) currentText = candidate;
|
|
1266
1383
|
else {
|
|
1267
|
-
if (
|
|
1268
|
-
|
|
1384
|
+
if (currentText.length > 0) words.push(encodeWord(currentText));
|
|
1385
|
+
currentText = character;
|
|
1269
1386
|
}
|
|
1270
1387
|
}
|
|
1271
|
-
if (
|
|
1388
|
+
if (currentText.length > 0) words.push(encodeWord(currentText));
|
|
1272
1389
|
return words.join(" ");
|
|
1273
1390
|
}
|
|
1274
1391
|
return value;
|
|
1275
1392
|
}
|
|
1393
|
+
function encodeMimeParameter(name, value) {
|
|
1394
|
+
const escapedValue = value.replace(/[\\"]/g, "\\$&");
|
|
1395
|
+
const quotedParameter = `${name}="${escapedValue}"`;
|
|
1396
|
+
if (/^[\x20-\x7E]*$/.test(value) && quotedParameter.length <= 60) return quotedParameter;
|
|
1397
|
+
const encodedBytes = Array.from(new TextEncoder().encode(value), (byte) => {
|
|
1398
|
+
const character = String.fromCharCode(byte);
|
|
1399
|
+
return /^[A-Za-z0-9!#$&+.^_`|~-]$/.test(character) ? character : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
|
|
1400
|
+
});
|
|
1401
|
+
const segments = [];
|
|
1402
|
+
let segment = "";
|
|
1403
|
+
for (const encodedByte of encodedBytes) {
|
|
1404
|
+
if (segment.length + encodedByte.length > 45) {
|
|
1405
|
+
segments.push(segment);
|
|
1406
|
+
segment = "";
|
|
1407
|
+
}
|
|
1408
|
+
segment += encodedByte;
|
|
1409
|
+
}
|
|
1410
|
+
if (segment.length > 0 || segments.length === 0) segments.push(segment);
|
|
1411
|
+
return segments.map((part, index) => `${name}*${index}*=${index === 0 ? "UTF-8''" : ""}${part}`).join("; ");
|
|
1412
|
+
}
|
|
1413
|
+
function foldHeader(name, value) {
|
|
1414
|
+
const recommendedLineLength = 78;
|
|
1415
|
+
const lines = [];
|
|
1416
|
+
let prefix = `${name}: `;
|
|
1417
|
+
let remaining = value;
|
|
1418
|
+
while (prefix.length + remaining.length > recommendedLineLength) {
|
|
1419
|
+
const availableLength = recommendedLineLength - prefix.length;
|
|
1420
|
+
let breakIndex = -1;
|
|
1421
|
+
for (let index = Math.min(availableLength, remaining.length - 1); index >= 0; index--) if (remaining[index] === " " || remaining[index] === " ") {
|
|
1422
|
+
breakIndex = index;
|
|
1423
|
+
break;
|
|
1424
|
+
}
|
|
1425
|
+
if (breakIndex < 0) {
|
|
1426
|
+
for (let index = Math.max(availableLength + 1, 0); index < remaining.length; index++) if (remaining[index] === " " || remaining[index] === " ") {
|
|
1427
|
+
breakIndex = index;
|
|
1428
|
+
break;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
if (breakIndex < 0) break;
|
|
1432
|
+
let whitespaceEnd = breakIndex + 1;
|
|
1433
|
+
while (whitespaceEnd < remaining.length && (remaining[whitespaceEnd] === " " || remaining[whitespaceEnd] === " ")) whitespaceEnd++;
|
|
1434
|
+
if (whitespaceEnd === remaining.length) break;
|
|
1435
|
+
lines.push(prefix + remaining.slice(0, breakIndex));
|
|
1436
|
+
prefix = remaining.slice(breakIndex, whitespaceEnd);
|
|
1437
|
+
remaining = remaining.slice(whitespaceEnd);
|
|
1438
|
+
}
|
|
1439
|
+
lines.push(prefix + remaining);
|
|
1440
|
+
if (lines.some((line) => line.length > 998)) throw new RangeError(`Header field ${name} contains a token too long to fold.`);
|
|
1441
|
+
return lines.join("\r\n");
|
|
1442
|
+
}
|
|
1276
1443
|
function encodeQuotedPrintable(text) {
|
|
1277
1444
|
const utf8Bytes = new TextEncoder().encode(text);
|
|
1278
1445
|
let result = "";
|
|
@@ -1403,12 +1570,13 @@ var SmtpTransport = class {
|
|
|
1403
1570
|
options?.signal?.throwIfAborted();
|
|
1404
1571
|
const smtpMessage = await convertMessage(message, this.config.dkim);
|
|
1405
1572
|
options?.signal?.throwIfAborted();
|
|
1406
|
-
const
|
|
1573
|
+
const result = await connection.sendMessage(smtpMessage, options?.signal);
|
|
1407
1574
|
await this.returnConnection(connection);
|
|
1408
1575
|
return {
|
|
1409
1576
|
successful: true,
|
|
1410
|
-
messageId,
|
|
1411
|
-
provider: "smtp"
|
|
1577
|
+
messageId: result.messageId,
|
|
1578
|
+
provider: "smtp",
|
|
1579
|
+
rejectedRecipients: result.rejectedRecipients
|
|
1412
1580
|
};
|
|
1413
1581
|
} catch (error) {
|
|
1414
1582
|
if (connection != null) await this.discardConnection(connection);
|
|
@@ -1472,11 +1640,12 @@ var SmtpTransport = class {
|
|
|
1472
1640
|
try {
|
|
1473
1641
|
const smtpMessage = await convertMessage(message, this.config.dkim);
|
|
1474
1642
|
options?.signal?.throwIfAborted();
|
|
1475
|
-
const
|
|
1643
|
+
const result = await connection.sendMessage(smtpMessage, options?.signal);
|
|
1476
1644
|
yield {
|
|
1477
1645
|
successful: true,
|
|
1478
|
-
messageId,
|
|
1479
|
-
provider: "smtp"
|
|
1646
|
+
messageId: result.messageId,
|
|
1647
|
+
provider: "smtp",
|
|
1648
|
+
rejectedRecipients: result.rejectedRecipients
|
|
1480
1649
|
};
|
|
1481
1650
|
} catch (error) {
|
|
1482
1651
|
options?.signal?.throwIfAborted();
|
|
@@ -1493,11 +1662,12 @@ var SmtpTransport = class {
|
|
|
1493
1662
|
try {
|
|
1494
1663
|
const smtpMessage = await convertMessage(message, this.config.dkim);
|
|
1495
1664
|
options?.signal?.throwIfAborted();
|
|
1496
|
-
const
|
|
1665
|
+
const result = await connection.sendMessage(smtpMessage, options?.signal);
|
|
1497
1666
|
yield {
|
|
1498
1667
|
successful: true,
|
|
1499
|
-
messageId,
|
|
1500
|
-
provider: "smtp"
|
|
1668
|
+
messageId: result.messageId,
|
|
1669
|
+
provider: "smtp",
|
|
1670
|
+
rejectedRecipients: result.rejectedRecipients
|
|
1501
1671
|
};
|
|
1502
1672
|
} catch (error) {
|
|
1503
1673
|
options?.signal?.throwIfAborted();
|
|
@@ -1606,7 +1776,8 @@ function createSmtpFailure(message, error) {
|
|
|
1606
1776
|
attempts: 1,
|
|
1607
1777
|
providerDetails: {
|
|
1608
1778
|
command: error.command,
|
|
1609
|
-
response: error.response
|
|
1779
|
+
response: error.response,
|
|
1780
|
+
rejectedRecipients: error.rejectedRecipients
|
|
1610
1781
|
}
|
|
1611
1782
|
});
|
|
1612
1783
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -417,6 +417,41 @@ interface SmtpTlsOptions {
|
|
|
417
417
|
* used internally by the SMTP transport implementation.
|
|
418
418
|
*/
|
|
419
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
|
|
420
455
|
//#region src/smtp-transport.d.ts
|
|
421
456
|
/**
|
|
422
457
|
* SMTP transport implementation for sending emails via SMTP protocol.
|
|
@@ -504,7 +539,7 @@ declare class SmtpTransport implements Transport<"smtp">, AsyncDisposable {
|
|
|
504
539
|
* @throws {DOMException} If the operation is aborted through
|
|
505
540
|
* `options.signal`.
|
|
506
541
|
*/
|
|
507
|
-
send(message: Message, options?: TransportOptions): Promise<
|
|
542
|
+
send(message: Message, options?: TransportOptions): Promise<SmtpReceipt>;
|
|
508
543
|
/**
|
|
509
544
|
* Sends multiple email messages efficiently using a single SMTP connection.
|
|
510
545
|
*
|
|
@@ -535,7 +570,7 @@ declare class SmtpTransport implements Transport<"smtp">, AsyncDisposable {
|
|
|
535
570
|
* @throws {DOMException} If the operation is aborted through
|
|
536
571
|
* `options.signal`.
|
|
537
572
|
*/
|
|
538
|
-
sendMany(messages: Iterable<Message> | AsyncIterable<Message>, options?: TransportOptions): AsyncIterable<
|
|
573
|
+
sendMany(messages: Iterable<Message> | AsyncIterable<Message>, options?: TransportOptions): AsyncIterable<SmtpReceipt>;
|
|
539
574
|
private getConnection;
|
|
540
575
|
private connectAndSetup;
|
|
541
576
|
private returnConnection;
|
|
@@ -600,4 +635,4 @@ declare class SmtpAuthError extends Error {
|
|
|
600
635
|
*/
|
|
601
636
|
|
|
602
637
|
//#endregion
|
|
603
|
-
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
|
@@ -417,6 +417,41 @@ interface SmtpTlsOptions {
|
|
|
417
417
|
* used internally by the SMTP transport implementation.
|
|
418
418
|
*/
|
|
419
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
|
|
420
455
|
//#region src/smtp-transport.d.ts
|
|
421
456
|
/**
|
|
422
457
|
* SMTP transport implementation for sending emails via SMTP protocol.
|
|
@@ -504,7 +539,7 @@ declare class SmtpTransport implements Transport<"smtp">, AsyncDisposable {
|
|
|
504
539
|
* @throws {DOMException} If the operation is aborted through
|
|
505
540
|
* `options.signal`.
|
|
506
541
|
*/
|
|
507
|
-
send(message: Message, options?: TransportOptions): Promise<
|
|
542
|
+
send(message: Message, options?: TransportOptions): Promise<SmtpReceipt>;
|
|
508
543
|
/**
|
|
509
544
|
* Sends multiple email messages efficiently using a single SMTP connection.
|
|
510
545
|
*
|
|
@@ -535,7 +570,7 @@ declare class SmtpTransport implements Transport<"smtp">, AsyncDisposable {
|
|
|
535
570
|
* @throws {DOMException} If the operation is aborted through
|
|
536
571
|
* `options.signal`.
|
|
537
572
|
*/
|
|
538
|
-
sendMany(messages: Iterable<Message> | AsyncIterable<Message>, options?: TransportOptions): AsyncIterable<
|
|
573
|
+
sendMany(messages: Iterable<Message> | AsyncIterable<Message>, options?: TransportOptions): AsyncIterable<SmtpReceipt>;
|
|
539
574
|
private getConnection;
|
|
540
575
|
private connectAndSetup;
|
|
541
576
|
private returnConnection;
|
|
@@ -600,4 +635,4 @@ declare class SmtpAuthError extends Error {
|
|
|
600
635
|
*/
|
|
601
636
|
|
|
602
637
|
//#endregion
|
|
603
|
-
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
|
@@ -338,6 +338,16 @@ var OAuth2TokenManager = class {
|
|
|
338
338
|
|
|
339
339
|
//#endregion
|
|
340
340
|
//#region src/smtp-connection.ts
|
|
341
|
+
var SmtpPipelineTerminatedError = class extends Error {
|
|
342
|
+
responseIndex;
|
|
343
|
+
response;
|
|
344
|
+
constructor(responseIndex, response) {
|
|
345
|
+
super("SMTP pipeline terminated by the server.");
|
|
346
|
+
this.name = "SmtpPipelineTerminatedError";
|
|
347
|
+
this.responseIndex = responseIndex;
|
|
348
|
+
this.response = response;
|
|
349
|
+
}
|
|
350
|
+
};
|
|
341
351
|
/**
|
|
342
352
|
* The maximum length of an SMTP command line, including the terminating CRLF,
|
|
343
353
|
* as specified by RFC 5321 §4.5.3.1.4.
|
|
@@ -490,30 +500,59 @@ var SmtpConnection = class {
|
|
|
490
500
|
});
|
|
491
501
|
}
|
|
492
502
|
sendCommand(command, signal) {
|
|
503
|
+
return this.sendCommands([command], signal).then((responses) => responses[0]);
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Sends a group of commands in one write and reads one reply per command.
|
|
507
|
+
*
|
|
508
|
+
* SMTP multiline replies are kept together and complete replies are matched
|
|
509
|
+
* to commands by their position in the returned array, as required for
|
|
510
|
+
* command pipelining by RFC 2920.
|
|
511
|
+
*/
|
|
512
|
+
sendCommands(commands, signal) {
|
|
493
513
|
if (!this.socket) throw new Error("Not connected");
|
|
494
514
|
signal?.throwIfAborted();
|
|
495
515
|
return new Promise((resolve, reject) => {
|
|
496
516
|
let buffer = "";
|
|
497
|
-
|
|
517
|
+
let responseLines = [];
|
|
518
|
+
const responses = [];
|
|
519
|
+
const startTimeout = () => setTimeout(() => {
|
|
520
|
+
cleanup();
|
|
498
521
|
reject(/* @__PURE__ */ new Error("Command timeout"));
|
|
499
522
|
}, this.config.socketTimeout);
|
|
523
|
+
let timeout = startTimeout();
|
|
524
|
+
const resetTimeout = () => {
|
|
525
|
+
clearTimeout(timeout);
|
|
526
|
+
timeout = startTimeout();
|
|
527
|
+
};
|
|
500
528
|
const onData = (data) => {
|
|
501
529
|
buffer += data.toString();
|
|
502
530
|
const lines = buffer.split("\r\n");
|
|
503
531
|
const incompleteLine = lines.pop() || "";
|
|
504
|
-
for (
|
|
505
|
-
|
|
532
|
+
for (const line of lines) {
|
|
533
|
+
responseLines.push(line);
|
|
506
534
|
if (line.length >= 4 && line[3] === " ") {
|
|
507
535
|
const code = parseInt(line.substring(0, 3), 10);
|
|
508
536
|
const message = line.substring(4);
|
|
509
|
-
const
|
|
510
|
-
cleanup();
|
|
511
|
-
resolve({
|
|
537
|
+
const response = {
|
|
512
538
|
code,
|
|
513
539
|
message,
|
|
514
|
-
raw:
|
|
515
|
-
}
|
|
516
|
-
|
|
540
|
+
raw: responseLines.join("\r\n")
|
|
541
|
+
};
|
|
542
|
+
const responseIndex = responses.length;
|
|
543
|
+
responses.push(response);
|
|
544
|
+
responseLines = [];
|
|
545
|
+
if (responses.length === commands.length) {
|
|
546
|
+
cleanup();
|
|
547
|
+
resolve(responses);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (response.code === 421) {
|
|
551
|
+
cleanup();
|
|
552
|
+
reject(new SmtpPipelineTerminatedError(responseIndex, response));
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
resetTimeout();
|
|
517
556
|
}
|
|
518
557
|
}
|
|
519
558
|
buffer = incompleteLine;
|
|
@@ -522,14 +561,35 @@ var SmtpConnection = class {
|
|
|
522
561
|
cleanup();
|
|
523
562
|
reject(error);
|
|
524
563
|
};
|
|
564
|
+
const onClose = () => {
|
|
565
|
+
cleanup();
|
|
566
|
+
const responseIndex = responses.findIndex((response) => response.code >= 400);
|
|
567
|
+
if (responseIndex >= 0) {
|
|
568
|
+
reject(new SmtpPipelineTerminatedError(responseIndex, responses[responseIndex]));
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
reject(/* @__PURE__ */ new Error("Connection closed before all command responses."));
|
|
572
|
+
};
|
|
573
|
+
const onAbort = () => {
|
|
574
|
+
cleanup();
|
|
575
|
+
try {
|
|
576
|
+
signal?.throwIfAborted();
|
|
577
|
+
} catch (error) {
|
|
578
|
+
reject(error);
|
|
579
|
+
}
|
|
580
|
+
};
|
|
525
581
|
const cleanup = () => {
|
|
526
582
|
clearTimeout(timeout);
|
|
527
583
|
this.socket?.off("data", onData);
|
|
528
584
|
this.socket?.off("error", onError);
|
|
585
|
+
this.socket?.off("close", onClose);
|
|
586
|
+
signal?.removeEventListener("abort", onAbort);
|
|
529
587
|
};
|
|
530
588
|
this.socket.on("data", onData);
|
|
531
589
|
this.socket.on("error", onError);
|
|
532
|
-
this.socket.
|
|
590
|
+
this.socket.on("close", onClose);
|
|
591
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
592
|
+
this.socket.write(commands.map((command) => `${command}\r\n`).join(""));
|
|
533
593
|
});
|
|
534
594
|
}
|
|
535
595
|
greeting(signal) {
|
|
@@ -570,8 +630,14 @@ var SmtpConnection = class {
|
|
|
570
630
|
}
|
|
571
631
|
async ehlo(signal) {
|
|
572
632
|
const response = await this.sendCommand(`EHLO ${this.config.localName}`, signal);
|
|
633
|
+
if (response.code === 500 || response.code === 502) {
|
|
634
|
+
const heloResponse = await this.sendCommand(`HELO ${this.config.localName}`, signal);
|
|
635
|
+
if (heloResponse.code !== 250) throw new Error(`HELO failed: ${heloResponse.message}`);
|
|
636
|
+
this.capabilities = [];
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
573
639
|
if (response.code !== 250) throw new Error(`EHLO failed: ${response.message}`);
|
|
574
|
-
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);
|
|
640
|
+
this.capabilities = response.raw.split("\r\n").filter((line) => line.startsWith("250-") || line.startsWith("250 ")).slice(1).map((line) => line.substring(4)).filter((line) => line.length > 0);
|
|
575
641
|
}
|
|
576
642
|
async starttls(signal) {
|
|
577
643
|
if (!this.socket) throw new Error("Not connected");
|
|
@@ -724,12 +790,43 @@ var SmtpConnection = class {
|
|
|
724
790
|
throw new SmtpAuthError(`${mechanism} authentication failed: ${response.message}`);
|
|
725
791
|
}
|
|
726
792
|
async sendMessage(message, signal) {
|
|
727
|
-
const
|
|
793
|
+
const mailCommand = `MAIL FROM:<${message.envelope.from}>`;
|
|
794
|
+
const recipientCommands = message.envelope.to.map((recipient) => `RCPT TO:<${recipient}>`);
|
|
795
|
+
const pipelining = this.capabilities.some((capability) => /^PIPELINING(?:\s|$)/i.test(capability));
|
|
796
|
+
let mailResponse;
|
|
797
|
+
let recipientResponses;
|
|
798
|
+
if (pipelining) try {
|
|
799
|
+
const envelopeResponses = await this.sendCommands([mailCommand, ...recipientCommands], signal);
|
|
800
|
+
mailResponse = envelopeResponses[0];
|
|
801
|
+
recipientResponses = envelopeResponses.slice(1);
|
|
802
|
+
} catch (error) {
|
|
803
|
+
if (!(error instanceof SmtpPipelineTerminatedError)) throw error;
|
|
804
|
+
const { response, responseIndex } = error;
|
|
805
|
+
if (responseIndex === 0) throw new SmtpResponseError(`MAIL FROM failed: ${response.message}`, response.code, "MAIL FROM", response.message);
|
|
806
|
+
const recipient = message.envelope.to[responseIndex - 1];
|
|
807
|
+
throw new SmtpResponseError(`RCPT TO failed for ${recipient}: ${response.message}`, response.code, "RCPT TO", response.message);
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
mailResponse = await this.sendCommand(mailCommand, signal);
|
|
811
|
+
recipientResponses = [];
|
|
812
|
+
}
|
|
728
813
|
if (mailResponse.code !== 250) throw new SmtpResponseError(`MAIL FROM failed: ${mailResponse.message}`, mailResponse.code, "MAIL FROM", mailResponse.message);
|
|
729
|
-
|
|
814
|
+
const rejectedRecipients = [];
|
|
815
|
+
for (const [index, recipient] of message.envelope.to.entries()) {
|
|
730
816
|
signal?.throwIfAborted();
|
|
731
|
-
const rcptResponse = await this.sendCommand(
|
|
732
|
-
if (rcptResponse.code
|
|
817
|
+
const rcptResponse = pipelining ? recipientResponses[index] : await this.sendCommand(recipientCommands[index], signal);
|
|
818
|
+
if (rcptResponse.code === 421) throw new SmtpResponseError(`RCPT TO failed for ${recipient}: ${rcptResponse.message}`, rcptResponse.code, "RCPT TO", rcptResponse.message);
|
|
819
|
+
if (rcptResponse.code !== 250 && rcptResponse.code !== 251) rejectedRecipients.push({
|
|
820
|
+
recipient,
|
|
821
|
+
code: rcptResponse.code,
|
|
822
|
+
response: rcptResponse.message,
|
|
823
|
+
retryable: rcptResponse.code >= 400 && rcptResponse.code < 500
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
if (rejectedRecipients.length > 0 && rejectedRecipients.length === message.envelope.to.length) {
|
|
827
|
+
const rejection = rejectedRecipients.find((item) => item.retryable) ?? rejectedRecipients[0];
|
|
828
|
+
const details = rejectedRecipients.map((item) => `${item.recipient}: ${item.code} ${item.response}`).join("; ");
|
|
829
|
+
throw new SmtpResponseError(`RCPT TO failed for every recipient: ${details}`, rejection.code, "RCPT TO", rejection.response, rejectedRecipients);
|
|
733
830
|
}
|
|
734
831
|
const dataResponse = await this.sendCommand("DATA", signal);
|
|
735
832
|
if (dataResponse.code !== 354) throw new SmtpResponseError(`DATA failed: ${dataResponse.message}`, dataResponse.code, "DATA", dataResponse.message);
|
|
@@ -737,7 +834,10 @@ var SmtpConnection = class {
|
|
|
737
834
|
const finalResponse = await this.sendCommand(`${content}\r\n.`, signal);
|
|
738
835
|
if (finalResponse.code !== 250) throw new SmtpResponseError(`Message send failed: ${finalResponse.message}`, finalResponse.code, "DATA_END", finalResponse.message);
|
|
739
836
|
const messageId = this.extractMessageId(finalResponse.message);
|
|
740
|
-
return
|
|
837
|
+
return {
|
|
838
|
+
messageId,
|
|
839
|
+
rejectedRecipients
|
|
840
|
+
};
|
|
741
841
|
}
|
|
742
842
|
extractMessageId(response) {
|
|
743
843
|
const match = response.match(/(?:Message-ID:|id=)[\s<]*([^>\s]+)/i);
|
|
@@ -793,6 +893,8 @@ var SmtpResponseError = class extends Error {
|
|
|
793
893
|
* The textual SMTP reply returned by the server.
|
|
794
894
|
*/
|
|
795
895
|
response;
|
|
896
|
+
/** Recipient-level failures collected for an unsuccessful transaction. */
|
|
897
|
+
rejectedRecipients;
|
|
796
898
|
/**
|
|
797
899
|
* Creates an SMTP response error.
|
|
798
900
|
*
|
|
@@ -800,13 +902,16 @@ var SmtpResponseError = class extends Error {
|
|
|
800
902
|
* @param code The numeric SMTP reply code.
|
|
801
903
|
* @param command The SMTP command that produced the reply.
|
|
802
904
|
* @param response The textual SMTP reply returned by the server.
|
|
905
|
+
* @param rejectedRecipients Recipient-level failures collected for the
|
|
906
|
+
* transaction.
|
|
803
907
|
*/
|
|
804
|
-
constructor(message, code, command, response) {
|
|
908
|
+
constructor(message, code, command, response, rejectedRecipients) {
|
|
805
909
|
super(message);
|
|
806
910
|
this.name = "SmtpResponseError";
|
|
807
911
|
this.code = code;
|
|
808
912
|
this.command = command;
|
|
809
913
|
this.response = response;
|
|
914
|
+
this.rejectedRecipients = rejectedRecipients;
|
|
810
915
|
}
|
|
811
916
|
};
|
|
812
917
|
/**
|
|
@@ -1108,6 +1213,15 @@ function arrayBufferToBase64(buffer) {
|
|
|
1108
1213
|
|
|
1109
1214
|
//#endregion
|
|
1110
1215
|
//#region src/message-converter.ts
|
|
1216
|
+
/**
|
|
1217
|
+
* Converts a message to its SMTP envelope and wire representation.
|
|
1218
|
+
*
|
|
1219
|
+
* @param message The message to convert.
|
|
1220
|
+
* @param dkimConfig Optional DKIM signing configuration.
|
|
1221
|
+
* @returns The converted SMTP message.
|
|
1222
|
+
* @throws {RangeError} If a header contains a token that cannot be folded
|
|
1223
|
+
* within the RFC 5322 hard line-length limit.
|
|
1224
|
+
*/
|
|
1111
1225
|
async function convertMessage(message, dkimConfig) {
|
|
1112
1226
|
const envelope = {
|
|
1113
1227
|
from: message.sender.address,
|
|
@@ -1139,11 +1253,11 @@ async function buildRawMessage(message) {
|
|
|
1139
1253
|
const hasHtml = "html" in message.content;
|
|
1140
1254
|
const hasText = "text" in message.content;
|
|
1141
1255
|
const isMultipart = hasAttachments || hasHtml && hasText;
|
|
1142
|
-
lines.push(
|
|
1143
|
-
lines.push(
|
|
1144
|
-
if (message.ccRecipients.length > 0) lines.push(
|
|
1145
|
-
if (message.replyRecipients.length > 0) lines.push(
|
|
1146
|
-
lines.push(
|
|
1256
|
+
lines.push(foldHeader("From", encodeAddress(message.sender)));
|
|
1257
|
+
lines.push(foldHeader("To", message.recipients.map(encodeAddress).join(", ")));
|
|
1258
|
+
if (message.ccRecipients.length > 0) lines.push(foldHeader("Cc", message.ccRecipients.map(encodeAddress).join(", ")));
|
|
1259
|
+
if (message.replyRecipients.length > 0) lines.push(foldHeader("Reply-To", message.replyRecipients.map(encodeAddress).join(", ")));
|
|
1260
|
+
lines.push(foldHeader("Subject", encodeHeaderValue(message.subject, true)));
|
|
1147
1261
|
lines.push(`Date: ${(/* @__PURE__ */ new Date()).toUTCString()}`);
|
|
1148
1262
|
lines.push(`Message-ID: <${generateMessageId()}>`);
|
|
1149
1263
|
if (message.priority !== "normal") {
|
|
@@ -1151,7 +1265,7 @@ async function buildRawMessage(message) {
|
|
|
1151
1265
|
lines.push(`X-Priority: ${priorityValue}`);
|
|
1152
1266
|
lines.push(`X-MSMail-Priority: ${message.priority === "high" ? "High" : "Low"}`);
|
|
1153
1267
|
}
|
|
1154
|
-
for (const [key, value] of message.headers) lines.push(
|
|
1268
|
+
for (const [key, value] of message.headers) lines.push(foldHeader(key, encodeHeaderValue(value)));
|
|
1155
1269
|
lines.push("MIME-Version: 1.0");
|
|
1156
1270
|
if (isMultipart) {
|
|
1157
1271
|
lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
|
|
@@ -1190,12 +1304,12 @@ async function buildRawMessage(message) {
|
|
|
1190
1304
|
for (const attachment of message.attachments) {
|
|
1191
1305
|
lines.push("");
|
|
1192
1306
|
lines.push(`--${boundary}`);
|
|
1193
|
-
lines.push(
|
|
1307
|
+
lines.push(foldHeader("Content-Type", `${attachment.contentType}; ${encodeMimeParameter("name", attachment.filename)}`));
|
|
1194
1308
|
lines.push("Content-Transfer-Encoding: base64");
|
|
1195
1309
|
if (attachment.inline) {
|
|
1196
|
-
lines.push(
|
|
1310
|
+
lines.push(foldHeader("Content-Disposition", `inline; ${encodeMimeParameter("filename", attachment.filename)}`));
|
|
1197
1311
|
lines.push(`Content-ID: <${attachment.contentId}>`);
|
|
1198
|
-
} else lines.push(
|
|
1312
|
+
} else lines.push(foldHeader("Content-Disposition", `attachment; ${encodeMimeParameter("filename", attachment.filename)}`));
|
|
1199
1313
|
lines.push("");
|
|
1200
1314
|
lines.push(encodeBase64(await attachment.content));
|
|
1201
1315
|
}
|
|
@@ -1224,32 +1338,85 @@ function generateMessageId() {
|
|
|
1224
1338
|
}
|
|
1225
1339
|
function encodeAddress(address) {
|
|
1226
1340
|
if (address.name == null) return address.address;
|
|
1227
|
-
const encodedDisplayName = encodeHeaderValue(address.name);
|
|
1341
|
+
const encodedDisplayName = encodeHeaderValue(address.name, true);
|
|
1228
1342
|
return `${encodedDisplayName} <${address.address}>`;
|
|
1229
1343
|
}
|
|
1230
|
-
function encodeHeaderValue(value) {
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
const
|
|
1344
|
+
function encodeHeaderValue(value, encodeLongAsciiWords = false) {
|
|
1345
|
+
const hasLongWord = value.split(/\s+/).some((word) => word.length > 60);
|
|
1346
|
+
if (!/^[\x20-\x7E]*$/.test(value) || encodeLongAsciiWords && hasLongWord) {
|
|
1347
|
+
const encodeWord = (text) => {
|
|
1348
|
+
const utf8Bytes = new TextEncoder().encode(text);
|
|
1349
|
+
const base64 = Buffer.from(utf8Bytes).toString("base64");
|
|
1350
|
+
return `=?UTF-8?B?${base64}?=`;
|
|
1351
|
+
};
|
|
1234
1352
|
const maxEncodedLength = 75;
|
|
1235
|
-
const encodedWord =
|
|
1353
|
+
const encodedWord = encodeWord(value);
|
|
1236
1354
|
if (encodedWord.length <= maxEncodedLength) return encodedWord;
|
|
1237
1355
|
const words = [];
|
|
1238
|
-
let
|
|
1239
|
-
for (
|
|
1240
|
-
const
|
|
1241
|
-
|
|
1242
|
-
if (testWord.length <= maxEncodedLength) currentBase64 += chunk;
|
|
1356
|
+
let currentText = "";
|
|
1357
|
+
for (const character of value) {
|
|
1358
|
+
const candidate = currentText + character;
|
|
1359
|
+
if (encodeWord(candidate).length <= maxEncodedLength) currentText = candidate;
|
|
1243
1360
|
else {
|
|
1244
|
-
if (
|
|
1245
|
-
|
|
1361
|
+
if (currentText.length > 0) words.push(encodeWord(currentText));
|
|
1362
|
+
currentText = character;
|
|
1246
1363
|
}
|
|
1247
1364
|
}
|
|
1248
|
-
if (
|
|
1365
|
+
if (currentText.length > 0) words.push(encodeWord(currentText));
|
|
1249
1366
|
return words.join(" ");
|
|
1250
1367
|
}
|
|
1251
1368
|
return value;
|
|
1252
1369
|
}
|
|
1370
|
+
function encodeMimeParameter(name, value) {
|
|
1371
|
+
const escapedValue = value.replace(/[\\"]/g, "\\$&");
|
|
1372
|
+
const quotedParameter = `${name}="${escapedValue}"`;
|
|
1373
|
+
if (/^[\x20-\x7E]*$/.test(value) && quotedParameter.length <= 60) return quotedParameter;
|
|
1374
|
+
const encodedBytes = Array.from(new TextEncoder().encode(value), (byte) => {
|
|
1375
|
+
const character = String.fromCharCode(byte);
|
|
1376
|
+
return /^[A-Za-z0-9!#$&+.^_`|~-]$/.test(character) ? character : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
|
|
1377
|
+
});
|
|
1378
|
+
const segments = [];
|
|
1379
|
+
let segment = "";
|
|
1380
|
+
for (const encodedByte of encodedBytes) {
|
|
1381
|
+
if (segment.length + encodedByte.length > 45) {
|
|
1382
|
+
segments.push(segment);
|
|
1383
|
+
segment = "";
|
|
1384
|
+
}
|
|
1385
|
+
segment += encodedByte;
|
|
1386
|
+
}
|
|
1387
|
+
if (segment.length > 0 || segments.length === 0) segments.push(segment);
|
|
1388
|
+
return segments.map((part, index) => `${name}*${index}*=${index === 0 ? "UTF-8''" : ""}${part}`).join("; ");
|
|
1389
|
+
}
|
|
1390
|
+
function foldHeader(name, value) {
|
|
1391
|
+
const recommendedLineLength = 78;
|
|
1392
|
+
const lines = [];
|
|
1393
|
+
let prefix = `${name}: `;
|
|
1394
|
+
let remaining = value;
|
|
1395
|
+
while (prefix.length + remaining.length > recommendedLineLength) {
|
|
1396
|
+
const availableLength = recommendedLineLength - prefix.length;
|
|
1397
|
+
let breakIndex = -1;
|
|
1398
|
+
for (let index = Math.min(availableLength, remaining.length - 1); index >= 0; index--) if (remaining[index] === " " || remaining[index] === " ") {
|
|
1399
|
+
breakIndex = index;
|
|
1400
|
+
break;
|
|
1401
|
+
}
|
|
1402
|
+
if (breakIndex < 0) {
|
|
1403
|
+
for (let index = Math.max(availableLength + 1, 0); index < remaining.length; index++) if (remaining[index] === " " || remaining[index] === " ") {
|
|
1404
|
+
breakIndex = index;
|
|
1405
|
+
break;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
if (breakIndex < 0) break;
|
|
1409
|
+
let whitespaceEnd = breakIndex + 1;
|
|
1410
|
+
while (whitespaceEnd < remaining.length && (remaining[whitespaceEnd] === " " || remaining[whitespaceEnd] === " ")) whitespaceEnd++;
|
|
1411
|
+
if (whitespaceEnd === remaining.length) break;
|
|
1412
|
+
lines.push(prefix + remaining.slice(0, breakIndex));
|
|
1413
|
+
prefix = remaining.slice(breakIndex, whitespaceEnd);
|
|
1414
|
+
remaining = remaining.slice(whitespaceEnd);
|
|
1415
|
+
}
|
|
1416
|
+
lines.push(prefix + remaining);
|
|
1417
|
+
if (lines.some((line) => line.length > 998)) throw new RangeError(`Header field ${name} contains a token too long to fold.`);
|
|
1418
|
+
return lines.join("\r\n");
|
|
1419
|
+
}
|
|
1253
1420
|
function encodeQuotedPrintable(text) {
|
|
1254
1421
|
const utf8Bytes = new TextEncoder().encode(text);
|
|
1255
1422
|
let result = "";
|
|
@@ -1380,12 +1547,13 @@ var SmtpTransport = class {
|
|
|
1380
1547
|
options?.signal?.throwIfAborted();
|
|
1381
1548
|
const smtpMessage = await convertMessage(message, this.config.dkim);
|
|
1382
1549
|
options?.signal?.throwIfAborted();
|
|
1383
|
-
const
|
|
1550
|
+
const result = await connection.sendMessage(smtpMessage, options?.signal);
|
|
1384
1551
|
await this.returnConnection(connection);
|
|
1385
1552
|
return {
|
|
1386
1553
|
successful: true,
|
|
1387
|
-
messageId,
|
|
1388
|
-
provider: "smtp"
|
|
1554
|
+
messageId: result.messageId,
|
|
1555
|
+
provider: "smtp",
|
|
1556
|
+
rejectedRecipients: result.rejectedRecipients
|
|
1389
1557
|
};
|
|
1390
1558
|
} catch (error) {
|
|
1391
1559
|
if (connection != null) await this.discardConnection(connection);
|
|
@@ -1449,11 +1617,12 @@ var SmtpTransport = class {
|
|
|
1449
1617
|
try {
|
|
1450
1618
|
const smtpMessage = await convertMessage(message, this.config.dkim);
|
|
1451
1619
|
options?.signal?.throwIfAborted();
|
|
1452
|
-
const
|
|
1620
|
+
const result = await connection.sendMessage(smtpMessage, options?.signal);
|
|
1453
1621
|
yield {
|
|
1454
1622
|
successful: true,
|
|
1455
|
-
messageId,
|
|
1456
|
-
provider: "smtp"
|
|
1623
|
+
messageId: result.messageId,
|
|
1624
|
+
provider: "smtp",
|
|
1625
|
+
rejectedRecipients: result.rejectedRecipients
|
|
1457
1626
|
};
|
|
1458
1627
|
} catch (error) {
|
|
1459
1628
|
options?.signal?.throwIfAborted();
|
|
@@ -1470,11 +1639,12 @@ var SmtpTransport = class {
|
|
|
1470
1639
|
try {
|
|
1471
1640
|
const smtpMessage = await convertMessage(message, this.config.dkim);
|
|
1472
1641
|
options?.signal?.throwIfAborted();
|
|
1473
|
-
const
|
|
1642
|
+
const result = await connection.sendMessage(smtpMessage, options?.signal);
|
|
1474
1643
|
yield {
|
|
1475
1644
|
successful: true,
|
|
1476
|
-
messageId,
|
|
1477
|
-
provider: "smtp"
|
|
1645
|
+
messageId: result.messageId,
|
|
1646
|
+
provider: "smtp",
|
|
1647
|
+
rejectedRecipients: result.rejectedRecipients
|
|
1478
1648
|
};
|
|
1479
1649
|
} catch (error) {
|
|
1480
1650
|
options?.signal?.throwIfAborted();
|
|
@@ -1583,7 +1753,8 @@ function createSmtpFailure(message, error) {
|
|
|
1583
1753
|
attempts: 1,
|
|
1584
1754
|
providerDetails: {
|
|
1585
1755
|
command: error.command,
|
|
1586
|
-
response: error.response
|
|
1756
|
+
response: error.response,
|
|
1757
|
+
rejectedRecipients: error.rejectedRecipients
|
|
1587
1758
|
}
|
|
1588
1759
|
});
|
|
1589
1760
|
}
|
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.272",
|
|
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.272+71461772"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"tsdown": "^0.12.7",
|