@upyo/smtp 0.5.2 → 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/dist/index.cjs CHANGED
@@ -592,6 +592,12 @@ var SmtpConnection = class {
592
592
  }
593
593
  async ehlo(signal) {
594
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
+ }
595
601
  if (response.code !== 250) throw new Error(`EHLO failed: ${response.message}`);
596
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);
597
603
  }
@@ -748,10 +754,22 @@ var SmtpConnection = class {
748
754
  async sendMessage(message, signal) {
749
755
  const mailResponse = await this.sendCommand(`MAIL FROM:<${message.envelope.from}>`, signal);
750
756
  if (mailResponse.code !== 250) throw new SmtpResponseError(`MAIL FROM failed: ${mailResponse.message}`, mailResponse.code, "MAIL FROM", mailResponse.message);
757
+ const rejectedRecipients = [];
751
758
  for (const recipient of message.envelope.to) {
752
759
  signal?.throwIfAborted();
753
760
  const rcptResponse = await this.sendCommand(`RCPT TO:<${recipient}>`, signal);
754
- 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);
755
773
  }
756
774
  const dataResponse = await this.sendCommand("DATA", signal);
757
775
  if (dataResponse.code !== 354) throw new SmtpResponseError(`DATA failed: ${dataResponse.message}`, dataResponse.code, "DATA", dataResponse.message);
@@ -759,7 +777,10 @@ var SmtpConnection = class {
759
777
  const finalResponse = await this.sendCommand(`${content}\r\n.`, signal);
760
778
  if (finalResponse.code !== 250) throw new SmtpResponseError(`Message send failed: ${finalResponse.message}`, finalResponse.code, "DATA_END", finalResponse.message);
761
779
  const messageId = this.extractMessageId(finalResponse.message);
762
- return messageId;
780
+ return {
781
+ messageId,
782
+ rejectedRecipients
783
+ };
763
784
  }
764
785
  extractMessageId(response) {
765
786
  const match = response.match(/(?:Message-ID:|id=)[\s<]*([^>\s]+)/i);
@@ -815,6 +836,8 @@ var SmtpResponseError = class extends Error {
815
836
  * The textual SMTP reply returned by the server.
816
837
  */
817
838
  response;
839
+ /** Recipient-level failures collected for an unsuccessful transaction. */
840
+ rejectedRecipients;
818
841
  /**
819
842
  * Creates an SMTP response error.
820
843
  *
@@ -822,13 +845,16 @@ var SmtpResponseError = class extends Error {
822
845
  * @param code The numeric SMTP reply code.
823
846
  * @param command The SMTP command that produced the reply.
824
847
  * @param response The textual SMTP reply returned by the server.
848
+ * @param rejectedRecipients Recipient-level failures collected for the
849
+ * transaction.
825
850
  */
826
- constructor(message, code, command, response) {
851
+ constructor(message, code, command, response, rejectedRecipients) {
827
852
  super(message);
828
853
  this.name = "SmtpResponseError";
829
854
  this.code = code;
830
855
  this.command = command;
831
856
  this.response = response;
857
+ this.rejectedRecipients = rejectedRecipients;
832
858
  }
833
859
  };
834
860
  /**
@@ -1130,6 +1156,15 @@ function arrayBufferToBase64(buffer) {
1130
1156
 
1131
1157
  //#endregion
1132
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
+ */
1133
1168
  async function convertMessage(message, dkimConfig) {
1134
1169
  const envelope = {
1135
1170
  from: message.sender.address,
@@ -1161,11 +1196,11 @@ async function buildRawMessage(message) {
1161
1196
  const hasHtml = "html" in message.content;
1162
1197
  const hasText = "text" in message.content;
1163
1198
  const isMultipart = hasAttachments || hasHtml && hasText;
1164
- lines.push(`From: ${encodeAddress(message.sender)}`);
1165
- lines.push(`To: ${message.recipients.map(encodeAddress).join(", ")}`);
1166
- if (message.ccRecipients.length > 0) lines.push(`Cc: ${message.ccRecipients.map(encodeAddress).join(", ")}`);
1167
- if (message.replyRecipients.length > 0) lines.push(`Reply-To: ${message.replyRecipients.map(encodeAddress).join(", ")}`);
1168
- 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)));
1169
1204
  lines.push(`Date: ${(/* @__PURE__ */ new Date()).toUTCString()}`);
1170
1205
  lines.push(`Message-ID: <${generateMessageId()}>`);
1171
1206
  if (message.priority !== "normal") {
@@ -1173,7 +1208,7 @@ async function buildRawMessage(message) {
1173
1208
  lines.push(`X-Priority: ${priorityValue}`);
1174
1209
  lines.push(`X-MSMail-Priority: ${message.priority === "high" ? "High" : "Low"}`);
1175
1210
  }
1176
- 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)));
1177
1212
  lines.push("MIME-Version: 1.0");
1178
1213
  if (isMultipart) {
1179
1214
  lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
@@ -1212,12 +1247,12 @@ async function buildRawMessage(message) {
1212
1247
  for (const attachment of message.attachments) {
1213
1248
  lines.push("");
1214
1249
  lines.push(`--${boundary}`);
1215
- lines.push(`Content-Type: ${attachment.contentType}; name="${attachment.filename}"`);
1250
+ lines.push(foldHeader("Content-Type", `${attachment.contentType}; ${encodeMimeParameter("name", attachment.filename)}`));
1216
1251
  lines.push("Content-Transfer-Encoding: base64");
1217
1252
  if (attachment.inline) {
1218
- lines.push(`Content-Disposition: inline; filename="${attachment.filename}"`);
1253
+ lines.push(foldHeader("Content-Disposition", `inline; ${encodeMimeParameter("filename", attachment.filename)}`));
1219
1254
  lines.push(`Content-ID: <${attachment.contentId}>`);
1220
- } else lines.push(`Content-Disposition: attachment; filename="${attachment.filename}"`);
1255
+ } else lines.push(foldHeader("Content-Disposition", `attachment; ${encodeMimeParameter("filename", attachment.filename)}`));
1221
1256
  lines.push("");
1222
1257
  lines.push(encodeBase64(await attachment.content));
1223
1258
  }
@@ -1246,32 +1281,85 @@ function generateMessageId() {
1246
1281
  }
1247
1282
  function encodeAddress(address) {
1248
1283
  if (address.name == null) return address.address;
1249
- const encodedDisplayName = encodeHeaderValue(address.name);
1284
+ const encodedDisplayName = encodeHeaderValue(address.name, true);
1250
1285
  return `${encodedDisplayName} <${address.address}>`;
1251
1286
  }
1252
- function encodeHeaderValue(value) {
1253
- if (!/^[\x20-\x7E]*$/.test(value)) {
1254
- const utf8Bytes = new TextEncoder().encode(value);
1255
- 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
+ };
1256
1295
  const maxEncodedLength = 75;
1257
- const encodedWord = `=?UTF-8?B?${base64}?=`;
1296
+ const encodedWord = encodeWord(value);
1258
1297
  if (encodedWord.length <= maxEncodedLength) return encodedWord;
1259
1298
  const words = [];
1260
- let currentBase64 = "";
1261
- for (let i = 0; i < base64.length; i += 4) {
1262
- const chunk = base64.slice(i, i + 4);
1263
- const testWord = `=?UTF-8?B?${currentBase64}${chunk}?=`;
1264
- 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;
1265
1303
  else {
1266
- if (currentBase64) words.push(`=?UTF-8?B?${currentBase64}?=`);
1267
- currentBase64 = chunk;
1304
+ if (currentText.length > 0) words.push(encodeWord(currentText));
1305
+ currentText = character;
1268
1306
  }
1269
1307
  }
1270
- if (currentBase64) words.push(`=?UTF-8?B?${currentBase64}?=`);
1308
+ if (currentText.length > 0) words.push(encodeWord(currentText));
1271
1309
  return words.join(" ");
1272
1310
  }
1273
1311
  return value;
1274
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
+ }
1275
1363
  function encodeQuotedPrintable(text) {
1276
1364
  const utf8Bytes = new TextEncoder().encode(text);
1277
1365
  let result = "";
@@ -1402,12 +1490,13 @@ var SmtpTransport = class {
1402
1490
  options?.signal?.throwIfAborted();
1403
1491
  const smtpMessage = await convertMessage(message, this.config.dkim);
1404
1492
  options?.signal?.throwIfAborted();
1405
- const messageId = await connection.sendMessage(smtpMessage, options?.signal);
1493
+ const result = await connection.sendMessage(smtpMessage, options?.signal);
1406
1494
  await this.returnConnection(connection);
1407
1495
  return {
1408
1496
  successful: true,
1409
- messageId,
1410
- provider: "smtp"
1497
+ messageId: result.messageId,
1498
+ provider: "smtp",
1499
+ rejectedRecipients: result.rejectedRecipients
1411
1500
  };
1412
1501
  } catch (error) {
1413
1502
  if (connection != null) await this.discardConnection(connection);
@@ -1471,11 +1560,12 @@ var SmtpTransport = class {
1471
1560
  try {
1472
1561
  const smtpMessage = await convertMessage(message, this.config.dkim);
1473
1562
  options?.signal?.throwIfAborted();
1474
- const messageId = await connection.sendMessage(smtpMessage, options?.signal);
1563
+ const result = await connection.sendMessage(smtpMessage, options?.signal);
1475
1564
  yield {
1476
1565
  successful: true,
1477
- messageId,
1478
- provider: "smtp"
1566
+ messageId: result.messageId,
1567
+ provider: "smtp",
1568
+ rejectedRecipients: result.rejectedRecipients
1479
1569
  };
1480
1570
  } catch (error) {
1481
1571
  options?.signal?.throwIfAborted();
@@ -1492,11 +1582,12 @@ var SmtpTransport = class {
1492
1582
  try {
1493
1583
  const smtpMessage = await convertMessage(message, this.config.dkim);
1494
1584
  options?.signal?.throwIfAborted();
1495
- const messageId = await connection.sendMessage(smtpMessage, options?.signal);
1585
+ const result = await connection.sendMessage(smtpMessage, options?.signal);
1496
1586
  yield {
1497
1587
  successful: true,
1498
- messageId,
1499
- provider: "smtp"
1588
+ messageId: result.messageId,
1589
+ provider: "smtp",
1590
+ rejectedRecipients: result.rejectedRecipients
1500
1591
  };
1501
1592
  } catch (error) {
1502
1593
  options?.signal?.throwIfAborted();
@@ -1605,7 +1696,8 @@ function createSmtpFailure(message, error) {
1605
1696
  attempts: 1,
1606
1697
  providerDetails: {
1607
1698
  command: error.command,
1608
- response: error.response
1699
+ response: error.response,
1700
+ rejectedRecipients: error.rejectedRecipients
1609
1701
  }
1610
1702
  });
1611
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
@@ -569,6 +569,12 @@ var SmtpConnection = class {
569
569
  }
570
570
  async ehlo(signal) {
571
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
+ }
572
578
  if (response.code !== 250) throw new Error(`EHLO failed: ${response.message}`);
573
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);
574
580
  }
@@ -725,10 +731,22 @@ var SmtpConnection = class {
725
731
  async sendMessage(message, signal) {
726
732
  const mailResponse = await this.sendCommand(`MAIL FROM:<${message.envelope.from}>`, signal);
727
733
  if (mailResponse.code !== 250) throw new SmtpResponseError(`MAIL FROM failed: ${mailResponse.message}`, mailResponse.code, "MAIL FROM", mailResponse.message);
734
+ const rejectedRecipients = [];
728
735
  for (const recipient of message.envelope.to) {
729
736
  signal?.throwIfAborted();
730
737
  const rcptResponse = await this.sendCommand(`RCPT TO:<${recipient}>`, signal);
731
- 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);
732
750
  }
733
751
  const dataResponse = await this.sendCommand("DATA", signal);
734
752
  if (dataResponse.code !== 354) throw new SmtpResponseError(`DATA failed: ${dataResponse.message}`, dataResponse.code, "DATA", dataResponse.message);
@@ -736,7 +754,10 @@ var SmtpConnection = class {
736
754
  const finalResponse = await this.sendCommand(`${content}\r\n.`, signal);
737
755
  if (finalResponse.code !== 250) throw new SmtpResponseError(`Message send failed: ${finalResponse.message}`, finalResponse.code, "DATA_END", finalResponse.message);
738
756
  const messageId = this.extractMessageId(finalResponse.message);
739
- return messageId;
757
+ return {
758
+ messageId,
759
+ rejectedRecipients
760
+ };
740
761
  }
741
762
  extractMessageId(response) {
742
763
  const match = response.match(/(?:Message-ID:|id=)[\s<]*([^>\s]+)/i);
@@ -792,6 +813,8 @@ var SmtpResponseError = class extends Error {
792
813
  * The textual SMTP reply returned by the server.
793
814
  */
794
815
  response;
816
+ /** Recipient-level failures collected for an unsuccessful transaction. */
817
+ rejectedRecipients;
795
818
  /**
796
819
  * Creates an SMTP response error.
797
820
  *
@@ -799,13 +822,16 @@ var SmtpResponseError = class extends Error {
799
822
  * @param code The numeric SMTP reply code.
800
823
  * @param command The SMTP command that produced the reply.
801
824
  * @param response The textual SMTP reply returned by the server.
825
+ * @param rejectedRecipients Recipient-level failures collected for the
826
+ * transaction.
802
827
  */
803
- constructor(message, code, command, response) {
828
+ constructor(message, code, command, response, rejectedRecipients) {
804
829
  super(message);
805
830
  this.name = "SmtpResponseError";
806
831
  this.code = code;
807
832
  this.command = command;
808
833
  this.response = response;
834
+ this.rejectedRecipients = rejectedRecipients;
809
835
  }
810
836
  };
811
837
  /**
@@ -1107,6 +1133,15 @@ function arrayBufferToBase64(buffer) {
1107
1133
 
1108
1134
  //#endregion
1109
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
+ */
1110
1145
  async function convertMessage(message, dkimConfig) {
1111
1146
  const envelope = {
1112
1147
  from: message.sender.address,
@@ -1138,11 +1173,11 @@ async function buildRawMessage(message) {
1138
1173
  const hasHtml = "html" in message.content;
1139
1174
  const hasText = "text" in message.content;
1140
1175
  const isMultipart = hasAttachments || hasHtml && hasText;
1141
- lines.push(`From: ${encodeAddress(message.sender)}`);
1142
- lines.push(`To: ${message.recipients.map(encodeAddress).join(", ")}`);
1143
- if (message.ccRecipients.length > 0) lines.push(`Cc: ${message.ccRecipients.map(encodeAddress).join(", ")}`);
1144
- if (message.replyRecipients.length > 0) lines.push(`Reply-To: ${message.replyRecipients.map(encodeAddress).join(", ")}`);
1145
- 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)));
1146
1181
  lines.push(`Date: ${(/* @__PURE__ */ new Date()).toUTCString()}`);
1147
1182
  lines.push(`Message-ID: <${generateMessageId()}>`);
1148
1183
  if (message.priority !== "normal") {
@@ -1150,7 +1185,7 @@ async function buildRawMessage(message) {
1150
1185
  lines.push(`X-Priority: ${priorityValue}`);
1151
1186
  lines.push(`X-MSMail-Priority: ${message.priority === "high" ? "High" : "Low"}`);
1152
1187
  }
1153
- 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)));
1154
1189
  lines.push("MIME-Version: 1.0");
1155
1190
  if (isMultipart) {
1156
1191
  lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
@@ -1189,12 +1224,12 @@ async function buildRawMessage(message) {
1189
1224
  for (const attachment of message.attachments) {
1190
1225
  lines.push("");
1191
1226
  lines.push(`--${boundary}`);
1192
- lines.push(`Content-Type: ${attachment.contentType}; name="${attachment.filename}"`);
1227
+ lines.push(foldHeader("Content-Type", `${attachment.contentType}; ${encodeMimeParameter("name", attachment.filename)}`));
1193
1228
  lines.push("Content-Transfer-Encoding: base64");
1194
1229
  if (attachment.inline) {
1195
- lines.push(`Content-Disposition: inline; filename="${attachment.filename}"`);
1230
+ lines.push(foldHeader("Content-Disposition", `inline; ${encodeMimeParameter("filename", attachment.filename)}`));
1196
1231
  lines.push(`Content-ID: <${attachment.contentId}>`);
1197
- } else lines.push(`Content-Disposition: attachment; filename="${attachment.filename}"`);
1232
+ } else lines.push(foldHeader("Content-Disposition", `attachment; ${encodeMimeParameter("filename", attachment.filename)}`));
1198
1233
  lines.push("");
1199
1234
  lines.push(encodeBase64(await attachment.content));
1200
1235
  }
@@ -1223,32 +1258,85 @@ function generateMessageId() {
1223
1258
  }
1224
1259
  function encodeAddress(address) {
1225
1260
  if (address.name == null) return address.address;
1226
- const encodedDisplayName = encodeHeaderValue(address.name);
1261
+ const encodedDisplayName = encodeHeaderValue(address.name, true);
1227
1262
  return `${encodedDisplayName} <${address.address}>`;
1228
1263
  }
1229
- function encodeHeaderValue(value) {
1230
- if (!/^[\x20-\x7E]*$/.test(value)) {
1231
- const utf8Bytes = new TextEncoder().encode(value);
1232
- 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
+ };
1233
1272
  const maxEncodedLength = 75;
1234
- const encodedWord = `=?UTF-8?B?${base64}?=`;
1273
+ const encodedWord = encodeWord(value);
1235
1274
  if (encodedWord.length <= maxEncodedLength) return encodedWord;
1236
1275
  const words = [];
1237
- let currentBase64 = "";
1238
- for (let i = 0; i < base64.length; i += 4) {
1239
- const chunk = base64.slice(i, i + 4);
1240
- const testWord = `=?UTF-8?B?${currentBase64}${chunk}?=`;
1241
- 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;
1242
1280
  else {
1243
- if (currentBase64) words.push(`=?UTF-8?B?${currentBase64}?=`);
1244
- currentBase64 = chunk;
1281
+ if (currentText.length > 0) words.push(encodeWord(currentText));
1282
+ currentText = character;
1245
1283
  }
1246
1284
  }
1247
- if (currentBase64) words.push(`=?UTF-8?B?${currentBase64}?=`);
1285
+ if (currentText.length > 0) words.push(encodeWord(currentText));
1248
1286
  return words.join(" ");
1249
1287
  }
1250
1288
  return value;
1251
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
+ }
1252
1340
  function encodeQuotedPrintable(text) {
1253
1341
  const utf8Bytes = new TextEncoder().encode(text);
1254
1342
  let result = "";
@@ -1379,12 +1467,13 @@ var SmtpTransport = class {
1379
1467
  options?.signal?.throwIfAborted();
1380
1468
  const smtpMessage = await convertMessage(message, this.config.dkim);
1381
1469
  options?.signal?.throwIfAborted();
1382
- const messageId = await connection.sendMessage(smtpMessage, options?.signal);
1470
+ const result = await connection.sendMessage(smtpMessage, options?.signal);
1383
1471
  await this.returnConnection(connection);
1384
1472
  return {
1385
1473
  successful: true,
1386
- messageId,
1387
- provider: "smtp"
1474
+ messageId: result.messageId,
1475
+ provider: "smtp",
1476
+ rejectedRecipients: result.rejectedRecipients
1388
1477
  };
1389
1478
  } catch (error) {
1390
1479
  if (connection != null) await this.discardConnection(connection);
@@ -1448,11 +1537,12 @@ var SmtpTransport = class {
1448
1537
  try {
1449
1538
  const smtpMessage = await convertMessage(message, this.config.dkim);
1450
1539
  options?.signal?.throwIfAborted();
1451
- const messageId = await connection.sendMessage(smtpMessage, options?.signal);
1540
+ const result = await connection.sendMessage(smtpMessage, options?.signal);
1452
1541
  yield {
1453
1542
  successful: true,
1454
- messageId,
1455
- provider: "smtp"
1543
+ messageId: result.messageId,
1544
+ provider: "smtp",
1545
+ rejectedRecipients: result.rejectedRecipients
1456
1546
  };
1457
1547
  } catch (error) {
1458
1548
  options?.signal?.throwIfAborted();
@@ -1469,11 +1559,12 @@ var SmtpTransport = class {
1469
1559
  try {
1470
1560
  const smtpMessage = await convertMessage(message, this.config.dkim);
1471
1561
  options?.signal?.throwIfAborted();
1472
- const messageId = await connection.sendMessage(smtpMessage, options?.signal);
1562
+ const result = await connection.sendMessage(smtpMessage, options?.signal);
1473
1563
  yield {
1474
1564
  successful: true,
1475
- messageId,
1476
- provider: "smtp"
1565
+ messageId: result.messageId,
1566
+ provider: "smtp",
1567
+ rejectedRecipients: result.rejectedRecipients
1477
1568
  };
1478
1569
  } catch (error) {
1479
1570
  options?.signal?.throwIfAborted();
@@ -1582,7 +1673,8 @@ function createSmtpFailure(message, error) {
1582
1673
  attempts: 1,
1583
1674
  providerDetails: {
1584
1675
  command: error.command,
1585
- response: error.response
1676
+ response: error.response,
1677
+ rejectedRecipients: error.rejectedRecipients
1586
1678
  }
1587
1679
  });
1588
1680
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upyo/smtp",
3
- "version": "0.5.2",
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.2"
56
+ "@upyo/core": "0.5.3"
57
57
  },
58
58
  "devDependencies": {
59
59
  "tsdown": "^0.12.7",