@upyo/smtp 0.6.0-dev.269 → 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/dist/index.cjs CHANGED
@@ -593,6 +593,12 @@ var SmtpConnection = class {
593
593
  }
594
594
  async ehlo(signal) {
595
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
+ }
596
602
  if (response.code !== 250) throw new Error(`EHLO failed: ${response.message}`);
597
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);
598
604
  }
@@ -749,10 +755,22 @@ var SmtpConnection = class {
749
755
  async sendMessage(message, signal) {
750
756
  const mailResponse = await this.sendCommand(`MAIL FROM:<${message.envelope.from}>`, signal);
751
757
  if (mailResponse.code !== 250) throw new SmtpResponseError(`MAIL FROM failed: ${mailResponse.message}`, mailResponse.code, "MAIL FROM", mailResponse.message);
758
+ const rejectedRecipients = [];
752
759
  for (const recipient of message.envelope.to) {
753
760
  signal?.throwIfAborted();
754
761
  const rcptResponse = await this.sendCommand(`RCPT TO:<${recipient}>`, signal);
755
- if (rcptResponse.code !== 250) throw new SmtpResponseError(`RCPT TO failed for ${recipient}: ${rcptResponse.message}`, rcptResponse.code, "RCPT TO", rcptResponse.message);
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);
756
774
  }
757
775
  const dataResponse = await this.sendCommand("DATA", signal);
758
776
  if (dataResponse.code !== 354) throw new SmtpResponseError(`DATA failed: ${dataResponse.message}`, dataResponse.code, "DATA", dataResponse.message);
@@ -760,7 +778,10 @@ var SmtpConnection = class {
760
778
  const finalResponse = await this.sendCommand(`${content}\r\n.`, signal);
761
779
  if (finalResponse.code !== 250) throw new SmtpResponseError(`Message send failed: ${finalResponse.message}`, finalResponse.code, "DATA_END", finalResponse.message);
762
780
  const messageId = this.extractMessageId(finalResponse.message);
763
- return messageId;
781
+ return {
782
+ messageId,
783
+ rejectedRecipients
784
+ };
764
785
  }
765
786
  extractMessageId(response) {
766
787
  const match = response.match(/(?:Message-ID:|id=)[\s<]*([^>\s]+)/i);
@@ -816,6 +837,8 @@ var SmtpResponseError = class extends Error {
816
837
  * The textual SMTP reply returned by the server.
817
838
  */
818
839
  response;
840
+ /** Recipient-level failures collected for an unsuccessful transaction. */
841
+ rejectedRecipients;
819
842
  /**
820
843
  * Creates an SMTP response error.
821
844
  *
@@ -823,13 +846,16 @@ var SmtpResponseError = class extends Error {
823
846
  * @param code The numeric SMTP reply code.
824
847
  * @param command The SMTP command that produced the reply.
825
848
  * @param response The textual SMTP reply returned by the server.
849
+ * @param rejectedRecipients Recipient-level failures collected for the
850
+ * transaction.
826
851
  */
827
- constructor(message, code, command, response) {
852
+ constructor(message, code, command, response, rejectedRecipients) {
828
853
  super(message);
829
854
  this.name = "SmtpResponseError";
830
855
  this.code = code;
831
856
  this.command = command;
832
857
  this.response = response;
858
+ this.rejectedRecipients = rejectedRecipients;
833
859
  }
834
860
  };
835
861
  /**
@@ -1131,6 +1157,15 @@ function arrayBufferToBase64(buffer) {
1131
1157
 
1132
1158
  //#endregion
1133
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
+ */
1134
1169
  async function convertMessage(message, dkimConfig) {
1135
1170
  const envelope = {
1136
1171
  from: message.sender.address,
@@ -1162,11 +1197,11 @@ async function buildRawMessage(message) {
1162
1197
  const hasHtml = "html" in message.content;
1163
1198
  const hasText = "text" in message.content;
1164
1199
  const isMultipart = hasAttachments || hasHtml && hasText;
1165
- lines.push(`From: ${encodeAddress(message.sender)}`);
1166
- lines.push(`To: ${message.recipients.map(encodeAddress).join(", ")}`);
1167
- if (message.ccRecipients.length > 0) lines.push(`Cc: ${message.ccRecipients.map(encodeAddress).join(", ")}`);
1168
- if (message.replyRecipients.length > 0) lines.push(`Reply-To: ${message.replyRecipients.map(encodeAddress).join(", ")}`);
1169
- lines.push(`Subject: ${encodeHeaderValue(message.subject)}`);
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)));
1170
1205
  lines.push(`Date: ${(/* @__PURE__ */ new Date()).toUTCString()}`);
1171
1206
  lines.push(`Message-ID: <${generateMessageId()}>`);
1172
1207
  if (message.priority !== "normal") {
@@ -1174,7 +1209,7 @@ async function buildRawMessage(message) {
1174
1209
  lines.push(`X-Priority: ${priorityValue}`);
1175
1210
  lines.push(`X-MSMail-Priority: ${message.priority === "high" ? "High" : "Low"}`);
1176
1211
  }
1177
- 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)));
1178
1213
  lines.push("MIME-Version: 1.0");
1179
1214
  if (isMultipart) {
1180
1215
  lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
@@ -1213,12 +1248,12 @@ async function buildRawMessage(message) {
1213
1248
  for (const attachment of message.attachments) {
1214
1249
  lines.push("");
1215
1250
  lines.push(`--${boundary}`);
1216
- lines.push(`Content-Type: ${attachment.contentType}; name="${attachment.filename}"`);
1251
+ lines.push(foldHeader("Content-Type", `${attachment.contentType}; ${encodeMimeParameter("name", attachment.filename)}`));
1217
1252
  lines.push("Content-Transfer-Encoding: base64");
1218
1253
  if (attachment.inline) {
1219
- lines.push(`Content-Disposition: inline; filename="${attachment.filename}"`);
1254
+ lines.push(foldHeader("Content-Disposition", `inline; ${encodeMimeParameter("filename", attachment.filename)}`));
1220
1255
  lines.push(`Content-ID: <${attachment.contentId}>`);
1221
- } else lines.push(`Content-Disposition: attachment; filename="${attachment.filename}"`);
1256
+ } else lines.push(foldHeader("Content-Disposition", `attachment; ${encodeMimeParameter("filename", attachment.filename)}`));
1222
1257
  lines.push("");
1223
1258
  lines.push(encodeBase64(await attachment.content));
1224
1259
  }
@@ -1247,32 +1282,85 @@ function generateMessageId() {
1247
1282
  }
1248
1283
  function encodeAddress(address) {
1249
1284
  if (address.name == null) return address.address;
1250
- const encodedDisplayName = encodeHeaderValue(address.name);
1285
+ const encodedDisplayName = encodeHeaderValue(address.name, true);
1251
1286
  return `${encodedDisplayName} <${address.address}>`;
1252
1287
  }
1253
- function encodeHeaderValue(value) {
1254
- if (!/^[\x20-\x7E]*$/.test(value)) {
1255
- const utf8Bytes = new TextEncoder().encode(value);
1256
- const base64 = node_buffer.Buffer.from(utf8Bytes).toString("base64");
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
+ };
1257
1296
  const maxEncodedLength = 75;
1258
- const encodedWord = `=?UTF-8?B?${base64}?=`;
1297
+ const encodedWord = encodeWord(value);
1259
1298
  if (encodedWord.length <= maxEncodedLength) return encodedWord;
1260
1299
  const words = [];
1261
- let currentBase64 = "";
1262
- for (let i = 0; i < base64.length; i += 4) {
1263
- const chunk = base64.slice(i, i + 4);
1264
- const testWord = `=?UTF-8?B?${currentBase64}${chunk}?=`;
1265
- if (testWord.length <= maxEncodedLength) currentBase64 += chunk;
1300
+ let currentText = "";
1301
+ for (const character of value) {
1302
+ const candidate = currentText + character;
1303
+ if (encodeWord(candidate).length <= maxEncodedLength) currentText = candidate;
1266
1304
  else {
1267
- if (currentBase64) words.push(`=?UTF-8?B?${currentBase64}?=`);
1268
- currentBase64 = chunk;
1305
+ if (currentText.length > 0) words.push(encodeWord(currentText));
1306
+ currentText = character;
1269
1307
  }
1270
1308
  }
1271
- if (currentBase64) words.push(`=?UTF-8?B?${currentBase64}?=`);
1309
+ if (currentText.length > 0) words.push(encodeWord(currentText));
1272
1310
  return words.join(" ");
1273
1311
  }
1274
1312
  return value;
1275
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
+ }
1276
1364
  function encodeQuotedPrintable(text) {
1277
1365
  const utf8Bytes = new TextEncoder().encode(text);
1278
1366
  let result = "";
@@ -1403,12 +1491,13 @@ var SmtpTransport = class {
1403
1491
  options?.signal?.throwIfAborted();
1404
1492
  const smtpMessage = await convertMessage(message, this.config.dkim);
1405
1493
  options?.signal?.throwIfAborted();
1406
- const messageId = await connection.sendMessage(smtpMessage, options?.signal);
1494
+ const result = await connection.sendMessage(smtpMessage, options?.signal);
1407
1495
  await this.returnConnection(connection);
1408
1496
  return {
1409
1497
  successful: true,
1410
- messageId,
1411
- provider: "smtp"
1498
+ messageId: result.messageId,
1499
+ provider: "smtp",
1500
+ rejectedRecipients: result.rejectedRecipients
1412
1501
  };
1413
1502
  } catch (error) {
1414
1503
  if (connection != null) await this.discardConnection(connection);
@@ -1472,11 +1561,12 @@ var SmtpTransport = class {
1472
1561
  try {
1473
1562
  const smtpMessage = await convertMessage(message, this.config.dkim);
1474
1563
  options?.signal?.throwIfAborted();
1475
- const messageId = await connection.sendMessage(smtpMessage, options?.signal);
1564
+ const result = await connection.sendMessage(smtpMessage, options?.signal);
1476
1565
  yield {
1477
1566
  successful: true,
1478
- messageId,
1479
- provider: "smtp"
1567
+ messageId: result.messageId,
1568
+ provider: "smtp",
1569
+ rejectedRecipients: result.rejectedRecipients
1480
1570
  };
1481
1571
  } catch (error) {
1482
1572
  options?.signal?.throwIfAborted();
@@ -1493,11 +1583,12 @@ var SmtpTransport = class {
1493
1583
  try {
1494
1584
  const smtpMessage = await convertMessage(message, this.config.dkim);
1495
1585
  options?.signal?.throwIfAborted();
1496
- const messageId = await connection.sendMessage(smtpMessage, options?.signal);
1586
+ const result = await connection.sendMessage(smtpMessage, options?.signal);
1497
1587
  yield {
1498
1588
  successful: true,
1499
- messageId,
1500
- provider: "smtp"
1589
+ messageId: result.messageId,
1590
+ provider: "smtp",
1591
+ rejectedRecipients: result.rejectedRecipients
1501
1592
  };
1502
1593
  } catch (error) {
1503
1594
  options?.signal?.throwIfAborted();
@@ -1606,7 +1697,8 @@ function createSmtpFailure(message, error) {
1606
1697
  attempts: 1,
1607
1698
  providerDetails: {
1608
1699
  command: error.command,
1609
- response: error.response
1700
+ response: error.response,
1701
+ rejectedRecipients: error.rejectedRecipients
1610
1702
  }
1611
1703
  });
1612
1704
  }
package/dist/index.d.cts CHANGED
@@ -417,6 +417,41 @@ interface SmtpTlsOptions {
417
417
  * used internally by the SMTP transport implementation.
418
418
  */
419
419
  //#endregion
420
+ //#region src/smtp-receipt.d.ts
421
+ /**
422
+ * An SMTP envelope recipient that the server rejected during an otherwise
423
+ * successful delivery.
424
+ *
425
+ * @since 0.5.3
426
+ */
427
+ interface SmtpRejectedRecipient {
428
+ /** The rejected recipient address. */
429
+ readonly recipient: string;
430
+ /** The three-digit SMTP reply code. */
431
+ readonly code: number;
432
+ /** The SMTP server's reply text. */
433
+ readonly response: string;
434
+ /** Whether retrying delivery to this recipient may succeed. */
435
+ readonly retryable: boolean;
436
+ }
437
+ /**
438
+ * A receipt returned by {@link SmtpTransport}.
439
+ *
440
+ * Successful receipts list any recipients rejected before the message was
441
+ * delivered to the remaining accepted recipients. Callers can retry delivery
442
+ * to entries marked as retryable without redelivering to accepted recipients.
443
+ *
444
+ * @since 0.5.3
445
+ */
446
+ type SmtpReceipt = (Extract<Receipt<"smtp">, {
447
+ readonly successful: true;
448
+ }> & {
449
+ /** Recipients excluded from an otherwise successful delivery. */
450
+ readonly rejectedRecipients: readonly SmtpRejectedRecipient[];
451
+ }) | Extract<Receipt<"smtp">, {
452
+ readonly successful: false;
453
+ }>;
454
+ //#endregion
420
455
  //#region src/smtp-transport.d.ts
421
456
  /**
422
457
  * SMTP transport implementation for sending emails via SMTP protocol.
@@ -504,7 +539,7 @@ declare class SmtpTransport implements Transport<"smtp">, AsyncDisposable {
504
539
  * @throws {DOMException} If the operation is aborted through
505
540
  * `options.signal`.
506
541
  */
507
- send(message: Message, options?: TransportOptions): Promise<Receipt<"smtp">>;
542
+ send(message: Message, options?: TransportOptions): Promise<SmtpReceipt>;
508
543
  /**
509
544
  * Sends multiple email messages efficiently using a single SMTP connection.
510
545
  *
@@ -535,7 +570,7 @@ declare class SmtpTransport implements Transport<"smtp">, AsyncDisposable {
535
570
  * @throws {DOMException} If the operation is aborted through
536
571
  * `options.signal`.
537
572
  */
538
- sendMany(messages: Iterable<Message> | AsyncIterable<Message>, options?: TransportOptions): AsyncIterable<Receipt<"smtp">>;
573
+ sendMany(messages: Iterable<Message> | AsyncIterable<Message>, options?: TransportOptions): AsyncIterable<SmtpReceipt>;
539
574
  private getConnection;
540
575
  private connectAndSetup;
541
576
  private returnConnection;
@@ -600,4 +635,4 @@ declare class SmtpAuthError extends Error {
600
635
  */
601
636
 
602
637
  //#endregion
603
- export { DkimAlgorithm, DkimCanonicalization, DkimConfig, DkimSignature, DkimSigningFailureAction, OAuth2TokenProvider, SmtpAuth, SmtpAuthError, SmtpConfig, SmtpOAuth2Auth, SmtpOAuth2RefreshAuth, SmtpOAuth2TokenAuth, SmtpTlsOptions, SmtpTransport, SmtpUserPassAuth };
638
+ export { DkimAlgorithm, DkimCanonicalization, DkimConfig, DkimSignature, DkimSigningFailureAction, OAuth2TokenProvider, SmtpAuth, SmtpAuthError, SmtpConfig, SmtpOAuth2Auth, SmtpOAuth2RefreshAuth, SmtpOAuth2TokenAuth, SmtpReceipt, SmtpRejectedRecipient, SmtpTlsOptions, SmtpTransport, SmtpUserPassAuth };
package/dist/index.d.ts CHANGED
@@ -417,6 +417,41 @@ interface SmtpTlsOptions {
417
417
  * used internally by the SMTP transport implementation.
418
418
  */
419
419
  //#endregion
420
+ //#region src/smtp-receipt.d.ts
421
+ /**
422
+ * An SMTP envelope recipient that the server rejected during an otherwise
423
+ * successful delivery.
424
+ *
425
+ * @since 0.5.3
426
+ */
427
+ interface SmtpRejectedRecipient {
428
+ /** The rejected recipient address. */
429
+ readonly recipient: string;
430
+ /** The three-digit SMTP reply code. */
431
+ readonly code: number;
432
+ /** The SMTP server's reply text. */
433
+ readonly response: string;
434
+ /** Whether retrying delivery to this recipient may succeed. */
435
+ readonly retryable: boolean;
436
+ }
437
+ /**
438
+ * A receipt returned by {@link SmtpTransport}.
439
+ *
440
+ * Successful receipts list any recipients rejected before the message was
441
+ * delivered to the remaining accepted recipients. Callers can retry delivery
442
+ * to entries marked as retryable without redelivering to accepted recipients.
443
+ *
444
+ * @since 0.5.3
445
+ */
446
+ type SmtpReceipt = (Extract<Receipt<"smtp">, {
447
+ readonly successful: true;
448
+ }> & {
449
+ /** Recipients excluded from an otherwise successful delivery. */
450
+ readonly rejectedRecipients: readonly SmtpRejectedRecipient[];
451
+ }) | Extract<Receipt<"smtp">, {
452
+ readonly successful: false;
453
+ }>;
454
+ //#endregion
420
455
  //#region src/smtp-transport.d.ts
421
456
  /**
422
457
  * SMTP transport implementation for sending emails via SMTP protocol.
@@ -504,7 +539,7 @@ declare class SmtpTransport implements Transport<"smtp">, AsyncDisposable {
504
539
  * @throws {DOMException} If the operation is aborted through
505
540
  * `options.signal`.
506
541
  */
507
- send(message: Message, options?: TransportOptions): Promise<Receipt<"smtp">>;
542
+ send(message: Message, options?: TransportOptions): Promise<SmtpReceipt>;
508
543
  /**
509
544
  * Sends multiple email messages efficiently using a single SMTP connection.
510
545
  *
@@ -535,7 +570,7 @@ declare class SmtpTransport implements Transport<"smtp">, AsyncDisposable {
535
570
  * @throws {DOMException} If the operation is aborted through
536
571
  * `options.signal`.
537
572
  */
538
- sendMany(messages: Iterable<Message> | AsyncIterable<Message>, options?: TransportOptions): AsyncIterable<Receipt<"smtp">>;
573
+ sendMany(messages: Iterable<Message> | AsyncIterable<Message>, options?: TransportOptions): AsyncIterable<SmtpReceipt>;
539
574
  private getConnection;
540
575
  private connectAndSetup;
541
576
  private returnConnection;
@@ -600,4 +635,4 @@ declare class SmtpAuthError extends Error {
600
635
  */
601
636
 
602
637
  //#endregion
603
- export { DkimAlgorithm, DkimCanonicalization, DkimConfig, DkimSignature, DkimSigningFailureAction, OAuth2TokenProvider, SmtpAuth, SmtpAuthError, SmtpConfig, SmtpOAuth2Auth, SmtpOAuth2RefreshAuth, SmtpOAuth2TokenAuth, SmtpTlsOptions, SmtpTransport, SmtpUserPassAuth };
638
+ export { DkimAlgorithm, DkimCanonicalization, DkimConfig, DkimSignature, DkimSigningFailureAction, OAuth2TokenProvider, SmtpAuth, SmtpAuthError, SmtpConfig, SmtpOAuth2Auth, SmtpOAuth2RefreshAuth, SmtpOAuth2TokenAuth, SmtpReceipt, SmtpRejectedRecipient, SmtpTlsOptions, SmtpTransport, SmtpUserPassAuth };
package/dist/index.js CHANGED
@@ -570,6 +570,12 @@ var SmtpConnection = class {
570
570
  }
571
571
  async ehlo(signal) {
572
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
+ }
573
579
  if (response.code !== 250) throw new Error(`EHLO failed: ${response.message}`);
574
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);
575
581
  }
@@ -726,10 +732,22 @@ var SmtpConnection = class {
726
732
  async sendMessage(message, signal) {
727
733
  const mailResponse = await this.sendCommand(`MAIL FROM:<${message.envelope.from}>`, signal);
728
734
  if (mailResponse.code !== 250) throw new SmtpResponseError(`MAIL FROM failed: ${mailResponse.message}`, mailResponse.code, "MAIL FROM", mailResponse.message);
735
+ const rejectedRecipients = [];
729
736
  for (const recipient of message.envelope.to) {
730
737
  signal?.throwIfAborted();
731
738
  const rcptResponse = await this.sendCommand(`RCPT TO:<${recipient}>`, signal);
732
- if (rcptResponse.code !== 250) throw new SmtpResponseError(`RCPT TO failed for ${recipient}: ${rcptResponse.message}`, rcptResponse.code, "RCPT TO", rcptResponse.message);
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);
733
751
  }
734
752
  const dataResponse = await this.sendCommand("DATA", signal);
735
753
  if (dataResponse.code !== 354) throw new SmtpResponseError(`DATA failed: ${dataResponse.message}`, dataResponse.code, "DATA", dataResponse.message);
@@ -737,7 +755,10 @@ var SmtpConnection = class {
737
755
  const finalResponse = await this.sendCommand(`${content}\r\n.`, signal);
738
756
  if (finalResponse.code !== 250) throw new SmtpResponseError(`Message send failed: ${finalResponse.message}`, finalResponse.code, "DATA_END", finalResponse.message);
739
757
  const messageId = this.extractMessageId(finalResponse.message);
740
- return messageId;
758
+ return {
759
+ messageId,
760
+ rejectedRecipients
761
+ };
741
762
  }
742
763
  extractMessageId(response) {
743
764
  const match = response.match(/(?:Message-ID:|id=)[\s<]*([^>\s]+)/i);
@@ -793,6 +814,8 @@ var SmtpResponseError = class extends Error {
793
814
  * The textual SMTP reply returned by the server.
794
815
  */
795
816
  response;
817
+ /** Recipient-level failures collected for an unsuccessful transaction. */
818
+ rejectedRecipients;
796
819
  /**
797
820
  * Creates an SMTP response error.
798
821
  *
@@ -800,13 +823,16 @@ var SmtpResponseError = class extends Error {
800
823
  * @param code The numeric SMTP reply code.
801
824
  * @param command The SMTP command that produced the reply.
802
825
  * @param response The textual SMTP reply returned by the server.
826
+ * @param rejectedRecipients Recipient-level failures collected for the
827
+ * transaction.
803
828
  */
804
- constructor(message, code, command, response) {
829
+ constructor(message, code, command, response, rejectedRecipients) {
805
830
  super(message);
806
831
  this.name = "SmtpResponseError";
807
832
  this.code = code;
808
833
  this.command = command;
809
834
  this.response = response;
835
+ this.rejectedRecipients = rejectedRecipients;
810
836
  }
811
837
  };
812
838
  /**
@@ -1108,6 +1134,15 @@ function arrayBufferToBase64(buffer) {
1108
1134
 
1109
1135
  //#endregion
1110
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
+ */
1111
1146
  async function convertMessage(message, dkimConfig) {
1112
1147
  const envelope = {
1113
1148
  from: message.sender.address,
@@ -1139,11 +1174,11 @@ async function buildRawMessage(message) {
1139
1174
  const hasHtml = "html" in message.content;
1140
1175
  const hasText = "text" in message.content;
1141
1176
  const isMultipart = hasAttachments || hasHtml && hasText;
1142
- lines.push(`From: ${encodeAddress(message.sender)}`);
1143
- lines.push(`To: ${message.recipients.map(encodeAddress).join(", ")}`);
1144
- if (message.ccRecipients.length > 0) lines.push(`Cc: ${message.ccRecipients.map(encodeAddress).join(", ")}`);
1145
- if (message.replyRecipients.length > 0) lines.push(`Reply-To: ${message.replyRecipients.map(encodeAddress).join(", ")}`);
1146
- lines.push(`Subject: ${encodeHeaderValue(message.subject)}`);
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)));
1147
1182
  lines.push(`Date: ${(/* @__PURE__ */ new Date()).toUTCString()}`);
1148
1183
  lines.push(`Message-ID: <${generateMessageId()}>`);
1149
1184
  if (message.priority !== "normal") {
@@ -1151,7 +1186,7 @@ async function buildRawMessage(message) {
1151
1186
  lines.push(`X-Priority: ${priorityValue}`);
1152
1187
  lines.push(`X-MSMail-Priority: ${message.priority === "high" ? "High" : "Low"}`);
1153
1188
  }
1154
- 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)));
1155
1190
  lines.push("MIME-Version: 1.0");
1156
1191
  if (isMultipart) {
1157
1192
  lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
@@ -1190,12 +1225,12 @@ async function buildRawMessage(message) {
1190
1225
  for (const attachment of message.attachments) {
1191
1226
  lines.push("");
1192
1227
  lines.push(`--${boundary}`);
1193
- lines.push(`Content-Type: ${attachment.contentType}; name="${attachment.filename}"`);
1228
+ lines.push(foldHeader("Content-Type", `${attachment.contentType}; ${encodeMimeParameter("name", attachment.filename)}`));
1194
1229
  lines.push("Content-Transfer-Encoding: base64");
1195
1230
  if (attachment.inline) {
1196
- lines.push(`Content-Disposition: inline; filename="${attachment.filename}"`);
1231
+ lines.push(foldHeader("Content-Disposition", `inline; ${encodeMimeParameter("filename", attachment.filename)}`));
1197
1232
  lines.push(`Content-ID: <${attachment.contentId}>`);
1198
- } else lines.push(`Content-Disposition: attachment; filename="${attachment.filename}"`);
1233
+ } else lines.push(foldHeader("Content-Disposition", `attachment; ${encodeMimeParameter("filename", attachment.filename)}`));
1199
1234
  lines.push("");
1200
1235
  lines.push(encodeBase64(await attachment.content));
1201
1236
  }
@@ -1224,32 +1259,85 @@ function generateMessageId() {
1224
1259
  }
1225
1260
  function encodeAddress(address) {
1226
1261
  if (address.name == null) return address.address;
1227
- const encodedDisplayName = encodeHeaderValue(address.name);
1262
+ const encodedDisplayName = encodeHeaderValue(address.name, true);
1228
1263
  return `${encodedDisplayName} <${address.address}>`;
1229
1264
  }
1230
- function encodeHeaderValue(value) {
1231
- if (!/^[\x20-\x7E]*$/.test(value)) {
1232
- const utf8Bytes = new TextEncoder().encode(value);
1233
- const base64 = Buffer.from(utf8Bytes).toString("base64");
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
+ };
1234
1273
  const maxEncodedLength = 75;
1235
- const encodedWord = `=?UTF-8?B?${base64}?=`;
1274
+ const encodedWord = encodeWord(value);
1236
1275
  if (encodedWord.length <= maxEncodedLength) return encodedWord;
1237
1276
  const words = [];
1238
- let currentBase64 = "";
1239
- for (let i = 0; i < base64.length; i += 4) {
1240
- const chunk = base64.slice(i, i + 4);
1241
- const testWord = `=?UTF-8?B?${currentBase64}${chunk}?=`;
1242
- if (testWord.length <= maxEncodedLength) currentBase64 += chunk;
1277
+ let currentText = "";
1278
+ for (const character of value) {
1279
+ const candidate = currentText + character;
1280
+ if (encodeWord(candidate).length <= maxEncodedLength) currentText = candidate;
1243
1281
  else {
1244
- if (currentBase64) words.push(`=?UTF-8?B?${currentBase64}?=`);
1245
- currentBase64 = chunk;
1282
+ if (currentText.length > 0) words.push(encodeWord(currentText));
1283
+ currentText = character;
1246
1284
  }
1247
1285
  }
1248
- if (currentBase64) words.push(`=?UTF-8?B?${currentBase64}?=`);
1286
+ if (currentText.length > 0) words.push(encodeWord(currentText));
1249
1287
  return words.join(" ");
1250
1288
  }
1251
1289
  return value;
1252
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
+ }
1253
1341
  function encodeQuotedPrintable(text) {
1254
1342
  const utf8Bytes = new TextEncoder().encode(text);
1255
1343
  let result = "";
@@ -1380,12 +1468,13 @@ var SmtpTransport = class {
1380
1468
  options?.signal?.throwIfAborted();
1381
1469
  const smtpMessage = await convertMessage(message, this.config.dkim);
1382
1470
  options?.signal?.throwIfAborted();
1383
- const messageId = await connection.sendMessage(smtpMessage, options?.signal);
1471
+ const result = await connection.sendMessage(smtpMessage, options?.signal);
1384
1472
  await this.returnConnection(connection);
1385
1473
  return {
1386
1474
  successful: true,
1387
- messageId,
1388
- provider: "smtp"
1475
+ messageId: result.messageId,
1476
+ provider: "smtp",
1477
+ rejectedRecipients: result.rejectedRecipients
1389
1478
  };
1390
1479
  } catch (error) {
1391
1480
  if (connection != null) await this.discardConnection(connection);
@@ -1449,11 +1538,12 @@ var SmtpTransport = class {
1449
1538
  try {
1450
1539
  const smtpMessage = await convertMessage(message, this.config.dkim);
1451
1540
  options?.signal?.throwIfAborted();
1452
- const messageId = await connection.sendMessage(smtpMessage, options?.signal);
1541
+ const result = await connection.sendMessage(smtpMessage, options?.signal);
1453
1542
  yield {
1454
1543
  successful: true,
1455
- messageId,
1456
- provider: "smtp"
1544
+ messageId: result.messageId,
1545
+ provider: "smtp",
1546
+ rejectedRecipients: result.rejectedRecipients
1457
1547
  };
1458
1548
  } catch (error) {
1459
1549
  options?.signal?.throwIfAborted();
@@ -1470,11 +1560,12 @@ var SmtpTransport = class {
1470
1560
  try {
1471
1561
  const smtpMessage = await convertMessage(message, this.config.dkim);
1472
1562
  options?.signal?.throwIfAborted();
1473
- const messageId = await connection.sendMessage(smtpMessage, options?.signal);
1563
+ const result = await connection.sendMessage(smtpMessage, options?.signal);
1474
1564
  yield {
1475
1565
  successful: true,
1476
- messageId,
1477
- provider: "smtp"
1566
+ messageId: result.messageId,
1567
+ provider: "smtp",
1568
+ rejectedRecipients: result.rejectedRecipients
1478
1569
  };
1479
1570
  } catch (error) {
1480
1571
  options?.signal?.throwIfAborted();
@@ -1583,7 +1674,8 @@ function createSmtpFailure(message, error) {
1583
1674
  attempts: 1,
1584
1675
  providerDetails: {
1585
1676
  command: error.command,
1586
- response: error.response
1677
+ response: error.response,
1678
+ rejectedRecipients: error.rejectedRecipients
1587
1679
  }
1588
1680
  });
1589
1681
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upyo/smtp",
3
- "version": "0.6.0-dev.269",
3
+ "version": "0.6.0-dev.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.269+e9149444"
56
+ "@upyo/core": "0.6.0-dev.271+8deab0ad"
57
57
  },
58
58
  "devDependencies": {
59
59
  "tsdown": "^0.12.7",