@upyo/smtp 0.6.0-dev.265 → 0.6.0-dev.271

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