@upyo/smtp 0.5.1 → 0.5.3

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