@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 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
- const timeout = setTimeout(() => {
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 (let i = 0; i < lines.length; i++) {
528
- const line = lines[i];
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 fullResponse = lines.slice(0, i + 1).join("\r\n");
533
- cleanup();
534
- resolve({
560
+ const response = {
535
561
  code,
536
562
  message,
537
- raw: fullResponse
538
- });
539
- return;
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.write(command + "\r\n");
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 mailResponse = await this.sendCommand(`MAIL FROM:<${message.envelope.from}>`, signal);
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
- for (const recipient of message.envelope.to) {
837
+ const rejectedRecipients = [];
838
+ for (const [index, recipient] of message.envelope.to.entries()) {
753
839
  signal?.throwIfAborted();
754
- const rcptResponse = await this.sendCommand(`RCPT TO:<${recipient}>`, signal);
755
- if (rcptResponse.code !== 250) throw new SmtpResponseError(`RCPT TO failed for ${recipient}: ${rcptResponse.message}`, rcptResponse.code, "RCPT TO", rcptResponse.message);
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 messageId;
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(`From: ${encodeAddress(message.sender)}`);
1166
- lines.push(`To: ${message.recipients.map(encodeAddress).join(", ")}`);
1167
- if (message.ccRecipients.length > 0) lines.push(`Cc: ${message.ccRecipients.map(encodeAddress).join(", ")}`);
1168
- if (message.replyRecipients.length > 0) lines.push(`Reply-To: ${message.replyRecipients.map(encodeAddress).join(", ")}`);
1169
- lines.push(`Subject: ${encodeHeaderValue(message.subject)}`);
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(`${key}: ${encodeHeaderValue(value)}`);
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(`Content-Type: ${attachment.contentType}; name="${attachment.filename}"`);
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(`Content-Disposition: inline; filename="${attachment.filename}"`);
1333
+ lines.push(foldHeader("Content-Disposition", `inline; ${encodeMimeParameter("filename", attachment.filename)}`));
1220
1334
  lines.push(`Content-ID: <${attachment.contentId}>`);
1221
- } else lines.push(`Content-Disposition: attachment; filename="${attachment.filename}"`);
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
- if (!/^[\x20-\x7E]*$/.test(value)) {
1255
- const utf8Bytes = new TextEncoder().encode(value);
1256
- const base64 = node_buffer.Buffer.from(utf8Bytes).toString("base64");
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 = `=?UTF-8?B?${base64}?=`;
1376
+ const encodedWord = encodeWord(value);
1259
1377
  if (encodedWord.length <= maxEncodedLength) return encodedWord;
1260
1378
  const words = [];
1261
- let currentBase64 = "";
1262
- for (let i = 0; i < base64.length; i += 4) {
1263
- const chunk = base64.slice(i, i + 4);
1264
- const testWord = `=?UTF-8?B?${currentBase64}${chunk}?=`;
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 (currentBase64) words.push(`=?UTF-8?B?${currentBase64}?=`);
1268
- currentBase64 = chunk;
1384
+ if (currentText.length > 0) words.push(encodeWord(currentText));
1385
+ currentText = character;
1269
1386
  }
1270
1387
  }
1271
- if (currentBase64) words.push(`=?UTF-8?B?${currentBase64}?=`);
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 messageId = await connection.sendMessage(smtpMessage, options?.signal);
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 messageId = await connection.sendMessage(smtpMessage, options?.signal);
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 messageId = await connection.sendMessage(smtpMessage, options?.signal);
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<Receipt<"smtp">>;
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<Receipt<"smtp">>;
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<Receipt<"smtp">>;
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<Receipt<"smtp">>;
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
- const timeout = setTimeout(() => {
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 (let i = 0; i < lines.length; i++) {
505
- const line = lines[i];
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 fullResponse = lines.slice(0, i + 1).join("\r\n");
510
- cleanup();
511
- resolve({
537
+ const response = {
512
538
  code,
513
539
  message,
514
- raw: fullResponse
515
- });
516
- return;
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.write(command + "\r\n");
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 mailResponse = await this.sendCommand(`MAIL FROM:<${message.envelope.from}>`, signal);
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
- for (const recipient of message.envelope.to) {
814
+ const rejectedRecipients = [];
815
+ for (const [index, recipient] of message.envelope.to.entries()) {
730
816
  signal?.throwIfAborted();
731
- const rcptResponse = await this.sendCommand(`RCPT TO:<${recipient}>`, signal);
732
- if (rcptResponse.code !== 250) throw new SmtpResponseError(`RCPT TO failed for ${recipient}: ${rcptResponse.message}`, rcptResponse.code, "RCPT TO", rcptResponse.message);
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 messageId;
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(`From: ${encodeAddress(message.sender)}`);
1143
- lines.push(`To: ${message.recipients.map(encodeAddress).join(", ")}`);
1144
- if (message.ccRecipients.length > 0) lines.push(`Cc: ${message.ccRecipients.map(encodeAddress).join(", ")}`);
1145
- if (message.replyRecipients.length > 0) lines.push(`Reply-To: ${message.replyRecipients.map(encodeAddress).join(", ")}`);
1146
- lines.push(`Subject: ${encodeHeaderValue(message.subject)}`);
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(`${key}: ${encodeHeaderValue(value)}`);
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(`Content-Type: ${attachment.contentType}; name="${attachment.filename}"`);
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(`Content-Disposition: inline; filename="${attachment.filename}"`);
1310
+ lines.push(foldHeader("Content-Disposition", `inline; ${encodeMimeParameter("filename", attachment.filename)}`));
1197
1311
  lines.push(`Content-ID: <${attachment.contentId}>`);
1198
- } else lines.push(`Content-Disposition: attachment; filename="${attachment.filename}"`);
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
- if (!/^[\x20-\x7E]*$/.test(value)) {
1232
- const utf8Bytes = new TextEncoder().encode(value);
1233
- const base64 = Buffer.from(utf8Bytes).toString("base64");
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 = `=?UTF-8?B?${base64}?=`;
1353
+ const encodedWord = encodeWord(value);
1236
1354
  if (encodedWord.length <= maxEncodedLength) return encodedWord;
1237
1355
  const words = [];
1238
- let currentBase64 = "";
1239
- for (let i = 0; i < base64.length; i += 4) {
1240
- const chunk = base64.slice(i, i + 4);
1241
- const testWord = `=?UTF-8?B?${currentBase64}${chunk}?=`;
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 (currentBase64) words.push(`=?UTF-8?B?${currentBase64}?=`);
1245
- currentBase64 = chunk;
1361
+ if (currentText.length > 0) words.push(encodeWord(currentText));
1362
+ currentText = character;
1246
1363
  }
1247
1364
  }
1248
- if (currentBase64) words.push(`=?UTF-8?B?${currentBase64}?=`);
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 messageId = await connection.sendMessage(smtpMessage, options?.signal);
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 messageId = await connection.sendMessage(smtpMessage, options?.signal);
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 messageId = await connection.sendMessage(smtpMessage, options?.signal);
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.269",
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.269+e9149444"
56
+ "@upyo/core": "0.6.0-dev.272+71461772"
57
57
  },
58
58
  "devDependencies": {
59
59
  "tsdown": "^0.12.7",