@upyo/smtp 0.5.0-dev.86 → 0.5.0

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
@@ -21,6 +21,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
21
21
  }) : target, mod));
22
22
 
23
23
  //#endregion
24
+ const __upyo_core = __toESM(require("@upyo/core"));
24
25
  const node_net = __toESM(require("node:net"));
25
26
  const node_tls = __toESM(require("node:tls"));
26
27
  const node_buffer = __toESM(require("node:buffer"));
@@ -53,15 +54,344 @@ function createSmtpConfig(config) {
53
54
  };
54
55
  }
55
56
 
57
+ //#endregion
58
+ //#region src/oauth2.ts
59
+ /**
60
+ * An error thrown when SMTP authentication fails, including OAuth 2.0 token
61
+ * acquisition and SASL exchange failures.
62
+ *
63
+ * @since 0.5.0
64
+ */
65
+ var SmtpAuthError = class extends Error {
66
+ /**
67
+ * Creates a new {@link SmtpAuthError}.
68
+ * @param message A human-readable description of the failure.
69
+ */
70
+ constructor(message) {
71
+ super(message);
72
+ this.name = "SmtpAuthError";
73
+ }
74
+ };
75
+ /**
76
+ * Encodes a string as Base64 in a cross-runtime, UTF-8-safe manner.
77
+ *
78
+ * Unlike calling `btoa()` directly, this first encodes the input as UTF-8 so
79
+ * that non-ASCII characters (e.g., in an internationalized address) do not
80
+ * throw.
81
+ *
82
+ * @param input The string to encode.
83
+ * @returns The Base64-encoded representation of the UTF-8 bytes of `input`.
84
+ */
85
+ function toBase64(input) {
86
+ const bytes = new TextEncoder().encode(input);
87
+ let binary = "";
88
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
89
+ return btoa(binary);
90
+ }
91
+ /**
92
+ * Builds the SASL `XOAUTH2` initial client response for SMTP authentication.
93
+ *
94
+ * The unencoded payload follows Google's XOAUTH2 format
95
+ * (`user=<user>^Aauth=Bearer <token>^A^A`, where `^A` is the `0x01` control
96
+ * character) and is returned Base64-encoded, ready to be sent as the argument
97
+ * of `AUTH XOAUTH2`.
98
+ *
99
+ * @param user The user (email address) to authenticate as.
100
+ * @param accessToken The OAuth 2.0 access token.
101
+ * @returns The Base64-encoded XOAUTH2 initial client response.
102
+ * @since 0.5.0
103
+ */
104
+ function formatXoauth2(user, accessToken) {
105
+ return toBase64(`user=${user}\x01auth=Bearer ${accessToken}\x01\x01`);
106
+ }
107
+ /**
108
+ * Builds the SASL `OAUTHBEARER` initial client response for SMTP
109
+ * authentication, as defined by [RFC 7628].
110
+ *
111
+ * The unencoded payload is
112
+ * `n,a=<user>,^A[host=<host>^A][port=<port>^A]auth=Bearer <token>^A^A`
113
+ * (where `^A` is the `0x01` control character) and is returned Base64-encoded,
114
+ * ready to be sent as the argument of `AUTH OAUTHBEARER`.
115
+ *
116
+ * [RFC 7628]: https://www.rfc-editor.org/rfc/rfc7628
117
+ *
118
+ * @param user The user (email address) to authenticate as.
119
+ * @param accessToken The OAuth 2.0 access token.
120
+ * @param host The server host to include as the `host` key/value, if any.
121
+ * @param port The server port to include as the `port` key/value, if any.
122
+ * @returns The Base64-encoded OAUTHBEARER initial client response.
123
+ * @since 0.5.0
124
+ */
125
+ function formatOauthbearer(user, accessToken, host, port) {
126
+ let response = `n,a=${escapeSaslName(user)},\x01`;
127
+ if (host != null) response += `host=${host}\x01`;
128
+ if (port != null) response += `port=${port}\x01`;
129
+ response += `auth=Bearer ${accessToken}\x01\x01`;
130
+ return toBase64(response);
131
+ }
132
+ /**
133
+ * Escapes a SASL name (GS2 `authzid`) per RFC 5801, encoding `,` as `=2C` and
134
+ * `=` as `=3D` so that identities containing those characters do not corrupt
135
+ * the GS2 header.
136
+ *
137
+ * @param name The SASL name to escape.
138
+ * @returns The escaped SASL name.
139
+ */
140
+ function escapeSaslName(name) {
141
+ return name.replace(/=/g, "=3D").replace(/,/g, "=2C");
142
+ }
143
+ /**
144
+ * Chooses an OAuth 2.0 SASL mechanism based on the mechanisms advertised by the
145
+ * server in its `AUTH` capability line.
146
+ *
147
+ * `XOAUTH2` is preferred when available (it is the de-facto standard supported
148
+ * by Gmail and Outlook); otherwise `OAUTHBEARER` is used when advertised. When
149
+ * neither is advertised, `xoauth2` is returned as a best-effort default.
150
+ *
151
+ * @param capabilities The capability lines parsed from the server's EHLO
152
+ * response.
153
+ * @returns The selected mechanism, either `"xoauth2"` or `"oauthbearer"`.
154
+ * @since 0.5.0
155
+ */
156
+ function selectOAuth2Mechanism(capabilities) {
157
+ const authLine = capabilities.find((cap) => cap.toUpperCase().startsWith("AUTH"));
158
+ const mechanisms = authLine == null ? [] : authLine.toUpperCase().replace(/=/g, " ").split(/\s+/).slice(1);
159
+ if (mechanisms.includes("XOAUTH2")) return "xoauth2";
160
+ if (mechanisms.includes("OAUTHBEARER")) return "oauthbearer";
161
+ return "xoauth2";
162
+ }
163
+ /**
164
+ * Validates that an OAuth 2.0 token endpoint is safe to send client credentials
165
+ * to, requiring HTTPS except for loopback addresses (for local testing).
166
+ *
167
+ * @param endpoint The token endpoint URL to validate.
168
+ * @throws {TypeError} If the URL is malformed or uses an insecure scheme.
169
+ */
170
+ function assertSecureTokenEndpoint(endpoint) {
171
+ let url;
172
+ try {
173
+ url = new URL(endpoint);
174
+ } catch {
175
+ throw new TypeError(`Invalid OAuth 2.0 token endpoint URL: ${endpoint}`);
176
+ }
177
+ if (url.protocol === "https:") return;
178
+ const isLoopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
179
+ if (url.protocol === "http:" && isLoopback) return;
180
+ throw new TypeError("OAuth 2.0 token endpoint must use HTTPS to protect client credentials: " + endpoint);
181
+ }
182
+ /**
183
+ * The number of milliseconds before a cached access token's expiry at which it
184
+ * is considered stale and eligible for refresh.
185
+ */
186
+ const REFRESH_SAFETY_MARGIN_MS = 6e4;
187
+ /**
188
+ * The default access token lifetime (in seconds) assumed when the token
189
+ * endpoint omits `expires_in`.
190
+ */
191
+ const DEFAULT_EXPIRES_IN = 3600;
192
+ /**
193
+ * How long to wait for the token endpoint before aborting the request, so a
194
+ * stalled endpoint cannot block authentication indefinitely.
195
+ */
196
+ const TOKEN_REQUEST_TIMEOUT_MS = 6e4;
197
+ /** Maximum number of response-body characters to include in error messages. */
198
+ const MAX_ERROR_BODY_LENGTH = 500;
199
+ /**
200
+ * Truncates a token-endpoint response body so an oversized payload (e.g. a
201
+ * large proxy/CDN error page) does not bloat error messages and logs.
202
+ *
203
+ * @param text The response body text.
204
+ * @returns The text, truncated with an ellipsis if it exceeds the limit.
205
+ */
206
+ function truncateErrorBody(text) {
207
+ return text.length > MAX_ERROR_BODY_LENGTH ? `${text.slice(0, MAX_ERROR_BODY_LENGTH)}…` : text;
208
+ }
209
+ /**
210
+ * Mirrors a promise but rejects early if the given abort signal fires, without
211
+ * cancelling the underlying promise. This lets a single waiter abort its own
212
+ * wait on a shared operation without affecting other waiters.
213
+ *
214
+ * @param promise The promise to await.
215
+ * @param signal An optional abort signal that rejects the returned promise.
216
+ * @returns A promise that settles with `promise`, or rejects when `signal`
217
+ * aborts.
218
+ */
219
+ function abortable(promise, signal) {
220
+ if (signal == null) return promise;
221
+ return new Promise((resolve, reject) => {
222
+ const abortReason = () => signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
223
+ const onAbort = () => reject(abortReason());
224
+ if (signal.aborted) reject(abortReason());
225
+ else signal.addEventListener("abort", onAbort, { once: true });
226
+ promise.then((value) => {
227
+ signal.removeEventListener("abort", onAbort);
228
+ resolve(value);
229
+ }, (error) => {
230
+ signal.removeEventListener("abort", onAbort);
231
+ reject(error);
232
+ });
233
+ });
234
+ }
235
+ /**
236
+ * Acquires and caches OAuth 2.0 access tokens for SMTP authentication.
237
+ *
238
+ * Depending on the {@link SmtpOAuth2Auth} configuration, the manager either
239
+ * returns a static token, invokes a provider callback on every request, or
240
+ * exchanges a refresh token for an access token at the configured token
241
+ * endpoint and caches it until shortly before it expires. A single manager is
242
+ * shared across all pooled connections of a transport so that the refresh-token
243
+ * exchange happens at most once per token lifetime.
244
+ *
245
+ * @since 0.5.0
246
+ */
247
+ var OAuth2TokenManager = class {
248
+ auth;
249
+ fetchFn;
250
+ cached;
251
+ pending;
252
+ /**
253
+ * Creates a new {@link OAuth2TokenManager}.
254
+ *
255
+ * @param auth The OAuth 2.0 authentication configuration.
256
+ * @param fetchFn The `fetch` implementation to use for the refresh-token
257
+ * exchange. Defaults to the global `fetch`; primarily
258
+ * overridden in tests.
259
+ * @throws {TypeError} If a refresh-token configuration has an insecure
260
+ * (non-HTTPS) token endpoint.
261
+ */
262
+ constructor(auth, fetchFn = fetch) {
263
+ if ("refreshToken" in auth) assertSecureTokenEndpoint(auth.tokenEndpoint);
264
+ this.auth = auth;
265
+ this.fetchFn = fetchFn;
266
+ }
267
+ /**
268
+ * Returns a valid OAuth 2.0 access token, acquiring or refreshing it as
269
+ * needed.
270
+ *
271
+ * @param signal An optional {@link AbortSignal} to cancel token acquisition.
272
+ * @returns The current access token.
273
+ * @throws {SmtpAuthError} If a refresh-token exchange fails or returns an
274
+ * unexpected response.
275
+ */
276
+ async getAccessToken(signal) {
277
+ signal?.throwIfAborted();
278
+ const auth = this.auth;
279
+ if ("accessToken" in auth) return typeof auth.accessToken === "function" ? await auth.accessToken(signal) : auth.accessToken;
280
+ const cached = this.cached;
281
+ if (cached != null && cached.expiresAt > Date.now()) return cached.accessToken;
282
+ this.pending ??= this.refresh(auth).then((result) => {
283
+ const lifetimeMs = result.expiresIn * 1e3;
284
+ const safetyMargin = Math.min(REFRESH_SAFETY_MARGIN_MS, lifetimeMs / 2);
285
+ this.cached = {
286
+ accessToken: result.accessToken,
287
+ expiresAt: Date.now() + lifetimeMs - safetyMargin
288
+ };
289
+ return result;
290
+ }).finally(() => {
291
+ this.pending = void 0;
292
+ });
293
+ const { accessToken } = await abortable(this.pending, signal);
294
+ return accessToken;
295
+ }
296
+ /**
297
+ * Exchanges the configured refresh token for a fresh access token.
298
+ *
299
+ * The request deliberately runs without a caller-specific abort signal; it is
300
+ * shared across concurrent callers, each of which applies its own
301
+ * cancellation separately (see {@link getAccessToken}).
302
+ *
303
+ * @param auth The refresh-token authentication configuration.
304
+ * @returns The new access token and its lifetime in seconds.
305
+ * @throws {SmtpAuthError} If the request fails or the response is invalid.
306
+ */
307
+ async refresh(auth) {
308
+ const body = new URLSearchParams({
309
+ grant_type: "refresh_token",
310
+ client_id: auth.clientId,
311
+ refresh_token: auth.refreshToken
312
+ });
313
+ if (auth.clientSecret != null) body.set("client_secret", auth.clientSecret);
314
+ if (auth.scope != null) body.set("scope", auth.scope);
315
+ const controller = new AbortController();
316
+ const timeout = setTimeout(() => {
317
+ controller.abort(/* @__PURE__ */ new Error("OAuth 2.0 token request timed out"));
318
+ }, TOKEN_REQUEST_TIMEOUT_MS);
319
+ let response;
320
+ let text;
321
+ const fetchFn = this.fetchFn;
322
+ try {
323
+ response = await fetchFn(auth.tokenEndpoint, {
324
+ method: "POST",
325
+ headers: {
326
+ "content-type": "application/x-www-form-urlencoded",
327
+ "accept": "application/json"
328
+ },
329
+ body,
330
+ signal: controller.signal
331
+ });
332
+ text = await response.text();
333
+ } catch (cause) {
334
+ throw new SmtpAuthError(`Failed to request an OAuth 2.0 access token from ${auth.tokenEndpoint}: ${cause instanceof Error ? cause.message : String(cause)}`);
335
+ } finally {
336
+ clearTimeout(timeout);
337
+ }
338
+ const safeText = truncateErrorBody(text);
339
+ if (!response.ok) throw new SmtpAuthError(`OAuth 2.0 token endpoint responded with HTTP ${response.status}: ${safeText}`);
340
+ let json;
341
+ try {
342
+ json = JSON.parse(text);
343
+ } catch {
344
+ throw new SmtpAuthError(`OAuth 2.0 token endpoint returned a non-JSON response: ${safeText}`);
345
+ }
346
+ if (typeof json !== "object" || json == null || typeof json.access_token !== "string") throw new SmtpAuthError(`OAuth 2.0 token endpoint response did not include an access_token: ${safeText}`);
347
+ const record = json;
348
+ const rawExpiresIn = record.expires_in;
349
+ let parsedExpiresIn;
350
+ if (typeof rawExpiresIn === "number") parsedExpiresIn = rawExpiresIn;
351
+ else if (typeof rawExpiresIn === "string" && rawExpiresIn.trim() !== "") parsedExpiresIn = Number(rawExpiresIn);
352
+ else parsedExpiresIn = NaN;
353
+ const expiresIn = Number.isFinite(parsedExpiresIn) && parsedExpiresIn >= 0 ? parsedExpiresIn : DEFAULT_EXPIRES_IN;
354
+ return {
355
+ accessToken: record.access_token,
356
+ expiresIn
357
+ };
358
+ }
359
+ };
360
+
56
361
  //#endregion
57
362
  //#region src/smtp-connection.ts
363
+ /**
364
+ * The maximum length of an SMTP command line, including the terminating CRLF,
365
+ * as specified by RFC 5321 §4.5.3.1.4.
366
+ */
367
+ const MAX_COMMAND_LINE_LENGTH = 512;
368
+ /** The length of the CRLF terminator appended to every command. */
369
+ const CRLF_LENGTH = 2;
370
+ /**
371
+ * How long, in milliseconds, to wait for the graceful `QUIT` to flush during
372
+ * teardown before giving up, so an unresponsive server cannot block shutdown
373
+ * for the full socket timeout.
374
+ */
375
+ const QUIT_TIMEOUT_MS = 5e3;
376
+ /**
377
+ * Whether a host refers to the local loopback interface, for which cleartext
378
+ * OAuth 2.0 authentication is permitted (e.g. local testing and development).
379
+ *
380
+ * @param host The host to check.
381
+ * @returns `true` if the host is a loopback address.
382
+ */
383
+ function isLoopbackHost(host) {
384
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
385
+ }
58
386
  var SmtpConnection = class {
59
387
  socket = null;
60
388
  config;
61
389
  authenticated = false;
62
390
  capabilities = [];
63
- constructor(config) {
391
+ tokenManager;
392
+ constructor(config, tokenManager) {
64
393
  this.config = createSmtpConfig(config);
394
+ this.tokenManager = tokenManager ?? null;
65
395
  }
66
396
  connect(signal) {
67
397
  if (this.socket) throw new Error("Connection already established");
@@ -231,29 +561,44 @@ var SmtpConnection = class {
231
561
  });
232
562
  }
233
563
  async authenticate(signal) {
234
- if (!this.config.auth) return;
564
+ const auth = this.config.auth;
565
+ if (!auth) return;
235
566
  if (this.authenticated) return;
236
- const authMethod = this.config.auth.method ?? "plain";
237
- if (!this.capabilities.some((cap) => cap.toUpperCase().startsWith("AUTH"))) throw new Error("Server does not support authentication");
238
- switch (authMethod) {
239
- case "plain":
240
- await this.authPlain(signal);
241
- break;
242
- case "login":
243
- await this.authLogin(signal);
244
- break;
245
- default: throw new Error(`Unsupported authentication method: ${authMethod}`);
567
+ if (!this.capabilities.some((cap) => cap.toUpperCase().startsWith("AUTH"))) throw new SmtpAuthError("Server does not support authentication.");
568
+ if ("accessToken" in auth || "refreshToken" in auth) {
569
+ if (!(this.socket instanceof node_tls.TLSSocket) && !isLoopbackHost(this.config.host)) throw new SmtpAuthError("OAuth 2.0 authentication requires a TLS-secured connection to protect the access token; use `secure: true` or STARTTLS.");
570
+ const mechanism = auth.method ?? selectOAuth2Mechanism(this.capabilities);
571
+ switch (mechanism) {
572
+ case "xoauth2":
573
+ await this.authXoauth2(auth, signal);
574
+ break;
575
+ case "oauthbearer":
576
+ await this.authOauthbearer(auth, signal);
577
+ break;
578
+ default: throw new SmtpAuthError(`Unsupported authentication method: ${mechanism}`);
579
+ }
580
+ } else {
581
+ const method = auth.method ?? "plain";
582
+ switch (method) {
583
+ case "plain":
584
+ await this.authPlain(auth, signal);
585
+ break;
586
+ case "login":
587
+ await this.authLogin(auth, signal);
588
+ break;
589
+ default: throw new Error(`Unsupported authentication method: ${method}`);
590
+ }
246
591
  }
247
592
  this.authenticated = true;
248
593
  }
249
- async authPlain(signal) {
250
- const { user, pass } = this.config.auth;
594
+ async authPlain(auth, signal) {
595
+ const { user, pass } = auth;
251
596
  const credentials = btoa(`\0${user}\0${pass}`);
252
597
  const response = await this.sendCommand(`AUTH PLAIN ${credentials}`, signal);
253
598
  if (response.code !== 235) throw new Error(`Authentication failed: ${response.message}`);
254
599
  }
255
- async authLogin(signal) {
256
- const { user, pass } = this.config.auth;
600
+ async authLogin(auth, signal) {
601
+ const { user, pass } = auth;
257
602
  let response = await this.sendCommand("AUTH LOGIN", signal);
258
603
  if (response.code !== 334) throw new Error(`AUTH LOGIN failed: ${response.message}`);
259
604
  response = await this.sendCommand(btoa(user), signal);
@@ -261,19 +606,80 @@ var SmtpConnection = class {
261
606
  response = await this.sendCommand(btoa(pass), signal);
262
607
  if (response.code !== 235) throw new Error(`Password authentication failed: ${response.message}`);
263
608
  }
609
+ /**
610
+ * Resolves an OAuth 2.0 access token via the connection's token manager,
611
+ * creating a standalone manager from the auth config if none was injected.
612
+ */
613
+ async getOAuth2Token(auth, signal) {
614
+ this.tokenManager ??= new OAuth2TokenManager(auth);
615
+ return await this.tokenManager.getAccessToken(signal);
616
+ }
617
+ async authXoauth2(auth, signal) {
618
+ const token = await this.getOAuth2Token(auth, signal);
619
+ const initialResponse = formatXoauth2(auth.user, token);
620
+ const response = await this.sendSaslAuth("XOAUTH2", initialResponse, signal);
621
+ await this.finishOAuth2(response, "XOAUTH2", "", signal);
622
+ }
623
+ async authOauthbearer(auth, signal) {
624
+ const token = await this.getOAuth2Token(auth, signal);
625
+ const initialResponse = formatOauthbearer(auth.user, token, this.config.host, this.config.port);
626
+ const response = await this.sendSaslAuth("OAUTHBEARER", initialResponse, signal);
627
+ await this.finishOAuth2(response, "OAUTHBEARER", "AQ==", signal);
628
+ }
629
+ /**
630
+ * Sends a SASL `AUTH` command with its Base64 initial response.
631
+ *
632
+ * When the resulting command line would exceed the SMTP command-line length
633
+ * limit (e.g. a long Outlook JWT access token), RFC 4954 requires the client
634
+ * to omit the initial response and send it on its own line after the server's
635
+ * `334` challenge. This method transparently falls back to that two-step
636
+ * form, since some servers reject the over-long single-line command outright.
637
+ *
638
+ * @param mechanism The SASL mechanism name (e.g. `XOAUTH2`).
639
+ * @param initialResponse The Base64-encoded initial client response.
640
+ * @param signal An optional {@link AbortSignal}.
641
+ * @returns The server's response to the initial response.
642
+ */
643
+ async sendSaslAuth(mechanism, initialResponse, signal) {
644
+ const inlineCommand = `AUTH ${mechanism} ${initialResponse}`;
645
+ if (inlineCommand.length + CRLF_LENGTH <= MAX_COMMAND_LINE_LENGTH) return await this.sendCommand(inlineCommand, signal);
646
+ const challenge = await this.sendCommand(`AUTH ${mechanism}`, signal);
647
+ if (challenge.code !== 334) return challenge;
648
+ return await this.sendCommand(initialResponse, signal);
649
+ }
650
+ /**
651
+ * Interprets the server's reply to an OAuth SASL initial response, draining
652
+ * the failure challenge continuation when authentication is rejected.
653
+ *
654
+ * @throws {SmtpAuthError} If authentication did not succeed.
655
+ */
656
+ async finishOAuth2(response, mechanism, continuation, signal) {
657
+ if (response.code === 235) return;
658
+ if (response.code === 334) {
659
+ let finalMessage = "";
660
+ try {
661
+ const final = await this.sendCommand(continuation, signal);
662
+ finalMessage = ` (${final.message})`;
663
+ } catch {
664
+ signal?.throwIfAborted();
665
+ }
666
+ throw new SmtpAuthError(`${mechanism} authentication failed: ${decodeOAuth2Challenge(response.message)}${finalMessage}`);
667
+ }
668
+ throw new SmtpAuthError(`${mechanism} authentication failed: ${response.message}`);
669
+ }
264
670
  async sendMessage(message, signal) {
265
671
  const mailResponse = await this.sendCommand(`MAIL FROM:<${message.envelope.from}>`, signal);
266
- if (mailResponse.code !== 250) throw new Error(`MAIL FROM failed: ${mailResponse.message}`);
672
+ if (mailResponse.code !== 250) throw new SmtpResponseError(`MAIL FROM failed: ${mailResponse.message}`, mailResponse.code, "MAIL FROM", mailResponse.message);
267
673
  for (const recipient of message.envelope.to) {
268
674
  signal?.throwIfAborted();
269
675
  const rcptResponse = await this.sendCommand(`RCPT TO:<${recipient}>`, signal);
270
- if (rcptResponse.code !== 250) throw new Error(`RCPT TO failed for ${recipient}: ${rcptResponse.message}`);
676
+ if (rcptResponse.code !== 250) throw new SmtpResponseError(`RCPT TO failed for ${recipient}: ${rcptResponse.message}`, rcptResponse.code, "RCPT TO", rcptResponse.message);
271
677
  }
272
678
  const dataResponse = await this.sendCommand("DATA", signal);
273
- if (dataResponse.code !== 354) throw new Error(`DATA failed: ${dataResponse.message}`);
679
+ if (dataResponse.code !== 354) throw new SmtpResponseError(`DATA failed: ${dataResponse.message}`, dataResponse.code, "DATA", dataResponse.message);
274
680
  const content = message.raw.replace(/\n\./g, "\n..");
275
681
  const finalResponse = await this.sendCommand(`${content}\r\n.`, signal);
276
- if (finalResponse.code !== 250) throw new Error(`Message send failed: ${finalResponse.message}`);
682
+ if (finalResponse.code !== 250) throw new SmtpResponseError(`Message send failed: ${finalResponse.message}`, finalResponse.code, "DATA_END", finalResponse.message);
277
683
  const messageId = this.extractMessageId(finalResponse.message);
278
684
  return messageId;
279
685
  }
@@ -282,11 +688,27 @@ var SmtpConnection = class {
282
688
  return match ? match[1] : `smtp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
283
689
  }
284
690
  async quit() {
285
- if (!this.socket) return;
691
+ const socket = this.socket;
692
+ if (!socket) return;
693
+ if (socket.writable) await new Promise((resolve) => {
694
+ const done = () => {
695
+ clearTimeout(timer);
696
+ socket.off("error", done);
697
+ socket.off("close", done);
698
+ resolve();
699
+ };
700
+ const timer = setTimeout(done, QUIT_TIMEOUT_MS);
701
+ socket.once("error", done);
702
+ socket.once("close", done);
703
+ try {
704
+ socket.write("QUIT\r\n", done);
705
+ } catch {
706
+ done();
707
+ }
708
+ });
286
709
  try {
287
- await this.sendCommand("QUIT");
710
+ socket.destroy();
288
711
  } catch {}
289
- this.socket.destroy();
290
712
  this.socket = null;
291
713
  this.authenticated = false;
292
714
  this.capabilities = [];
@@ -297,6 +719,59 @@ var SmtpConnection = class {
297
719
  if (response.code !== 250) throw new Error(`RESET failed: ${response.message}`);
298
720
  }
299
721
  };
722
+ /**
723
+ * Error thrown when an SMTP command receives an unsuccessful server reply.
724
+ *
725
+ * @since 0.5.0
726
+ */
727
+ var SmtpResponseError = class extends Error {
728
+ /**
729
+ * The numeric SMTP reply code returned by the server.
730
+ */
731
+ code;
732
+ /**
733
+ * The SMTP command that produced the reply.
734
+ */
735
+ command;
736
+ /**
737
+ * The textual SMTP reply returned by the server.
738
+ */
739
+ response;
740
+ /**
741
+ * Creates an SMTP response error.
742
+ *
743
+ * @param message Human-readable error message.
744
+ * @param code The numeric SMTP reply code.
745
+ * @param command The SMTP command that produced the reply.
746
+ * @param response The textual SMTP reply returned by the server.
747
+ */
748
+ constructor(message, code, command, response) {
749
+ super(message);
750
+ this.name = "SmtpResponseError";
751
+ this.code = code;
752
+ this.command = command;
753
+ this.response = response;
754
+ }
755
+ };
756
+ /**
757
+ * Decodes the Base64 JSON error challenge a server sends after a failed OAuth
758
+ * SASL exchange, falling back to the raw message when it is not valid Base64.
759
+ *
760
+ * The bytes are decoded as UTF-8 (via `TextDecoder`) so non-ASCII challenge
761
+ * messages are not corrupted, unlike decoding `atob`'s Latin-1 output directly.
762
+ *
763
+ * @param message The challenge text from the server's 334 response.
764
+ * @returns A human-readable description of the failure.
765
+ */
766
+ function decodeOAuth2Challenge(message) {
767
+ try {
768
+ const binary = atob(message.trim());
769
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
770
+ return new TextDecoder().decode(bytes);
771
+ } catch {
772
+ return message;
773
+ }
774
+ }
300
775
 
301
776
  //#endregion
302
777
  //#region src/dkim/canonicalize.ts
@@ -784,6 +1259,7 @@ function encodeBase64(data) {
784
1259
  * ```
785
1260
  */
786
1261
  var SmtpTransport = class {
1262
+ id = "smtp";
787
1263
  /**
788
1264
  * The SMTP configuration used by this transport.
789
1265
  */
@@ -794,6 +1270,13 @@ var SmtpTransport = class {
794
1270
  poolSize;
795
1271
  connectionPool = [];
796
1272
  /**
1273
+ * A token manager shared across all pooled connections, present only when the
1274
+ * configured authentication uses OAuth 2.0. Sharing it ensures the
1275
+ * refresh-token exchange happens at most once per token lifetime instead of
1276
+ * once per connection.
1277
+ */
1278
+ tokenManager;
1279
+ /**
797
1280
  * Creates a new SMTP transport instance.
798
1281
  *
799
1282
  * @param config SMTP configuration including server details, authentication,
@@ -802,6 +1285,8 @@ var SmtpTransport = class {
802
1285
  constructor(config) {
803
1286
  this.config = config;
804
1287
  this.poolSize = config.poolSize ?? 5;
1288
+ const auth = config.auth;
1289
+ this.tokenManager = auth != null && ("accessToken" in auth || "refreshToken" in auth) ? new OAuth2TokenManager(auth) : void 0;
805
1290
  }
806
1291
  /**
807
1292
  * Sends a single email message via SMTP.
@@ -828,11 +1313,14 @@ var SmtpTransport = class {
828
1313
  * cancellation.
829
1314
  * @returns A promise that resolves to a receipt indicating success or
830
1315
  * failure.
1316
+ * @throws {DOMException} If the operation is aborted through
1317
+ * `options.signal`.
831
1318
  */
832
1319
  async send(message, options) {
833
1320
  options?.signal?.throwIfAborted();
834
- const connection = await this.getConnection(options?.signal);
1321
+ let connection;
835
1322
  try {
1323
+ connection = await this.getConnection(options?.signal);
836
1324
  options?.signal?.throwIfAborted();
837
1325
  const smtpMessage = await convertMessage(message, this.config.dkim);
838
1326
  options?.signal?.throwIfAborted();
@@ -840,14 +1328,13 @@ var SmtpTransport = class {
840
1328
  await this.returnConnection(connection);
841
1329
  return {
842
1330
  successful: true,
843
- messageId
1331
+ messageId,
1332
+ provider: "smtp"
844
1333
  };
845
1334
  } catch (error) {
846
- await this.discardConnection(connection);
847
- return {
848
- successful: false,
849
- errorMessages: [error instanceof Error ? error.message : String(error)]
850
- };
1335
+ if (connection != null) await this.discardConnection(connection);
1336
+ options?.signal?.throwIfAborted();
1337
+ return createSmtpFailure(error instanceof Error ? error.message : String(error), error);
851
1338
  }
852
1339
  }
853
1340
  /**
@@ -877,20 +1364,30 @@ var SmtpTransport = class {
877
1364
  * @param options Optional transport options including `AbortSignal` for
878
1365
  * cancellation.
879
1366
  * @returns An async iterable of receipts, one for each message.
1367
+ * @throws {DOMException} If the operation is aborted through
1368
+ * `options.signal`.
880
1369
  */
881
1370
  async *sendMany(messages, options) {
882
1371
  options?.signal?.throwIfAborted();
883
- const connection = await this.getConnection(options?.signal);
1372
+ let connection;
1373
+ try {
1374
+ connection = await this.getConnection(options?.signal);
1375
+ } catch (error) {
1376
+ options?.signal?.throwIfAborted();
1377
+ const errorMessage = error instanceof Error ? error.message : String(error);
1378
+ for await (const _ of messages) {
1379
+ options?.signal?.throwIfAborted();
1380
+ yield createSmtpFailure(errorMessage, error);
1381
+ }
1382
+ return;
1383
+ }
884
1384
  let connectionValid = true;
885
1385
  try {
886
1386
  const isAsyncIterable = Symbol.asyncIterator in messages;
887
1387
  if (isAsyncIterable) for await (const message of messages) {
888
1388
  options?.signal?.throwIfAborted();
889
1389
  if (!connectionValid) {
890
- yield {
891
- successful: false,
892
- errorMessages: ["Connection is no longer valid"]
893
- };
1390
+ yield createSmtpFailure("Connection is no longer valid");
894
1391
  continue;
895
1392
  }
896
1393
  try {
@@ -899,23 +1396,19 @@ var SmtpTransport = class {
899
1396
  const messageId = await connection.sendMessage(smtpMessage, options?.signal);
900
1397
  yield {
901
1398
  successful: true,
902
- messageId
1399
+ messageId,
1400
+ provider: "smtp"
903
1401
  };
904
1402
  } catch (error) {
1403
+ options?.signal?.throwIfAborted();
905
1404
  connectionValid = false;
906
- yield {
907
- successful: false,
908
- errorMessages: [error instanceof Error ? error.message : String(error)]
909
- };
1405
+ yield createSmtpFailure(error instanceof Error ? error.message : String(error), error);
910
1406
  }
911
1407
  }
912
1408
  else for (const message of messages) {
913
1409
  options?.signal?.throwIfAborted();
914
1410
  if (!connectionValid) {
915
- yield {
916
- successful: false,
917
- errorMessages: ["Connection is no longer valid"]
918
- };
1411
+ yield createSmtpFailure("Connection is no longer valid");
919
1412
  continue;
920
1413
  }
921
1414
  try {
@@ -924,14 +1417,13 @@ var SmtpTransport = class {
924
1417
  const messageId = await connection.sendMessage(smtpMessage, options?.signal);
925
1418
  yield {
926
1419
  successful: true,
927
- messageId
1420
+ messageId,
1421
+ provider: "smtp"
928
1422
  };
929
1423
  } catch (error) {
1424
+ options?.signal?.throwIfAborted();
930
1425
  connectionValid = false;
931
- yield {
932
- successful: false,
933
- errorMessages: [error instanceof Error ? error.message : String(error)]
934
- };
1426
+ yield createSmtpFailure(error instanceof Error ? error.message : String(error), error);
935
1427
  }
936
1428
  }
937
1429
  if (connectionValid) await this.returnConnection(connection);
@@ -944,8 +1436,13 @@ var SmtpTransport = class {
944
1436
  async getConnection(signal) {
945
1437
  signal?.throwIfAborted();
946
1438
  if (this.connectionPool.length > 0) return this.connectionPool.pop();
947
- const connection = new SmtpConnection(this.config);
948
- await this.connectAndSetup(connection, signal);
1439
+ const connection = new SmtpConnection(this.config, this.tokenManager);
1440
+ try {
1441
+ await this.connectAndSetup(connection, signal);
1442
+ } catch (error) {
1443
+ await this.discardConnection(connection);
1444
+ throw error;
1445
+ }
949
1446
  return connection;
950
1447
  }
951
1448
  async connectAndSetup(connection, signal) {
@@ -1019,6 +1516,41 @@ var SmtpTransport = class {
1019
1516
  await this.closeAllConnections();
1020
1517
  }
1021
1518
  };
1519
+ function createSmtpFailure(message, error) {
1520
+ if (error instanceof SmtpResponseError) {
1521
+ const classification = classifySmtpReply(error.code);
1522
+ return (0, __upyo_core.createFailedReceipt)(message, {
1523
+ provider: "smtp",
1524
+ code: `smtp.${error.code}`,
1525
+ category: classification.category,
1526
+ retryable: classification.retryable,
1527
+ attempts: 1,
1528
+ providerDetails: {
1529
+ command: error.command,
1530
+ response: error.response
1531
+ }
1532
+ });
1533
+ }
1534
+ return (0, __upyo_core.createFailedReceipt)(message, {
1535
+ provider: "smtp",
1536
+ attempts: 1
1537
+ });
1538
+ }
1539
+ function classifySmtpReply(code) {
1540
+ if (code >= 400 && code < 500) return {
1541
+ category: "service-unavailable",
1542
+ retryable: true
1543
+ };
1544
+ if (code >= 500 && code < 600) return {
1545
+ category: "rejected",
1546
+ retryable: false
1547
+ };
1548
+ return {
1549
+ category: "unknown",
1550
+ retryable: false
1551
+ };
1552
+ }
1022
1553
 
1023
1554
  //#endregion
1555
+ exports.SmtpAuthError = SmtpAuthError;
1024
1556
  exports.SmtpTransport = SmtpTransport;