@upyo/smtp 0.6.0-dev.298 → 0.6.0-dev.301

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,6 +28,7 @@ Features
28
28
  - Multiple recipients (To, CC, BCC)
29
29
  - SMTP PIPELINING for faster multi-recipient delivery
30
30
  - SMTP SIZE declaration and advertised-limit checks
31
+ - Structured SMTP enhanced status codes
31
32
  - SMTPUTF8 internationalized address delivery
32
33
  - SMTP delivery status notification requests
33
34
  - Custom headers
@@ -184,6 +185,40 @@ See [RFC 1870] for the SMTP Message Size Declaration extension.
184
185
  [RFC 1870]: https://www.rfc-editor.org/rfc/rfc1870
185
186
 
186
187
 
188
+ Enhanced status codes
189
+ ---------------------
190
+
191
+ SMTP failures include a parsed enhanced status code when the server prefixes
192
+ its reply text with a valid RFC 2034 code such as `5.1.1`. The final reply
193
+ line's text remains in `providerDetails.response`, while
194
+ `providerDetails.enhancedStatusCode` provides the complete code and its numeric
195
+ `class`, `subject`, and `detail` fields. Address and message-content statuses
196
+ are categorized as validation failures, and network/routing statuses as network
197
+ failures.
198
+
199
+ Use `isSmtpResponseProviderDetails()` to narrow the provider-specific details:
200
+
201
+ ~~~~ typescript
202
+ import { isSmtpResponseProviderDetails } from "@upyo/smtp";
203
+
204
+ const error = receipt.successful ? undefined : receipt.errors?.[0];
205
+ if (isSmtpResponseProviderDetails(error?.providerDetails)) {
206
+ console.log(error.providerDetails.enhancedStatusCode?.subject);
207
+ }
208
+ ~~~~
209
+
210
+ Partially rejected recipients expose the same structure through
211
+ `receipt.rejectedRecipients[].enhancedStatusCode`. Replies without an enhanced
212
+ code, with malformed fields, or with a class that conflicts with the
213
+ three-digit SMTP reply continue to use the traditional reply classification.
214
+
215
+ See [RFC 2034] for the SMTP extension and [RFC 3463] for the status-code
216
+ structure.
217
+
218
+ [RFC 2034]: https://www.rfc-editor.org/rfc/rfc2034
219
+ [RFC 3463]: https://www.rfc-editor.org/rfc/rfc3463
220
+
221
+
187
222
  Internationalized addresses
188
223
  ---------------------------
189
224
 
package/dist/index.cjs CHANGED
@@ -472,6 +472,34 @@ var OAuth2TokenManager = class {
472
472
  }
473
473
  };
474
474
 
475
+ //#endregion
476
+ //#region src/smtp-status-code.ts
477
+ /**
478
+ * Parses an enhanced SMTP status code from the beginning of reply text.
479
+ *
480
+ * RFC 2034 requires the enhanced class to agree with the three-digit SMTP
481
+ * reply class. RFC 3463 also prohibits leading zeroes and limits the subject
482
+ * and detail fields to three digits.
483
+ *
484
+ * @param replyCode The three-digit SMTP reply code.
485
+ * @param response The textual part of the SMTP reply.
486
+ * @returns The parsed enhanced status code, or `undefined` when the reply does
487
+ * not begin with a valid, consistent code.
488
+ * @since 0.6.0
489
+ */
490
+ function parseEnhancedSmtpStatusCode(replyCode, response) {
491
+ const match = /^([245])\.((?:0|[1-9][0-9]{0,2}))\.((?:0|[1-9][0-9]{0,2})) +/.exec(response);
492
+ if (match == null) return void 0;
493
+ const statusClass = Number(match[1]);
494
+ if (Math.trunc(replyCode / 100) !== statusClass) return void 0;
495
+ return {
496
+ code: `${match[1]}.${match[2]}.${match[3]}`,
497
+ class: statusClass,
498
+ subject: Number(match[2]),
499
+ detail: Number(match[3])
500
+ };
501
+ }
502
+
475
503
  //#endregion
476
504
  //#region src/smtp-connection.ts
477
505
  /**
@@ -535,6 +563,37 @@ var SmtpPipelineTerminatedError = class extends Error {
535
563
  }
536
564
  };
537
565
  /**
566
+ * An authentication failure backed by an SMTP server reply.
567
+ *
568
+ * This keeps {@link SmtpAuthError} as the public authentication error while
569
+ * retaining the reply fields needed to build structured transport receipts.
570
+ *
571
+ * @since 0.6.0
572
+ */
573
+ var SmtpAuthResponseError = class extends SmtpAuthError {
574
+ /** The numeric SMTP reply code returned by the server. */
575
+ code;
576
+ /** The authentication command that produced the reply. */
577
+ command;
578
+ /** The textual SMTP reply returned by the server. */
579
+ response;
580
+ /**
581
+ * Creates an authentication response error.
582
+ *
583
+ * @param message A human-readable description of the authentication failure.
584
+ * @param code The numeric SMTP reply code.
585
+ * @param command The authentication command that produced the reply.
586
+ * @param response The textual SMTP reply returned by the server.
587
+ */
588
+ constructor(message, code, command, response) {
589
+ super(message);
590
+ this.name = "SmtpAuthResponseError";
591
+ this.code = code;
592
+ this.command = command;
593
+ this.response = response;
594
+ }
595
+ };
596
+ /**
538
597
  * The maximum length of an SMTP command line, including the terminating CRLF,
539
598
  * as specified by RFC 5321 §4.5.3.1.4.
540
599
  */
@@ -923,16 +982,16 @@ var SmtpConnection = class {
923
982
  const { user, pass } = auth;
924
983
  const credentials = btoa(`\0${user}\0${pass}`);
925
984
  const response = await this.sendCommand(`AUTH PLAIN ${credentials}`, signal);
926
- if (response.code !== 235) throw new Error(`Authentication failed: ${response.message}`);
985
+ if (response.code !== 235) throw new SmtpAuthResponseError(`Authentication failed: ${response.message}`, response.code, "AUTH PLAIN", response.message);
927
986
  }
928
987
  async authLogin(auth, signal) {
929
988
  const { user, pass } = auth;
930
989
  let response = await this.sendCommand("AUTH LOGIN", signal);
931
- if (response.code !== 334) throw new Error(`AUTH LOGIN failed: ${response.message}`);
990
+ if (response.code !== 334) throw new SmtpAuthResponseError(`AUTH LOGIN failed: ${response.message}`, response.code, "AUTH LOGIN", response.message);
932
991
  response = await this.sendCommand(btoa(user), signal);
933
- if (response.code !== 334) throw new Error(`Username authentication failed: ${response.message}`);
992
+ if (response.code !== 334) throw new SmtpAuthResponseError(`Username authentication failed: ${response.message}`, response.code, "AUTH LOGIN", response.message);
934
993
  response = await this.sendCommand(btoa(pass), signal);
935
- if (response.code !== 235) throw new Error(`Password authentication failed: ${response.message}`);
994
+ if (response.code !== 235) throw new SmtpAuthResponseError(`Password authentication failed: ${response.message}`, response.code, "AUTH LOGIN", response.message);
936
995
  }
937
996
  /**
938
997
  * Resolves an OAuth 2.0 access token via the connection's token manager,
@@ -984,16 +1043,19 @@ var SmtpConnection = class {
984
1043
  async finishOAuth2(response, mechanism, continuation, signal) {
985
1044
  if (response.code === 235) return;
986
1045
  if (response.code === 334) {
1046
+ let finalResponse;
987
1047
  let finalMessage = "";
988
1048
  try {
989
- const final = await this.sendCommand(continuation, signal);
990
- finalMessage = ` (${final.message})`;
1049
+ finalResponse = await this.sendCommand(continuation, signal);
1050
+ finalMessage = ` (${finalResponse.message})`;
991
1051
  } catch {
992
1052
  signal?.throwIfAborted();
993
1053
  }
994
- throw new SmtpAuthError(`${mechanism} authentication failed: ${decodeOAuth2Challenge(response.message)}${finalMessage}`);
1054
+ const message = `${mechanism} authentication failed: ${decodeOAuth2Challenge(response.message)}${finalMessage}`;
1055
+ if (finalResponse != null) throw new SmtpAuthResponseError(message, finalResponse.code, `AUTH ${mechanism}`, finalResponse.message);
1056
+ throw new SmtpAuthError(message);
995
1057
  }
996
- throw new SmtpAuthError(`${mechanism} authentication failed: ${response.message}`);
1058
+ throw new SmtpAuthResponseError(`${mechanism} authentication failed: ${response.message}`, response.code, `AUTH ${mechanism}`, response.message);
997
1059
  }
998
1060
  async sendMessage(message, signal) {
999
1061
  signal?.throwIfAborted();
@@ -1043,12 +1105,16 @@ var SmtpConnection = class {
1043
1105
  signal?.throwIfAborted();
1044
1106
  const rcptResponse = pipelining ? recipientResponses[index] : await this.sendCommand(recipientCommands[index], signal);
1045
1107
  if (rcptResponse.code === 421) throw new SmtpResponseError(`RCPT TO failed for ${recipient}: ${rcptResponse.message}`, rcptResponse.code, "RCPT TO", rcptResponse.message);
1046
- if (rcptResponse.code !== 250 && rcptResponse.code !== 251) rejectedRecipients.push({
1047
- recipient,
1048
- code: rcptResponse.code,
1049
- response: rcptResponse.message,
1050
- retryable: rcptResponse.code >= 400 && rcptResponse.code < 500
1051
- });
1108
+ if (rcptResponse.code !== 250 && rcptResponse.code !== 251) {
1109
+ const enhancedStatusCode = parseEnhancedSmtpStatusCode(rcptResponse.code, rcptResponse.message);
1110
+ rejectedRecipients.push({
1111
+ recipient,
1112
+ code: rcptResponse.code,
1113
+ response: rcptResponse.message,
1114
+ retryable: rcptResponse.code >= 400 && rcptResponse.code < 500,
1115
+ ...enhancedStatusCode == null ? {} : { enhancedStatusCode }
1116
+ });
1117
+ }
1052
1118
  }
1053
1119
  if (rejectedRecipients.length > 0 && rejectedRecipients.length === message.envelope.to.length) {
1054
1120
  const rejection = rejectedRecipients.find((item) => item.retryable) ?? rejectedRecipients[0];
@@ -2017,8 +2083,9 @@ function createSmtpFailure(message, error) {
2017
2083
  attempts: 1,
2018
2084
  providerDetails: { missingCapability: error.missingCapability }
2019
2085
  });
2020
- if (error instanceof SmtpResponseError) {
2021
- const classification = classifySmtpReply(error.code);
2086
+ if (error instanceof SmtpResponseError || error instanceof SmtpAuthResponseError) {
2087
+ const enhancedStatusCode = parseEnhancedSmtpStatusCode(error.code, error.response);
2088
+ const classification = classifySmtpReply(error.code, enhancedStatusCode);
2022
2089
  return (0, __upyo_core.createFailedReceipt)(message, {
2023
2090
  provider: "smtp",
2024
2091
  code: `smtp.${error.code}`,
@@ -2028,7 +2095,8 @@ function createSmtpFailure(message, error) {
2028
2095
  providerDetails: {
2029
2096
  command: error.command,
2030
2097
  response: error.response,
2031
- rejectedRecipients: error.rejectedRecipients
2098
+ rejectedRecipients: error instanceof SmtpResponseError ? error.rejectedRecipients : void 0,
2099
+ ...enhancedStatusCode == null ? {} : { enhancedStatusCode }
2032
2100
  }
2033
2101
  });
2034
2102
  }
@@ -2040,14 +2108,23 @@ function createSmtpFailure(message, error) {
2040
2108
  function isReusableLocalFailure(error) {
2041
2109
  return error instanceof SmtpMessageSizeError || error instanceof SmtpUtf8UnsupportedError || error instanceof SmtpDsnValidationError || error instanceof SmtpDsnUnsupportedError;
2042
2110
  }
2043
- function classifySmtpReply(code) {
2111
+ function classifySmtpReply(code, enhancedStatusCode) {
2112
+ const retryable = enhancedStatusCode == null ? code >= 400 && code < 500 : enhancedStatusCode.class === 4;
2113
+ if (enhancedStatusCode?.subject === 1 || enhancedStatusCode?.subject === 6) return {
2114
+ category: "validation",
2115
+ retryable
2116
+ };
2117
+ if (enhancedStatusCode?.subject === 4) return {
2118
+ category: "network",
2119
+ retryable
2120
+ };
2044
2121
  if (code >= 400 && code < 500) return {
2045
2122
  category: "service-unavailable",
2046
- retryable: true
2123
+ retryable
2047
2124
  };
2048
2125
  if (code >= 500 && code < 600) return {
2049
2126
  category: "rejected",
2050
- retryable: false
2127
+ retryable
2051
2128
  };
2052
2129
  return {
2053
2130
  category: "unknown",
@@ -2055,8 +2132,22 @@ function classifySmtpReply(code) {
2055
2132
  };
2056
2133
  }
2057
2134
 
2135
+ //#endregion
2136
+ //#region src/smtp-receipt.ts
2137
+ /**
2138
+ * Checks whether provider details came from an SMTP server response.
2139
+ *
2140
+ * @param value Provider details from an SMTP receipt error.
2141
+ * @returns Whether the value contains SMTP response details.
2142
+ * @since 0.6.0
2143
+ */
2144
+ function isSmtpResponseProviderDetails(value) {
2145
+ return value != null && typeof value === "object" && "command" in value && typeof value.command === "string" && "response" in value && typeof value.response === "string";
2146
+ }
2147
+
2058
2148
  //#endregion
2059
2149
  exports.SmtpAuthError = SmtpAuthError;
2060
2150
  exports.SmtpDsnUnsupportedError = SmtpDsnUnsupportedError;
2061
2151
  exports.SmtpDsnValidationError = SmtpDsnValidationError;
2062
- exports.SmtpTransport = SmtpTransport;
2152
+ exports.SmtpTransport = SmtpTransport;
2153
+ exports.isSmtpResponseProviderDetails = isSmtpResponseProviderDetails;
package/dist/index.d.cts CHANGED
@@ -510,6 +510,21 @@ declare class SmtpDsnUnsupportedError extends Error {
510
510
  /** @internal */
511
511
  //#endregion
512
512
  //#region src/smtp-receipt.d.ts
513
+ /**
514
+ * A machine-readable enhanced SMTP status code defined by RFC 3463.
515
+ *
516
+ * @since 0.6.0
517
+ */
518
+ interface SmtpEnhancedStatusCode {
519
+ /** The complete enhanced status code. */
520
+ readonly code: string;
521
+ /** The delivery result class: success, transient failure, or permanent failure. */
522
+ readonly class: 2 | 4 | 5;
523
+ /** The subject identifying the probable source of the condition. */
524
+ readonly subject: number;
525
+ /** The detail identifying the precise condition. */
526
+ readonly detail: number;
527
+ }
513
528
  /**
514
529
  * An SMTP envelope recipient that the server rejected during an otherwise
515
530
  * successful delivery.
@@ -525,7 +540,62 @@ interface SmtpRejectedRecipient {
525
540
  readonly response: string;
526
541
  /** Whether retrying delivery to this recipient may succeed. */
527
542
  readonly retryable: boolean;
543
+ /**
544
+ * The enhanced status code returned for this recipient, when valid.
545
+ *
546
+ * @since 0.6.0
547
+ */
548
+ readonly enhancedStatusCode?: SmtpEnhancedStatusCode;
528
549
  }
550
+ /**
551
+ * Provider details for a failure returned by an SMTP server.
552
+ *
553
+ * @since 0.6.0
554
+ */
555
+ interface SmtpResponseProviderDetails {
556
+ /** The SMTP command that failed. */
557
+ readonly command: string;
558
+ /** The text from the SMTP server's final reply line. */
559
+ readonly response: string;
560
+ /** Recipient-level failures collected for the transaction. */
561
+ readonly rejectedRecipients?: readonly SmtpRejectedRecipient[];
562
+ /** The enhanced status code in the server reply, when valid. */
563
+ readonly enhancedStatusCode?: SmtpEnhancedStatusCode;
564
+ }
565
+ /**
566
+ * Checks whether provider details came from an SMTP server response.
567
+ *
568
+ * @param value Provider details from an SMTP receipt error.
569
+ * @returns Whether the value contains SMTP response details.
570
+ * @since 0.6.0
571
+ */
572
+ declare function isSmtpResponseProviderDetails(value: unknown): value is SmtpResponseProviderDetails;
573
+ /**
574
+ * Provider details for a locally rejected oversized message.
575
+ *
576
+ * @since 0.6.0
577
+ */
578
+ interface SmtpMessageSizeProviderDetails {
579
+ /** The encoded message size in octets. */
580
+ readonly actualSize: number;
581
+ /** The server's advertised maximum size in octets. */
582
+ readonly maximumSize: string;
583
+ }
584
+ /**
585
+ * Provider details for a missing internationalization capability.
586
+ *
587
+ * @since 0.6.0
588
+ */
589
+ interface SmtpUtf8ProviderDetails {
590
+ /** The SMTP extension required by the message. */
591
+ readonly missingCapability: "SMTPUTF8" | "8BITMIME";
592
+ }
593
+ /**
594
+ * Provider-specific details attached to SMTP receipt errors.
595
+ *
596
+ * @since 0.6.0
597
+ */
598
+ type SmtpProviderDetails = SmtpResponseProviderDetails | SmtpMessageSizeProviderDetails | SmtpUtf8ProviderDetails;
529
599
  /**
530
600
  * A receipt returned by {@link SmtpTransport}.
531
601
  *
@@ -727,4 +797,4 @@ declare class SmtpAuthError extends Error {
727
797
  */
728
798
 
729
799
  //#endregion
730
- export { DkimAlgorithm, DkimCanonicalization, DkimConfig, DkimSignature, DkimSigningFailureAction, OAuth2TokenProvider, SmtpAuth, SmtpAuthError, SmtpConfig, SmtpDsnNotification, SmtpDsnOptions, SmtpDsnRecipientOptions, SmtpDsnUnsupportedError, SmtpDsnValidationError, SmtpOAuth2Auth, SmtpOAuth2RefreshAuth, SmtpOAuth2TokenAuth, SmtpReceipt, SmtpRejectedRecipient, SmtpTlsOptions, SmtpTransport, SmtpTransportOptions, SmtpUserPassAuth };
800
+ export { DkimAlgorithm, DkimCanonicalization, DkimConfig, DkimSignature, DkimSigningFailureAction, OAuth2TokenProvider, SmtpAuth, SmtpAuthError, SmtpConfig, SmtpDsnNotification, SmtpDsnOptions, SmtpDsnRecipientOptions, SmtpDsnUnsupportedError, SmtpDsnValidationError, SmtpEnhancedStatusCode, SmtpMessageSizeProviderDetails, SmtpOAuth2Auth, SmtpOAuth2RefreshAuth, SmtpOAuth2TokenAuth, SmtpProviderDetails, SmtpReceipt, SmtpRejectedRecipient, SmtpResponseProviderDetails, SmtpTlsOptions, SmtpTransport, SmtpTransportOptions, SmtpUserPassAuth, SmtpUtf8ProviderDetails, isSmtpResponseProviderDetails };
package/dist/index.d.ts CHANGED
@@ -510,6 +510,21 @@ declare class SmtpDsnUnsupportedError extends Error {
510
510
  /** @internal */
511
511
  //#endregion
512
512
  //#region src/smtp-receipt.d.ts
513
+ /**
514
+ * A machine-readable enhanced SMTP status code defined by RFC 3463.
515
+ *
516
+ * @since 0.6.0
517
+ */
518
+ interface SmtpEnhancedStatusCode {
519
+ /** The complete enhanced status code. */
520
+ readonly code: string;
521
+ /** The delivery result class: success, transient failure, or permanent failure. */
522
+ readonly class: 2 | 4 | 5;
523
+ /** The subject identifying the probable source of the condition. */
524
+ readonly subject: number;
525
+ /** The detail identifying the precise condition. */
526
+ readonly detail: number;
527
+ }
513
528
  /**
514
529
  * An SMTP envelope recipient that the server rejected during an otherwise
515
530
  * successful delivery.
@@ -525,7 +540,62 @@ interface SmtpRejectedRecipient {
525
540
  readonly response: string;
526
541
  /** Whether retrying delivery to this recipient may succeed. */
527
542
  readonly retryable: boolean;
543
+ /**
544
+ * The enhanced status code returned for this recipient, when valid.
545
+ *
546
+ * @since 0.6.0
547
+ */
548
+ readonly enhancedStatusCode?: SmtpEnhancedStatusCode;
528
549
  }
550
+ /**
551
+ * Provider details for a failure returned by an SMTP server.
552
+ *
553
+ * @since 0.6.0
554
+ */
555
+ interface SmtpResponseProviderDetails {
556
+ /** The SMTP command that failed. */
557
+ readonly command: string;
558
+ /** The text from the SMTP server's final reply line. */
559
+ readonly response: string;
560
+ /** Recipient-level failures collected for the transaction. */
561
+ readonly rejectedRecipients?: readonly SmtpRejectedRecipient[];
562
+ /** The enhanced status code in the server reply, when valid. */
563
+ readonly enhancedStatusCode?: SmtpEnhancedStatusCode;
564
+ }
565
+ /**
566
+ * Checks whether provider details came from an SMTP server response.
567
+ *
568
+ * @param value Provider details from an SMTP receipt error.
569
+ * @returns Whether the value contains SMTP response details.
570
+ * @since 0.6.0
571
+ */
572
+ declare function isSmtpResponseProviderDetails(value: unknown): value is SmtpResponseProviderDetails;
573
+ /**
574
+ * Provider details for a locally rejected oversized message.
575
+ *
576
+ * @since 0.6.0
577
+ */
578
+ interface SmtpMessageSizeProviderDetails {
579
+ /** The encoded message size in octets. */
580
+ readonly actualSize: number;
581
+ /** The server's advertised maximum size in octets. */
582
+ readonly maximumSize: string;
583
+ }
584
+ /**
585
+ * Provider details for a missing internationalization capability.
586
+ *
587
+ * @since 0.6.0
588
+ */
589
+ interface SmtpUtf8ProviderDetails {
590
+ /** The SMTP extension required by the message. */
591
+ readonly missingCapability: "SMTPUTF8" | "8BITMIME";
592
+ }
593
+ /**
594
+ * Provider-specific details attached to SMTP receipt errors.
595
+ *
596
+ * @since 0.6.0
597
+ */
598
+ type SmtpProviderDetails = SmtpResponseProviderDetails | SmtpMessageSizeProviderDetails | SmtpUtf8ProviderDetails;
529
599
  /**
530
600
  * A receipt returned by {@link SmtpTransport}.
531
601
  *
@@ -727,4 +797,4 @@ declare class SmtpAuthError extends Error {
727
797
  */
728
798
 
729
799
  //#endregion
730
- export { DkimAlgorithm, DkimCanonicalization, DkimConfig, DkimSignature, DkimSigningFailureAction, OAuth2TokenProvider, SmtpAuth, SmtpAuthError, SmtpConfig, SmtpDsnNotification, SmtpDsnOptions, SmtpDsnRecipientOptions, SmtpDsnUnsupportedError, SmtpDsnValidationError, SmtpOAuth2Auth, SmtpOAuth2RefreshAuth, SmtpOAuth2TokenAuth, SmtpReceipt, SmtpRejectedRecipient, SmtpTlsOptions, SmtpTransport, SmtpTransportOptions, SmtpUserPassAuth };
800
+ export { DkimAlgorithm, DkimCanonicalization, DkimConfig, DkimSignature, DkimSigningFailureAction, OAuth2TokenProvider, SmtpAuth, SmtpAuthError, SmtpConfig, SmtpDsnNotification, SmtpDsnOptions, SmtpDsnRecipientOptions, SmtpDsnUnsupportedError, SmtpDsnValidationError, SmtpEnhancedStatusCode, SmtpMessageSizeProviderDetails, SmtpOAuth2Auth, SmtpOAuth2RefreshAuth, SmtpOAuth2TokenAuth, SmtpProviderDetails, SmtpReceipt, SmtpRejectedRecipient, SmtpResponseProviderDetails, SmtpTlsOptions, SmtpTransport, SmtpTransportOptions, SmtpUserPassAuth, SmtpUtf8ProviderDetails, isSmtpResponseProviderDetails };
package/dist/index.js CHANGED
@@ -449,6 +449,34 @@ var OAuth2TokenManager = class {
449
449
  }
450
450
  };
451
451
 
452
+ //#endregion
453
+ //#region src/smtp-status-code.ts
454
+ /**
455
+ * Parses an enhanced SMTP status code from the beginning of reply text.
456
+ *
457
+ * RFC 2034 requires the enhanced class to agree with the three-digit SMTP
458
+ * reply class. RFC 3463 also prohibits leading zeroes and limits the subject
459
+ * and detail fields to three digits.
460
+ *
461
+ * @param replyCode The three-digit SMTP reply code.
462
+ * @param response The textual part of the SMTP reply.
463
+ * @returns The parsed enhanced status code, or `undefined` when the reply does
464
+ * not begin with a valid, consistent code.
465
+ * @since 0.6.0
466
+ */
467
+ function parseEnhancedSmtpStatusCode(replyCode, response) {
468
+ const match = /^([245])\.((?:0|[1-9][0-9]{0,2}))\.((?:0|[1-9][0-9]{0,2})) +/.exec(response);
469
+ if (match == null) return void 0;
470
+ const statusClass = Number(match[1]);
471
+ if (Math.trunc(replyCode / 100) !== statusClass) return void 0;
472
+ return {
473
+ code: `${match[1]}.${match[2]}.${match[3]}`,
474
+ class: statusClass,
475
+ subject: Number(match[2]),
476
+ detail: Number(match[3])
477
+ };
478
+ }
479
+
452
480
  //#endregion
453
481
  //#region src/smtp-connection.ts
454
482
  /**
@@ -512,6 +540,37 @@ var SmtpPipelineTerminatedError = class extends Error {
512
540
  }
513
541
  };
514
542
  /**
543
+ * An authentication failure backed by an SMTP server reply.
544
+ *
545
+ * This keeps {@link SmtpAuthError} as the public authentication error while
546
+ * retaining the reply fields needed to build structured transport receipts.
547
+ *
548
+ * @since 0.6.0
549
+ */
550
+ var SmtpAuthResponseError = class extends SmtpAuthError {
551
+ /** The numeric SMTP reply code returned by the server. */
552
+ code;
553
+ /** The authentication command that produced the reply. */
554
+ command;
555
+ /** The textual SMTP reply returned by the server. */
556
+ response;
557
+ /**
558
+ * Creates an authentication response error.
559
+ *
560
+ * @param message A human-readable description of the authentication failure.
561
+ * @param code The numeric SMTP reply code.
562
+ * @param command The authentication command that produced the reply.
563
+ * @param response The textual SMTP reply returned by the server.
564
+ */
565
+ constructor(message, code, command, response) {
566
+ super(message);
567
+ this.name = "SmtpAuthResponseError";
568
+ this.code = code;
569
+ this.command = command;
570
+ this.response = response;
571
+ }
572
+ };
573
+ /**
515
574
  * The maximum length of an SMTP command line, including the terminating CRLF,
516
575
  * as specified by RFC 5321 §4.5.3.1.4.
517
576
  */
@@ -900,16 +959,16 @@ var SmtpConnection = class {
900
959
  const { user, pass } = auth;
901
960
  const credentials = btoa(`\0${user}\0${pass}`);
902
961
  const response = await this.sendCommand(`AUTH PLAIN ${credentials}`, signal);
903
- if (response.code !== 235) throw new Error(`Authentication failed: ${response.message}`);
962
+ if (response.code !== 235) throw new SmtpAuthResponseError(`Authentication failed: ${response.message}`, response.code, "AUTH PLAIN", response.message);
904
963
  }
905
964
  async authLogin(auth, signal) {
906
965
  const { user, pass } = auth;
907
966
  let response = await this.sendCommand("AUTH LOGIN", signal);
908
- if (response.code !== 334) throw new Error(`AUTH LOGIN failed: ${response.message}`);
967
+ if (response.code !== 334) throw new SmtpAuthResponseError(`AUTH LOGIN failed: ${response.message}`, response.code, "AUTH LOGIN", response.message);
909
968
  response = await this.sendCommand(btoa(user), signal);
910
- if (response.code !== 334) throw new Error(`Username authentication failed: ${response.message}`);
969
+ if (response.code !== 334) throw new SmtpAuthResponseError(`Username authentication failed: ${response.message}`, response.code, "AUTH LOGIN", response.message);
911
970
  response = await this.sendCommand(btoa(pass), signal);
912
- if (response.code !== 235) throw new Error(`Password authentication failed: ${response.message}`);
971
+ if (response.code !== 235) throw new SmtpAuthResponseError(`Password authentication failed: ${response.message}`, response.code, "AUTH LOGIN", response.message);
913
972
  }
914
973
  /**
915
974
  * Resolves an OAuth 2.0 access token via the connection's token manager,
@@ -961,16 +1020,19 @@ var SmtpConnection = class {
961
1020
  async finishOAuth2(response, mechanism, continuation, signal) {
962
1021
  if (response.code === 235) return;
963
1022
  if (response.code === 334) {
1023
+ let finalResponse;
964
1024
  let finalMessage = "";
965
1025
  try {
966
- const final = await this.sendCommand(continuation, signal);
967
- finalMessage = ` (${final.message})`;
1026
+ finalResponse = await this.sendCommand(continuation, signal);
1027
+ finalMessage = ` (${finalResponse.message})`;
968
1028
  } catch {
969
1029
  signal?.throwIfAborted();
970
1030
  }
971
- throw new SmtpAuthError(`${mechanism} authentication failed: ${decodeOAuth2Challenge(response.message)}${finalMessage}`);
1031
+ const message = `${mechanism} authentication failed: ${decodeOAuth2Challenge(response.message)}${finalMessage}`;
1032
+ if (finalResponse != null) throw new SmtpAuthResponseError(message, finalResponse.code, `AUTH ${mechanism}`, finalResponse.message);
1033
+ throw new SmtpAuthError(message);
972
1034
  }
973
- throw new SmtpAuthError(`${mechanism} authentication failed: ${response.message}`);
1035
+ throw new SmtpAuthResponseError(`${mechanism} authentication failed: ${response.message}`, response.code, `AUTH ${mechanism}`, response.message);
974
1036
  }
975
1037
  async sendMessage(message, signal) {
976
1038
  signal?.throwIfAborted();
@@ -1020,12 +1082,16 @@ var SmtpConnection = class {
1020
1082
  signal?.throwIfAborted();
1021
1083
  const rcptResponse = pipelining ? recipientResponses[index] : await this.sendCommand(recipientCommands[index], signal);
1022
1084
  if (rcptResponse.code === 421) throw new SmtpResponseError(`RCPT TO failed for ${recipient}: ${rcptResponse.message}`, rcptResponse.code, "RCPT TO", rcptResponse.message);
1023
- if (rcptResponse.code !== 250 && rcptResponse.code !== 251) rejectedRecipients.push({
1024
- recipient,
1025
- code: rcptResponse.code,
1026
- response: rcptResponse.message,
1027
- retryable: rcptResponse.code >= 400 && rcptResponse.code < 500
1028
- });
1085
+ if (rcptResponse.code !== 250 && rcptResponse.code !== 251) {
1086
+ const enhancedStatusCode = parseEnhancedSmtpStatusCode(rcptResponse.code, rcptResponse.message);
1087
+ rejectedRecipients.push({
1088
+ recipient,
1089
+ code: rcptResponse.code,
1090
+ response: rcptResponse.message,
1091
+ retryable: rcptResponse.code >= 400 && rcptResponse.code < 500,
1092
+ ...enhancedStatusCode == null ? {} : { enhancedStatusCode }
1093
+ });
1094
+ }
1029
1095
  }
1030
1096
  if (rejectedRecipients.length > 0 && rejectedRecipients.length === message.envelope.to.length) {
1031
1097
  const rejection = rejectedRecipients.find((item) => item.retryable) ?? rejectedRecipients[0];
@@ -1994,8 +2060,9 @@ function createSmtpFailure(message, error) {
1994
2060
  attempts: 1,
1995
2061
  providerDetails: { missingCapability: error.missingCapability }
1996
2062
  });
1997
- if (error instanceof SmtpResponseError) {
1998
- const classification = classifySmtpReply(error.code);
2063
+ if (error instanceof SmtpResponseError || error instanceof SmtpAuthResponseError) {
2064
+ const enhancedStatusCode = parseEnhancedSmtpStatusCode(error.code, error.response);
2065
+ const classification = classifySmtpReply(error.code, enhancedStatusCode);
1999
2066
  return createFailedReceipt(message, {
2000
2067
  provider: "smtp",
2001
2068
  code: `smtp.${error.code}`,
@@ -2005,7 +2072,8 @@ function createSmtpFailure(message, error) {
2005
2072
  providerDetails: {
2006
2073
  command: error.command,
2007
2074
  response: error.response,
2008
- rejectedRecipients: error.rejectedRecipients
2075
+ rejectedRecipients: error instanceof SmtpResponseError ? error.rejectedRecipients : void 0,
2076
+ ...enhancedStatusCode == null ? {} : { enhancedStatusCode }
2009
2077
  }
2010
2078
  });
2011
2079
  }
@@ -2017,14 +2085,23 @@ function createSmtpFailure(message, error) {
2017
2085
  function isReusableLocalFailure(error) {
2018
2086
  return error instanceof SmtpMessageSizeError || error instanceof SmtpUtf8UnsupportedError || error instanceof SmtpDsnValidationError || error instanceof SmtpDsnUnsupportedError;
2019
2087
  }
2020
- function classifySmtpReply(code) {
2088
+ function classifySmtpReply(code, enhancedStatusCode) {
2089
+ const retryable = enhancedStatusCode == null ? code >= 400 && code < 500 : enhancedStatusCode.class === 4;
2090
+ if (enhancedStatusCode?.subject === 1 || enhancedStatusCode?.subject === 6) return {
2091
+ category: "validation",
2092
+ retryable
2093
+ };
2094
+ if (enhancedStatusCode?.subject === 4) return {
2095
+ category: "network",
2096
+ retryable
2097
+ };
2021
2098
  if (code >= 400 && code < 500) return {
2022
2099
  category: "service-unavailable",
2023
- retryable: true
2100
+ retryable
2024
2101
  };
2025
2102
  if (code >= 500 && code < 600) return {
2026
2103
  category: "rejected",
2027
- retryable: false
2104
+ retryable
2028
2105
  };
2029
2106
  return {
2030
2107
  category: "unknown",
@@ -2033,4 +2110,17 @@ function classifySmtpReply(code) {
2033
2110
  }
2034
2111
 
2035
2112
  //#endregion
2036
- export { SmtpAuthError, SmtpDsnUnsupportedError, SmtpDsnValidationError, SmtpTransport };
2113
+ //#region src/smtp-receipt.ts
2114
+ /**
2115
+ * Checks whether provider details came from an SMTP server response.
2116
+ *
2117
+ * @param value Provider details from an SMTP receipt error.
2118
+ * @returns Whether the value contains SMTP response details.
2119
+ * @since 0.6.0
2120
+ */
2121
+ function isSmtpResponseProviderDetails(value) {
2122
+ return value != null && typeof value === "object" && "command" in value && typeof value.command === "string" && "response" in value && typeof value.response === "string";
2123
+ }
2124
+
2125
+ //#endregion
2126
+ export { SmtpAuthError, SmtpDsnUnsupportedError, SmtpDsnValidationError, SmtpTransport, isSmtpResponseProviderDetails };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upyo/smtp",
3
- "version": "0.6.0-dev.298",
3
+ "version": "0.6.0-dev.301",
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.298+6dbdd09b"
56
+ "@upyo/core": "0.6.0-dev.301+877ca1c9"
57
57
  },
58
58
  "devDependencies": {
59
59
  "tsdown": "^0.12.7",