@upyo/smtp 0.5.2 → 0.5.4

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,17 @@ 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
+ * @throws {TypeError} If a `Date` or `Message-ID` header supplied by the
1168
+ * message contains a carriage return or line feed.
1169
+ */
1133
1170
  async function convertMessage(message, dkimConfig) {
1134
1171
  const envelope = {
1135
1172
  from: message.sender.address,
@@ -1161,19 +1198,34 @@ async function buildRawMessage(message) {
1161
1198
  const hasHtml = "html" in message.content;
1162
1199
  const hasText = "text" in message.content;
1163
1200
  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)}`);
1169
- lines.push(`Date: ${(/* @__PURE__ */ new Date()).toUTCString()}`);
1170
- lines.push(`Message-ID: <${generateMessageId()}>`);
1201
+ lines.push(foldHeader("From", encodeAddress(message.sender)));
1202
+ lines.push(foldHeader("To", message.recipients.map(encodeAddress).join(", ")));
1203
+ if (message.ccRecipients.length > 0) lines.push(foldHeader("Cc", message.ccRecipients.map(encodeAddress).join(", ")));
1204
+ if (message.replyRecipients.length > 0) lines.push(foldHeader("Reply-To", message.replyRecipients.map(encodeAddress).join(", ")));
1205
+ lines.push(foldHeader("Subject", encodeHeaderValue(message.subject, true)));
1206
+ lines.push(foldHeader("Date", overridden(message, "Date") ?? (/* @__PURE__ */ new Date()).toUTCString()));
1207
+ lines.push(foldHeader("Message-ID", overridden(message, "Message-ID") ?? `<${generateMessageId()}>`));
1208
+ const composed = new Set([
1209
+ "from",
1210
+ "to",
1211
+ "cc",
1212
+ "reply-to",
1213
+ "subject",
1214
+ "date",
1215
+ "message-id"
1216
+ ]);
1171
1217
  if (message.priority !== "normal") {
1172
1218
  const priorityValue = message.priority === "high" ? "1" : "5";
1173
1219
  lines.push(`X-Priority: ${priorityValue}`);
1174
1220
  lines.push(`X-MSMail-Priority: ${message.priority === "high" ? "High" : "Low"}`);
1221
+ composed.add("x-priority");
1222
+ composed.add("x-msmail-priority");
1223
+ }
1224
+ for (const [key, value] of message.headers) {
1225
+ const name = key.toLowerCase();
1226
+ if (composed.has(name) || reservedHeaders.has(name)) continue;
1227
+ lines.push(foldHeader(key, encodeHeaderValue(value)));
1175
1228
  }
1176
- for (const [key, value] of message.headers) lines.push(`${key}: ${encodeHeaderValue(value)}`);
1177
1229
  lines.push("MIME-Version: 1.0");
1178
1230
  if (isMultipart) {
1179
1231
  lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
@@ -1212,12 +1264,12 @@ async function buildRawMessage(message) {
1212
1264
  for (const attachment of message.attachments) {
1213
1265
  lines.push("");
1214
1266
  lines.push(`--${boundary}`);
1215
- lines.push(`Content-Type: ${attachment.contentType}; name="${attachment.filename}"`);
1267
+ lines.push(foldHeader("Content-Type", `${attachment.contentType}; ${encodeMimeParameter("name", attachment.filename)}`));
1216
1268
  lines.push("Content-Transfer-Encoding: base64");
1217
1269
  if (attachment.inline) {
1218
- lines.push(`Content-Disposition: inline; filename="${attachment.filename}"`);
1270
+ lines.push(foldHeader("Content-Disposition", `inline; ${encodeMimeParameter("filename", attachment.filename)}`));
1219
1271
  lines.push(`Content-ID: <${attachment.contentId}>`);
1220
- } else lines.push(`Content-Disposition: attachment; filename="${attachment.filename}"`);
1272
+ } else lines.push(foldHeader("Content-Disposition", `attachment; ${encodeMimeParameter("filename", attachment.filename)}`));
1221
1273
  lines.push("");
1222
1274
  lines.push(encodeBase64(await attachment.content));
1223
1275
  }
@@ -1236,6 +1288,41 @@ async function buildRawMessage(message) {
1236
1288
  }
1237
1289
  return lines.join("\r\n");
1238
1290
  }
1291
+ /**
1292
+ * Reads a header field that overrides a generated default.
1293
+ *
1294
+ * Unlike custom headers, the value is written verbatim rather than RFC 2047
1295
+ * encoded, so it cannot rely on `encodeHeaderValue()` to neutralize control
1296
+ * characters. `ImmutableHeaders` is a structural type, so a `Message` may
1297
+ * carry an adapter that never rejected CR or LF the way a platform `Headers`
1298
+ * does.
1299
+ *
1300
+ * @param message The message whose headers are read.
1301
+ * @param name The header field name.
1302
+ * @returns The supplied value, or `undefined` when the message has none.
1303
+ * @throws {TypeError} If the value contains a carriage return or line feed,
1304
+ * which would inject additional header fields into the message.
1305
+ */
1306
+ function overridden(message, name) {
1307
+ const value = message.headers.get(name);
1308
+ if (value == null) return void 0;
1309
+ if (/[\r\n]/.test(value)) throw new TypeError(`Header field ${name} must not contain a carriage return or line feed.`);
1310
+ return value;
1311
+ }
1312
+ /**
1313
+ * Header fields the composer owns but writes after the custom headers, or
1314
+ * deliberately omits. A custom header with one of these names is dropped
1315
+ * rather than appended: the structured `Message` fields are authoritative,
1316
+ * RFC 5322 §3.6 allows at most one of each, and a duplicate placed before the
1317
+ * composer's own field makes parsers that take the first occurrence read the
1318
+ * wrong value.
1319
+ */
1320
+ const reservedHeaders = new Set([
1321
+ "bcc",
1322
+ "content-transfer-encoding",
1323
+ "content-type",
1324
+ "mime-version"
1325
+ ]);
1239
1326
  function generateBoundary() {
1240
1327
  return `boundary-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
1241
1328
  }
@@ -1246,32 +1333,85 @@ function generateMessageId() {
1246
1333
  }
1247
1334
  function encodeAddress(address) {
1248
1335
  if (address.name == null) return address.address;
1249
- const encodedDisplayName = encodeHeaderValue(address.name);
1336
+ const encodedDisplayName = encodeHeaderValue(address.name, true);
1250
1337
  return `${encodedDisplayName} <${address.address}>`;
1251
1338
  }
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");
1339
+ function encodeHeaderValue(value, encodeLongAsciiWords = false) {
1340
+ const hasLongWord = value.split(/\s+/).some((word) => word.length > 60);
1341
+ if (!/^[\x20-\x7E]*$/.test(value) || encodeLongAsciiWords && hasLongWord) {
1342
+ const encodeWord = (text) => {
1343
+ const utf8Bytes = new TextEncoder().encode(text);
1344
+ const base64 = node_buffer.Buffer.from(utf8Bytes).toString("base64");
1345
+ return `=?UTF-8?B?${base64}?=`;
1346
+ };
1256
1347
  const maxEncodedLength = 75;
1257
- const encodedWord = `=?UTF-8?B?${base64}?=`;
1348
+ const encodedWord = encodeWord(value);
1258
1349
  if (encodedWord.length <= maxEncodedLength) return encodedWord;
1259
1350
  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;
1351
+ let currentText = "";
1352
+ for (const character of value) {
1353
+ const candidate = currentText + character;
1354
+ if (encodeWord(candidate).length <= maxEncodedLength) currentText = candidate;
1265
1355
  else {
1266
- if (currentBase64) words.push(`=?UTF-8?B?${currentBase64}?=`);
1267
- currentBase64 = chunk;
1356
+ if (currentText.length > 0) words.push(encodeWord(currentText));
1357
+ currentText = character;
1268
1358
  }
1269
1359
  }
1270
- if (currentBase64) words.push(`=?UTF-8?B?${currentBase64}?=`);
1360
+ if (currentText.length > 0) words.push(encodeWord(currentText));
1271
1361
  return words.join(" ");
1272
1362
  }
1273
1363
  return value;
1274
1364
  }
1365
+ function encodeMimeParameter(name, value) {
1366
+ const escapedValue = value.replace(/[\\"]/g, "\\$&");
1367
+ const quotedParameter = `${name}="${escapedValue}"`;
1368
+ if (/^[\x20-\x7E]*$/.test(value) && quotedParameter.length <= 60) return quotedParameter;
1369
+ const encodedBytes = Array.from(new TextEncoder().encode(value), (byte) => {
1370
+ const character = String.fromCharCode(byte);
1371
+ return /^[A-Za-z0-9!#$&+.^_`|~-]$/.test(character) ? character : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
1372
+ });
1373
+ const segments = [];
1374
+ let segment = "";
1375
+ for (const encodedByte of encodedBytes) {
1376
+ if (segment.length + encodedByte.length > 45) {
1377
+ segments.push(segment);
1378
+ segment = "";
1379
+ }
1380
+ segment += encodedByte;
1381
+ }
1382
+ if (segment.length > 0 || segments.length === 0) segments.push(segment);
1383
+ return segments.map((part, index) => `${name}*${index}*=${index === 0 ? "UTF-8''" : ""}${part}`).join("; ");
1384
+ }
1385
+ function foldHeader(name, value) {
1386
+ const recommendedLineLength = 78;
1387
+ const lines = [];
1388
+ let prefix = `${name}: `;
1389
+ let remaining = value;
1390
+ while (prefix.length + remaining.length > recommendedLineLength) {
1391
+ const availableLength = recommendedLineLength - prefix.length;
1392
+ let breakIndex = -1;
1393
+ for (let index = Math.min(availableLength, remaining.length - 1); index >= 0; index--) if (remaining[index] === " " || remaining[index] === " ") {
1394
+ breakIndex = index;
1395
+ break;
1396
+ }
1397
+ if (breakIndex < 0) {
1398
+ for (let index = Math.max(availableLength + 1, 0); index < remaining.length; index++) if (remaining[index] === " " || remaining[index] === " ") {
1399
+ breakIndex = index;
1400
+ break;
1401
+ }
1402
+ }
1403
+ if (breakIndex < 0) break;
1404
+ let whitespaceEnd = breakIndex + 1;
1405
+ while (whitespaceEnd < remaining.length && (remaining[whitespaceEnd] === " " || remaining[whitespaceEnd] === " ")) whitespaceEnd++;
1406
+ if (whitespaceEnd === remaining.length) break;
1407
+ lines.push(prefix + remaining.slice(0, breakIndex));
1408
+ prefix = remaining.slice(breakIndex, whitespaceEnd);
1409
+ remaining = remaining.slice(whitespaceEnd);
1410
+ }
1411
+ lines.push(prefix + remaining);
1412
+ if (lines.some((line) => line.length > 998)) throw new RangeError(`Header field ${name} contains a token too long to fold.`);
1413
+ return lines.join("\r\n");
1414
+ }
1275
1415
  function encodeQuotedPrintable(text) {
1276
1416
  const utf8Bytes = new TextEncoder().encode(text);
1277
1417
  let result = "";
@@ -1402,12 +1542,13 @@ var SmtpTransport = class {
1402
1542
  options?.signal?.throwIfAborted();
1403
1543
  const smtpMessage = await convertMessage(message, this.config.dkim);
1404
1544
  options?.signal?.throwIfAborted();
1405
- const messageId = await connection.sendMessage(smtpMessage, options?.signal);
1545
+ const result = await connection.sendMessage(smtpMessage, options?.signal);
1406
1546
  await this.returnConnection(connection);
1407
1547
  return {
1408
1548
  successful: true,
1409
- messageId,
1410
- provider: "smtp"
1549
+ messageId: result.messageId,
1550
+ provider: "smtp",
1551
+ rejectedRecipients: result.rejectedRecipients
1411
1552
  };
1412
1553
  } catch (error) {
1413
1554
  if (connection != null) await this.discardConnection(connection);
@@ -1471,11 +1612,12 @@ var SmtpTransport = class {
1471
1612
  try {
1472
1613
  const smtpMessage = await convertMessage(message, this.config.dkim);
1473
1614
  options?.signal?.throwIfAborted();
1474
- const messageId = await connection.sendMessage(smtpMessage, options?.signal);
1615
+ const result = await connection.sendMessage(smtpMessage, options?.signal);
1475
1616
  yield {
1476
1617
  successful: true,
1477
- messageId,
1478
- provider: "smtp"
1618
+ messageId: result.messageId,
1619
+ provider: "smtp",
1620
+ rejectedRecipients: result.rejectedRecipients
1479
1621
  };
1480
1622
  } catch (error) {
1481
1623
  options?.signal?.throwIfAborted();
@@ -1492,11 +1634,12 @@ var SmtpTransport = class {
1492
1634
  try {
1493
1635
  const smtpMessage = await convertMessage(message, this.config.dkim);
1494
1636
  options?.signal?.throwIfAborted();
1495
- const messageId = await connection.sendMessage(smtpMessage, options?.signal);
1637
+ const result = await connection.sendMessage(smtpMessage, options?.signal);
1496
1638
  yield {
1497
1639
  successful: true,
1498
- messageId,
1499
- provider: "smtp"
1640
+ messageId: result.messageId,
1641
+ provider: "smtp",
1642
+ rejectedRecipients: result.rejectedRecipients
1500
1643
  };
1501
1644
  } catch (error) {
1502
1645
  options?.signal?.throwIfAborted();
@@ -1605,7 +1748,8 @@ function createSmtpFailure(message, error) {
1605
1748
  attempts: 1,
1606
1749
  providerDetails: {
1607
1750
  command: error.command,
1608
- response: error.response
1751
+ response: error.response,
1752
+ rejectedRecipients: error.rejectedRecipients
1609
1753
  }
1610
1754
  });
1611
1755
  }
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,17 @@ 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
+ * @throws {TypeError} If a `Date` or `Message-ID` header supplied by the
1145
+ * message contains a carriage return or line feed.
1146
+ */
1110
1147
  async function convertMessage(message, dkimConfig) {
1111
1148
  const envelope = {
1112
1149
  from: message.sender.address,
@@ -1138,19 +1175,34 @@ async function buildRawMessage(message) {
1138
1175
  const hasHtml = "html" in message.content;
1139
1176
  const hasText = "text" in message.content;
1140
1177
  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)}`);
1146
- lines.push(`Date: ${(/* @__PURE__ */ new Date()).toUTCString()}`);
1147
- lines.push(`Message-ID: <${generateMessageId()}>`);
1178
+ lines.push(foldHeader("From", encodeAddress(message.sender)));
1179
+ lines.push(foldHeader("To", message.recipients.map(encodeAddress).join(", ")));
1180
+ if (message.ccRecipients.length > 0) lines.push(foldHeader("Cc", message.ccRecipients.map(encodeAddress).join(", ")));
1181
+ if (message.replyRecipients.length > 0) lines.push(foldHeader("Reply-To", message.replyRecipients.map(encodeAddress).join(", ")));
1182
+ lines.push(foldHeader("Subject", encodeHeaderValue(message.subject, true)));
1183
+ lines.push(foldHeader("Date", overridden(message, "Date") ?? (/* @__PURE__ */ new Date()).toUTCString()));
1184
+ lines.push(foldHeader("Message-ID", overridden(message, "Message-ID") ?? `<${generateMessageId()}>`));
1185
+ const composed = new Set([
1186
+ "from",
1187
+ "to",
1188
+ "cc",
1189
+ "reply-to",
1190
+ "subject",
1191
+ "date",
1192
+ "message-id"
1193
+ ]);
1148
1194
  if (message.priority !== "normal") {
1149
1195
  const priorityValue = message.priority === "high" ? "1" : "5";
1150
1196
  lines.push(`X-Priority: ${priorityValue}`);
1151
1197
  lines.push(`X-MSMail-Priority: ${message.priority === "high" ? "High" : "Low"}`);
1198
+ composed.add("x-priority");
1199
+ composed.add("x-msmail-priority");
1200
+ }
1201
+ for (const [key, value] of message.headers) {
1202
+ const name = key.toLowerCase();
1203
+ if (composed.has(name) || reservedHeaders.has(name)) continue;
1204
+ lines.push(foldHeader(key, encodeHeaderValue(value)));
1152
1205
  }
1153
- for (const [key, value] of message.headers) lines.push(`${key}: ${encodeHeaderValue(value)}`);
1154
1206
  lines.push("MIME-Version: 1.0");
1155
1207
  if (isMultipart) {
1156
1208
  lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
@@ -1189,12 +1241,12 @@ async function buildRawMessage(message) {
1189
1241
  for (const attachment of message.attachments) {
1190
1242
  lines.push("");
1191
1243
  lines.push(`--${boundary}`);
1192
- lines.push(`Content-Type: ${attachment.contentType}; name="${attachment.filename}"`);
1244
+ lines.push(foldHeader("Content-Type", `${attachment.contentType}; ${encodeMimeParameter("name", attachment.filename)}`));
1193
1245
  lines.push("Content-Transfer-Encoding: base64");
1194
1246
  if (attachment.inline) {
1195
- lines.push(`Content-Disposition: inline; filename="${attachment.filename}"`);
1247
+ lines.push(foldHeader("Content-Disposition", `inline; ${encodeMimeParameter("filename", attachment.filename)}`));
1196
1248
  lines.push(`Content-ID: <${attachment.contentId}>`);
1197
- } else lines.push(`Content-Disposition: attachment; filename="${attachment.filename}"`);
1249
+ } else lines.push(foldHeader("Content-Disposition", `attachment; ${encodeMimeParameter("filename", attachment.filename)}`));
1198
1250
  lines.push("");
1199
1251
  lines.push(encodeBase64(await attachment.content));
1200
1252
  }
@@ -1213,6 +1265,41 @@ async function buildRawMessage(message) {
1213
1265
  }
1214
1266
  return lines.join("\r\n");
1215
1267
  }
1268
+ /**
1269
+ * Reads a header field that overrides a generated default.
1270
+ *
1271
+ * Unlike custom headers, the value is written verbatim rather than RFC 2047
1272
+ * encoded, so it cannot rely on `encodeHeaderValue()` to neutralize control
1273
+ * characters. `ImmutableHeaders` is a structural type, so a `Message` may
1274
+ * carry an adapter that never rejected CR or LF the way a platform `Headers`
1275
+ * does.
1276
+ *
1277
+ * @param message The message whose headers are read.
1278
+ * @param name The header field name.
1279
+ * @returns The supplied value, or `undefined` when the message has none.
1280
+ * @throws {TypeError} If the value contains a carriage return or line feed,
1281
+ * which would inject additional header fields into the message.
1282
+ */
1283
+ function overridden(message, name) {
1284
+ const value = message.headers.get(name);
1285
+ if (value == null) return void 0;
1286
+ if (/[\r\n]/.test(value)) throw new TypeError(`Header field ${name} must not contain a carriage return or line feed.`);
1287
+ return value;
1288
+ }
1289
+ /**
1290
+ * Header fields the composer owns but writes after the custom headers, or
1291
+ * deliberately omits. A custom header with one of these names is dropped
1292
+ * rather than appended: the structured `Message` fields are authoritative,
1293
+ * RFC 5322 §3.6 allows at most one of each, and a duplicate placed before the
1294
+ * composer's own field makes parsers that take the first occurrence read the
1295
+ * wrong value.
1296
+ */
1297
+ const reservedHeaders = new Set([
1298
+ "bcc",
1299
+ "content-transfer-encoding",
1300
+ "content-type",
1301
+ "mime-version"
1302
+ ]);
1216
1303
  function generateBoundary() {
1217
1304
  return `boundary-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
1218
1305
  }
@@ -1223,32 +1310,85 @@ function generateMessageId() {
1223
1310
  }
1224
1311
  function encodeAddress(address) {
1225
1312
  if (address.name == null) return address.address;
1226
- const encodedDisplayName = encodeHeaderValue(address.name);
1313
+ const encodedDisplayName = encodeHeaderValue(address.name, true);
1227
1314
  return `${encodedDisplayName} <${address.address}>`;
1228
1315
  }
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");
1316
+ function encodeHeaderValue(value, encodeLongAsciiWords = false) {
1317
+ const hasLongWord = value.split(/\s+/).some((word) => word.length > 60);
1318
+ if (!/^[\x20-\x7E]*$/.test(value) || encodeLongAsciiWords && hasLongWord) {
1319
+ const encodeWord = (text) => {
1320
+ const utf8Bytes = new TextEncoder().encode(text);
1321
+ const base64 = Buffer.from(utf8Bytes).toString("base64");
1322
+ return `=?UTF-8?B?${base64}?=`;
1323
+ };
1233
1324
  const maxEncodedLength = 75;
1234
- const encodedWord = `=?UTF-8?B?${base64}?=`;
1325
+ const encodedWord = encodeWord(value);
1235
1326
  if (encodedWord.length <= maxEncodedLength) return encodedWord;
1236
1327
  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;
1328
+ let currentText = "";
1329
+ for (const character of value) {
1330
+ const candidate = currentText + character;
1331
+ if (encodeWord(candidate).length <= maxEncodedLength) currentText = candidate;
1242
1332
  else {
1243
- if (currentBase64) words.push(`=?UTF-8?B?${currentBase64}?=`);
1244
- currentBase64 = chunk;
1333
+ if (currentText.length > 0) words.push(encodeWord(currentText));
1334
+ currentText = character;
1245
1335
  }
1246
1336
  }
1247
- if (currentBase64) words.push(`=?UTF-8?B?${currentBase64}?=`);
1337
+ if (currentText.length > 0) words.push(encodeWord(currentText));
1248
1338
  return words.join(" ");
1249
1339
  }
1250
1340
  return value;
1251
1341
  }
1342
+ function encodeMimeParameter(name, value) {
1343
+ const escapedValue = value.replace(/[\\"]/g, "\\$&");
1344
+ const quotedParameter = `${name}="${escapedValue}"`;
1345
+ if (/^[\x20-\x7E]*$/.test(value) && quotedParameter.length <= 60) return quotedParameter;
1346
+ const encodedBytes = Array.from(new TextEncoder().encode(value), (byte) => {
1347
+ const character = String.fromCharCode(byte);
1348
+ return /^[A-Za-z0-9!#$&+.^_`|~-]$/.test(character) ? character : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
1349
+ });
1350
+ const segments = [];
1351
+ let segment = "";
1352
+ for (const encodedByte of encodedBytes) {
1353
+ if (segment.length + encodedByte.length > 45) {
1354
+ segments.push(segment);
1355
+ segment = "";
1356
+ }
1357
+ segment += encodedByte;
1358
+ }
1359
+ if (segment.length > 0 || segments.length === 0) segments.push(segment);
1360
+ return segments.map((part, index) => `${name}*${index}*=${index === 0 ? "UTF-8''" : ""}${part}`).join("; ");
1361
+ }
1362
+ function foldHeader(name, value) {
1363
+ const recommendedLineLength = 78;
1364
+ const lines = [];
1365
+ let prefix = `${name}: `;
1366
+ let remaining = value;
1367
+ while (prefix.length + remaining.length > recommendedLineLength) {
1368
+ const availableLength = recommendedLineLength - prefix.length;
1369
+ let breakIndex = -1;
1370
+ for (let index = Math.min(availableLength, remaining.length - 1); index >= 0; index--) if (remaining[index] === " " || remaining[index] === " ") {
1371
+ breakIndex = index;
1372
+ break;
1373
+ }
1374
+ if (breakIndex < 0) {
1375
+ for (let index = Math.max(availableLength + 1, 0); index < remaining.length; index++) if (remaining[index] === " " || remaining[index] === " ") {
1376
+ breakIndex = index;
1377
+ break;
1378
+ }
1379
+ }
1380
+ if (breakIndex < 0) break;
1381
+ let whitespaceEnd = breakIndex + 1;
1382
+ while (whitespaceEnd < remaining.length && (remaining[whitespaceEnd] === " " || remaining[whitespaceEnd] === " ")) whitespaceEnd++;
1383
+ if (whitespaceEnd === remaining.length) break;
1384
+ lines.push(prefix + remaining.slice(0, breakIndex));
1385
+ prefix = remaining.slice(breakIndex, whitespaceEnd);
1386
+ remaining = remaining.slice(whitespaceEnd);
1387
+ }
1388
+ lines.push(prefix + remaining);
1389
+ if (lines.some((line) => line.length > 998)) throw new RangeError(`Header field ${name} contains a token too long to fold.`);
1390
+ return lines.join("\r\n");
1391
+ }
1252
1392
  function encodeQuotedPrintable(text) {
1253
1393
  const utf8Bytes = new TextEncoder().encode(text);
1254
1394
  let result = "";
@@ -1379,12 +1519,13 @@ var SmtpTransport = class {
1379
1519
  options?.signal?.throwIfAborted();
1380
1520
  const smtpMessage = await convertMessage(message, this.config.dkim);
1381
1521
  options?.signal?.throwIfAborted();
1382
- const messageId = await connection.sendMessage(smtpMessage, options?.signal);
1522
+ const result = await connection.sendMessage(smtpMessage, options?.signal);
1383
1523
  await this.returnConnection(connection);
1384
1524
  return {
1385
1525
  successful: true,
1386
- messageId,
1387
- provider: "smtp"
1526
+ messageId: result.messageId,
1527
+ provider: "smtp",
1528
+ rejectedRecipients: result.rejectedRecipients
1388
1529
  };
1389
1530
  } catch (error) {
1390
1531
  if (connection != null) await this.discardConnection(connection);
@@ -1448,11 +1589,12 @@ var SmtpTransport = class {
1448
1589
  try {
1449
1590
  const smtpMessage = await convertMessage(message, this.config.dkim);
1450
1591
  options?.signal?.throwIfAborted();
1451
- const messageId = await connection.sendMessage(smtpMessage, options?.signal);
1592
+ const result = await connection.sendMessage(smtpMessage, options?.signal);
1452
1593
  yield {
1453
1594
  successful: true,
1454
- messageId,
1455
- provider: "smtp"
1595
+ messageId: result.messageId,
1596
+ provider: "smtp",
1597
+ rejectedRecipients: result.rejectedRecipients
1456
1598
  };
1457
1599
  } catch (error) {
1458
1600
  options?.signal?.throwIfAborted();
@@ -1469,11 +1611,12 @@ var SmtpTransport = class {
1469
1611
  try {
1470
1612
  const smtpMessage = await convertMessage(message, this.config.dkim);
1471
1613
  options?.signal?.throwIfAborted();
1472
- const messageId = await connection.sendMessage(smtpMessage, options?.signal);
1614
+ const result = await connection.sendMessage(smtpMessage, options?.signal);
1473
1615
  yield {
1474
1616
  successful: true,
1475
- messageId,
1476
- provider: "smtp"
1617
+ messageId: result.messageId,
1618
+ provider: "smtp",
1619
+ rejectedRecipients: result.rejectedRecipients
1477
1620
  };
1478
1621
  } catch (error) {
1479
1622
  options?.signal?.throwIfAborted();
@@ -1582,7 +1725,8 @@ function createSmtpFailure(message, error) {
1582
1725
  attempts: 1,
1583
1726
  providerDetails: {
1584
1727
  command: error.command,
1585
- response: error.response
1728
+ response: error.response,
1729
+ rejectedRecipients: error.rejectedRecipients
1586
1730
  }
1587
1731
  });
1588
1732
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upyo/smtp",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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.4"
57
57
  },
58
58
  "devDependencies": {
59
59
  "tsdown": "^0.12.7",