@upyo/smtp 0.5.0-dev.120 → 0.5.0-dev.136

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.js CHANGED
@@ -30,15 +30,344 @@ function createSmtpConfig(config) {
30
30
  };
31
31
  }
32
32
 
33
+ //#endregion
34
+ //#region src/oauth2.ts
35
+ /**
36
+ * An error thrown when SMTP authentication fails, including OAuth 2.0 token
37
+ * acquisition and SASL exchange failures.
38
+ *
39
+ * @since 0.5.0
40
+ */
41
+ var SmtpAuthError = class extends Error {
42
+ /**
43
+ * Creates a new {@link SmtpAuthError}.
44
+ * @param message A human-readable description of the failure.
45
+ */
46
+ constructor(message) {
47
+ super(message);
48
+ this.name = "SmtpAuthError";
49
+ }
50
+ };
51
+ /**
52
+ * Encodes a string as Base64 in a cross-runtime, UTF-8-safe manner.
53
+ *
54
+ * Unlike calling `btoa()` directly, this first encodes the input as UTF-8 so
55
+ * that non-ASCII characters (e.g., in an internationalized address) do not
56
+ * throw.
57
+ *
58
+ * @param input The string to encode.
59
+ * @returns The Base64-encoded representation of the UTF-8 bytes of `input`.
60
+ */
61
+ function toBase64(input) {
62
+ const bytes = new TextEncoder().encode(input);
63
+ let binary = "";
64
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
65
+ return btoa(binary);
66
+ }
67
+ /**
68
+ * Builds the SASL `XOAUTH2` initial client response for SMTP authentication.
69
+ *
70
+ * The unencoded payload follows Google's XOAUTH2 format
71
+ * (`user=<user>^Aauth=Bearer <token>^A^A`, where `^A` is the `0x01` control
72
+ * character) and is returned Base64-encoded, ready to be sent as the argument
73
+ * of `AUTH XOAUTH2`.
74
+ *
75
+ * @param user The user (email address) to authenticate as.
76
+ * @param accessToken The OAuth 2.0 access token.
77
+ * @returns The Base64-encoded XOAUTH2 initial client response.
78
+ * @since 0.5.0
79
+ */
80
+ function formatXoauth2(user, accessToken) {
81
+ return toBase64(`user=${user}\x01auth=Bearer ${accessToken}\x01\x01`);
82
+ }
83
+ /**
84
+ * Builds the SASL `OAUTHBEARER` initial client response for SMTP
85
+ * authentication, as defined by [RFC 7628].
86
+ *
87
+ * The unencoded payload is
88
+ * `n,a=<user>,^A[host=<host>^A][port=<port>^A]auth=Bearer <token>^A^A`
89
+ * (where `^A` is the `0x01` control character) and is returned Base64-encoded,
90
+ * ready to be sent as the argument of `AUTH OAUTHBEARER`.
91
+ *
92
+ * [RFC 7628]: https://www.rfc-editor.org/rfc/rfc7628
93
+ *
94
+ * @param user The user (email address) to authenticate as.
95
+ * @param accessToken The OAuth 2.0 access token.
96
+ * @param host The server host to include as the `host` key/value, if any.
97
+ * @param port The server port to include as the `port` key/value, if any.
98
+ * @returns The Base64-encoded OAUTHBEARER initial client response.
99
+ * @since 0.5.0
100
+ */
101
+ function formatOauthbearer(user, accessToken, host, port) {
102
+ let response = `n,a=${escapeSaslName(user)},\x01`;
103
+ if (host != null) response += `host=${host}\x01`;
104
+ if (port != null) response += `port=${port}\x01`;
105
+ response += `auth=Bearer ${accessToken}\x01\x01`;
106
+ return toBase64(response);
107
+ }
108
+ /**
109
+ * Escapes a SASL name (GS2 `authzid`) per RFC 5801, encoding `,` as `=2C` and
110
+ * `=` as `=3D` so that identities containing those characters do not corrupt
111
+ * the GS2 header.
112
+ *
113
+ * @param name The SASL name to escape.
114
+ * @returns The escaped SASL name.
115
+ */
116
+ function escapeSaslName(name) {
117
+ return name.replace(/=/g, "=3D").replace(/,/g, "=2C");
118
+ }
119
+ /**
120
+ * Chooses an OAuth 2.0 SASL mechanism based on the mechanisms advertised by the
121
+ * server in its `AUTH` capability line.
122
+ *
123
+ * `XOAUTH2` is preferred when available (it is the de-facto standard supported
124
+ * by Gmail and Outlook); otherwise `OAUTHBEARER` is used when advertised. When
125
+ * neither is advertised, `xoauth2` is returned as a best-effort default.
126
+ *
127
+ * @param capabilities The capability lines parsed from the server's EHLO
128
+ * response.
129
+ * @returns The selected mechanism, either `"xoauth2"` or `"oauthbearer"`.
130
+ * @since 0.5.0
131
+ */
132
+ function selectOAuth2Mechanism(capabilities) {
133
+ const authLine = capabilities.find((cap) => cap.toUpperCase().startsWith("AUTH"));
134
+ const mechanisms = authLine == null ? [] : authLine.toUpperCase().replace(/=/g, " ").split(/\s+/).slice(1);
135
+ if (mechanisms.includes("XOAUTH2")) return "xoauth2";
136
+ if (mechanisms.includes("OAUTHBEARER")) return "oauthbearer";
137
+ return "xoauth2";
138
+ }
139
+ /**
140
+ * Validates that an OAuth 2.0 token endpoint is safe to send client credentials
141
+ * to, requiring HTTPS except for loopback addresses (for local testing).
142
+ *
143
+ * @param endpoint The token endpoint URL to validate.
144
+ * @throws {TypeError} If the URL is malformed or uses an insecure scheme.
145
+ */
146
+ function assertSecureTokenEndpoint(endpoint) {
147
+ let url;
148
+ try {
149
+ url = new URL(endpoint);
150
+ } catch {
151
+ throw new TypeError(`Invalid OAuth 2.0 token endpoint URL: ${endpoint}`);
152
+ }
153
+ if (url.protocol === "https:") return;
154
+ const isLoopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
155
+ if (url.protocol === "http:" && isLoopback) return;
156
+ throw new TypeError("OAuth 2.0 token endpoint must use HTTPS to protect client credentials: " + endpoint);
157
+ }
158
+ /**
159
+ * The number of milliseconds before a cached access token's expiry at which it
160
+ * is considered stale and eligible for refresh.
161
+ */
162
+ const REFRESH_SAFETY_MARGIN_MS = 6e4;
163
+ /**
164
+ * The default access token lifetime (in seconds) assumed when the token
165
+ * endpoint omits `expires_in`.
166
+ */
167
+ const DEFAULT_EXPIRES_IN = 3600;
168
+ /**
169
+ * How long to wait for the token endpoint before aborting the request, so a
170
+ * stalled endpoint cannot block authentication indefinitely.
171
+ */
172
+ const TOKEN_REQUEST_TIMEOUT_MS = 6e4;
173
+ /** Maximum number of response-body characters to include in error messages. */
174
+ const MAX_ERROR_BODY_LENGTH = 500;
175
+ /**
176
+ * Truncates a token-endpoint response body so an oversized payload (e.g. a
177
+ * large proxy/CDN error page) does not bloat error messages and logs.
178
+ *
179
+ * @param text The response body text.
180
+ * @returns The text, truncated with an ellipsis if it exceeds the limit.
181
+ */
182
+ function truncateErrorBody(text) {
183
+ return text.length > MAX_ERROR_BODY_LENGTH ? `${text.slice(0, MAX_ERROR_BODY_LENGTH)}…` : text;
184
+ }
185
+ /**
186
+ * Mirrors a promise but rejects early if the given abort signal fires, without
187
+ * cancelling the underlying promise. This lets a single waiter abort its own
188
+ * wait on a shared operation without affecting other waiters.
189
+ *
190
+ * @param promise The promise to await.
191
+ * @param signal An optional abort signal that rejects the returned promise.
192
+ * @returns A promise that settles with `promise`, or rejects when `signal`
193
+ * aborts.
194
+ */
195
+ function abortable(promise, signal) {
196
+ if (signal == null) return promise;
197
+ return new Promise((resolve, reject) => {
198
+ const abortReason = () => signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
199
+ const onAbort = () => reject(abortReason());
200
+ if (signal.aborted) reject(abortReason());
201
+ else signal.addEventListener("abort", onAbort, { once: true });
202
+ promise.then((value) => {
203
+ signal.removeEventListener("abort", onAbort);
204
+ resolve(value);
205
+ }, (error) => {
206
+ signal.removeEventListener("abort", onAbort);
207
+ reject(error);
208
+ });
209
+ });
210
+ }
211
+ /**
212
+ * Acquires and caches OAuth 2.0 access tokens for SMTP authentication.
213
+ *
214
+ * Depending on the {@link SmtpOAuth2Auth} configuration, the manager either
215
+ * returns a static token, invokes a provider callback on every request, or
216
+ * exchanges a refresh token for an access token at the configured token
217
+ * endpoint and caches it until shortly before it expires. A single manager is
218
+ * shared across all pooled connections of a transport so that the refresh-token
219
+ * exchange happens at most once per token lifetime.
220
+ *
221
+ * @since 0.5.0
222
+ */
223
+ var OAuth2TokenManager = class {
224
+ auth;
225
+ fetchFn;
226
+ cached;
227
+ pending;
228
+ /**
229
+ * Creates a new {@link OAuth2TokenManager}.
230
+ *
231
+ * @param auth The OAuth 2.0 authentication configuration.
232
+ * @param fetchFn The `fetch` implementation to use for the refresh-token
233
+ * exchange. Defaults to the global `fetch`; primarily
234
+ * overridden in tests.
235
+ * @throws {TypeError} If a refresh-token configuration has an insecure
236
+ * (non-HTTPS) token endpoint.
237
+ */
238
+ constructor(auth, fetchFn = fetch) {
239
+ if ("refreshToken" in auth) assertSecureTokenEndpoint(auth.tokenEndpoint);
240
+ this.auth = auth;
241
+ this.fetchFn = fetchFn;
242
+ }
243
+ /**
244
+ * Returns a valid OAuth 2.0 access token, acquiring or refreshing it as
245
+ * needed.
246
+ *
247
+ * @param signal An optional {@link AbortSignal} to cancel token acquisition.
248
+ * @returns The current access token.
249
+ * @throws {SmtpAuthError} If a refresh-token exchange fails or returns an
250
+ * unexpected response.
251
+ */
252
+ async getAccessToken(signal) {
253
+ signal?.throwIfAborted();
254
+ const auth = this.auth;
255
+ if ("accessToken" in auth) return typeof auth.accessToken === "function" ? await auth.accessToken(signal) : auth.accessToken;
256
+ const cached = this.cached;
257
+ if (cached != null && cached.expiresAt > Date.now()) return cached.accessToken;
258
+ this.pending ??= this.refresh(auth).then((result) => {
259
+ const lifetimeMs = result.expiresIn * 1e3;
260
+ const safetyMargin = Math.min(REFRESH_SAFETY_MARGIN_MS, lifetimeMs / 2);
261
+ this.cached = {
262
+ accessToken: result.accessToken,
263
+ expiresAt: Date.now() + lifetimeMs - safetyMargin
264
+ };
265
+ return result;
266
+ }).finally(() => {
267
+ this.pending = void 0;
268
+ });
269
+ const { accessToken } = await abortable(this.pending, signal);
270
+ return accessToken;
271
+ }
272
+ /**
273
+ * Exchanges the configured refresh token for a fresh access token.
274
+ *
275
+ * The request deliberately runs without a caller-specific abort signal; it is
276
+ * shared across concurrent callers, each of which applies its own
277
+ * cancellation separately (see {@link getAccessToken}).
278
+ *
279
+ * @param auth The refresh-token authentication configuration.
280
+ * @returns The new access token and its lifetime in seconds.
281
+ * @throws {SmtpAuthError} If the request fails or the response is invalid.
282
+ */
283
+ async refresh(auth) {
284
+ const body = new URLSearchParams({
285
+ grant_type: "refresh_token",
286
+ client_id: auth.clientId,
287
+ refresh_token: auth.refreshToken
288
+ });
289
+ if (auth.clientSecret != null) body.set("client_secret", auth.clientSecret);
290
+ if (auth.scope != null) body.set("scope", auth.scope);
291
+ const controller = new AbortController();
292
+ const timeout = setTimeout(() => {
293
+ controller.abort(/* @__PURE__ */ new Error("OAuth 2.0 token request timed out"));
294
+ }, TOKEN_REQUEST_TIMEOUT_MS);
295
+ let response;
296
+ let text;
297
+ const fetchFn = this.fetchFn;
298
+ try {
299
+ response = await fetchFn(auth.tokenEndpoint, {
300
+ method: "POST",
301
+ headers: {
302
+ "content-type": "application/x-www-form-urlencoded",
303
+ "accept": "application/json"
304
+ },
305
+ body,
306
+ signal: controller.signal
307
+ });
308
+ text = await response.text();
309
+ } catch (cause) {
310
+ throw new SmtpAuthError(`Failed to request an OAuth 2.0 access token from ${auth.tokenEndpoint}: ${cause instanceof Error ? cause.message : String(cause)}`);
311
+ } finally {
312
+ clearTimeout(timeout);
313
+ }
314
+ const safeText = truncateErrorBody(text);
315
+ if (!response.ok) throw new SmtpAuthError(`OAuth 2.0 token endpoint responded with HTTP ${response.status}: ${safeText}`);
316
+ let json;
317
+ try {
318
+ json = JSON.parse(text);
319
+ } catch {
320
+ throw new SmtpAuthError(`OAuth 2.0 token endpoint returned a non-JSON response: ${safeText}`);
321
+ }
322
+ 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}`);
323
+ const record = json;
324
+ const rawExpiresIn = record.expires_in;
325
+ let parsedExpiresIn;
326
+ if (typeof rawExpiresIn === "number") parsedExpiresIn = rawExpiresIn;
327
+ else if (typeof rawExpiresIn === "string" && rawExpiresIn.trim() !== "") parsedExpiresIn = Number(rawExpiresIn);
328
+ else parsedExpiresIn = NaN;
329
+ const expiresIn = Number.isFinite(parsedExpiresIn) && parsedExpiresIn >= 0 ? parsedExpiresIn : DEFAULT_EXPIRES_IN;
330
+ return {
331
+ accessToken: record.access_token,
332
+ expiresIn
333
+ };
334
+ }
335
+ };
336
+
33
337
  //#endregion
34
338
  //#region src/smtp-connection.ts
339
+ /**
340
+ * The maximum length of an SMTP command line, including the terminating CRLF,
341
+ * as specified by RFC 5321 §4.5.3.1.4.
342
+ */
343
+ const MAX_COMMAND_LINE_LENGTH = 512;
344
+ /** The length of the CRLF terminator appended to every command. */
345
+ const CRLF_LENGTH = 2;
346
+ /**
347
+ * How long, in milliseconds, to wait for the graceful `QUIT` to flush during
348
+ * teardown before giving up, so an unresponsive server cannot block shutdown
349
+ * for the full socket timeout.
350
+ */
351
+ const QUIT_TIMEOUT_MS = 5e3;
352
+ /**
353
+ * Whether a host refers to the local loopback interface, for which cleartext
354
+ * OAuth 2.0 authentication is permitted (e.g. local testing and development).
355
+ *
356
+ * @param host The host to check.
357
+ * @returns `true` if the host is a loopback address.
358
+ */
359
+ function isLoopbackHost(host) {
360
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
361
+ }
35
362
  var SmtpConnection = class {
36
363
  socket = null;
37
364
  config;
38
365
  authenticated = false;
39
366
  capabilities = [];
40
- constructor(config) {
367
+ tokenManager;
368
+ constructor(config, tokenManager) {
41
369
  this.config = createSmtpConfig(config);
370
+ this.tokenManager = tokenManager ?? null;
42
371
  }
43
372
  connect(signal) {
44
373
  if (this.socket) throw new Error("Connection already established");
@@ -208,29 +537,44 @@ var SmtpConnection = class {
208
537
  });
209
538
  }
210
539
  async authenticate(signal) {
211
- if (!this.config.auth) return;
540
+ const auth = this.config.auth;
541
+ if (!auth) return;
212
542
  if (this.authenticated) return;
213
- const authMethod = this.config.auth.method ?? "plain";
214
- if (!this.capabilities.some((cap) => cap.toUpperCase().startsWith("AUTH"))) throw new Error("Server does not support authentication");
215
- switch (authMethod) {
216
- case "plain":
217
- await this.authPlain(signal);
218
- break;
219
- case "login":
220
- await this.authLogin(signal);
221
- break;
222
- default: throw new Error(`Unsupported authentication method: ${authMethod}`);
543
+ if (!this.capabilities.some((cap) => cap.toUpperCase().startsWith("AUTH"))) throw new SmtpAuthError("Server does not support authentication.");
544
+ if ("accessToken" in auth || "refreshToken" in auth) {
545
+ if (!(this.socket instanceof 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.");
546
+ const mechanism = auth.method ?? selectOAuth2Mechanism(this.capabilities);
547
+ switch (mechanism) {
548
+ case "xoauth2":
549
+ await this.authXoauth2(auth, signal);
550
+ break;
551
+ case "oauthbearer":
552
+ await this.authOauthbearer(auth, signal);
553
+ break;
554
+ default: throw new SmtpAuthError(`Unsupported authentication method: ${mechanism}`);
555
+ }
556
+ } else {
557
+ const method = auth.method ?? "plain";
558
+ switch (method) {
559
+ case "plain":
560
+ await this.authPlain(auth, signal);
561
+ break;
562
+ case "login":
563
+ await this.authLogin(auth, signal);
564
+ break;
565
+ default: throw new Error(`Unsupported authentication method: ${method}`);
566
+ }
223
567
  }
224
568
  this.authenticated = true;
225
569
  }
226
- async authPlain(signal) {
227
- const { user, pass } = this.config.auth;
570
+ async authPlain(auth, signal) {
571
+ const { user, pass } = auth;
228
572
  const credentials = btoa(`\0${user}\0${pass}`);
229
573
  const response = await this.sendCommand(`AUTH PLAIN ${credentials}`, signal);
230
574
  if (response.code !== 235) throw new Error(`Authentication failed: ${response.message}`);
231
575
  }
232
- async authLogin(signal) {
233
- const { user, pass } = this.config.auth;
576
+ async authLogin(auth, signal) {
577
+ const { user, pass } = auth;
234
578
  let response = await this.sendCommand("AUTH LOGIN", signal);
235
579
  if (response.code !== 334) throw new Error(`AUTH LOGIN failed: ${response.message}`);
236
580
  response = await this.sendCommand(btoa(user), signal);
@@ -238,6 +582,67 @@ var SmtpConnection = class {
238
582
  response = await this.sendCommand(btoa(pass), signal);
239
583
  if (response.code !== 235) throw new Error(`Password authentication failed: ${response.message}`);
240
584
  }
585
+ /**
586
+ * Resolves an OAuth 2.0 access token via the connection's token manager,
587
+ * creating a standalone manager from the auth config if none was injected.
588
+ */
589
+ async getOAuth2Token(auth, signal) {
590
+ this.tokenManager ??= new OAuth2TokenManager(auth);
591
+ return await this.tokenManager.getAccessToken(signal);
592
+ }
593
+ async authXoauth2(auth, signal) {
594
+ const token = await this.getOAuth2Token(auth, signal);
595
+ const initialResponse = formatXoauth2(auth.user, token);
596
+ const response = await this.sendSaslAuth("XOAUTH2", initialResponse, signal);
597
+ await this.finishOAuth2(response, "XOAUTH2", "", signal);
598
+ }
599
+ async authOauthbearer(auth, signal) {
600
+ const token = await this.getOAuth2Token(auth, signal);
601
+ const initialResponse = formatOauthbearer(auth.user, token, this.config.host, this.config.port);
602
+ const response = await this.sendSaslAuth("OAUTHBEARER", initialResponse, signal);
603
+ await this.finishOAuth2(response, "OAUTHBEARER", "AQ==", signal);
604
+ }
605
+ /**
606
+ * Sends a SASL `AUTH` command with its Base64 initial response.
607
+ *
608
+ * When the resulting command line would exceed the SMTP command-line length
609
+ * limit (e.g. a long Outlook JWT access token), RFC 4954 requires the client
610
+ * to omit the initial response and send it on its own line after the server's
611
+ * `334` challenge. This method transparently falls back to that two-step
612
+ * form, since some servers reject the over-long single-line command outright.
613
+ *
614
+ * @param mechanism The SASL mechanism name (e.g. `XOAUTH2`).
615
+ * @param initialResponse The Base64-encoded initial client response.
616
+ * @param signal An optional {@link AbortSignal}.
617
+ * @returns The server's response to the initial response.
618
+ */
619
+ async sendSaslAuth(mechanism, initialResponse, signal) {
620
+ const inlineCommand = `AUTH ${mechanism} ${initialResponse}`;
621
+ if (inlineCommand.length + CRLF_LENGTH <= MAX_COMMAND_LINE_LENGTH) return await this.sendCommand(inlineCommand, signal);
622
+ const challenge = await this.sendCommand(`AUTH ${mechanism}`, signal);
623
+ if (challenge.code !== 334) return challenge;
624
+ return await this.sendCommand(initialResponse, signal);
625
+ }
626
+ /**
627
+ * Interprets the server's reply to an OAuth SASL initial response, draining
628
+ * the failure challenge continuation when authentication is rejected.
629
+ *
630
+ * @throws {SmtpAuthError} If authentication did not succeed.
631
+ */
632
+ async finishOAuth2(response, mechanism, continuation, signal) {
633
+ if (response.code === 235) return;
634
+ if (response.code === 334) {
635
+ let finalMessage = "";
636
+ try {
637
+ const final = await this.sendCommand(continuation, signal);
638
+ finalMessage = ` (${final.message})`;
639
+ } catch {
640
+ signal?.throwIfAborted();
641
+ }
642
+ throw new SmtpAuthError(`${mechanism} authentication failed: ${decodeOAuth2Challenge(response.message)}${finalMessage}`);
643
+ }
644
+ throw new SmtpAuthError(`${mechanism} authentication failed: ${response.message}`);
645
+ }
241
646
  async sendMessage(message, signal) {
242
647
  const mailResponse = await this.sendCommand(`MAIL FROM:<${message.envelope.from}>`, signal);
243
648
  if (mailResponse.code !== 250) throw new Error(`MAIL FROM failed: ${mailResponse.message}`);
@@ -259,11 +664,27 @@ var SmtpConnection = class {
259
664
  return match ? match[1] : `smtp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
260
665
  }
261
666
  async quit() {
262
- if (!this.socket) return;
667
+ const socket = this.socket;
668
+ if (!socket) return;
669
+ if (socket.writable) await new Promise((resolve) => {
670
+ const done = () => {
671
+ clearTimeout(timer);
672
+ socket.off("error", done);
673
+ socket.off("close", done);
674
+ resolve();
675
+ };
676
+ const timer = setTimeout(done, QUIT_TIMEOUT_MS);
677
+ socket.once("error", done);
678
+ socket.once("close", done);
679
+ try {
680
+ socket.write("QUIT\r\n", done);
681
+ } catch {
682
+ done();
683
+ }
684
+ });
263
685
  try {
264
- await this.sendCommand("QUIT");
686
+ socket.destroy();
265
687
  } catch {}
266
- this.socket.destroy();
267
688
  this.socket = null;
268
689
  this.authenticated = false;
269
690
  this.capabilities = [];
@@ -274,6 +695,25 @@ var SmtpConnection = class {
274
695
  if (response.code !== 250) throw new Error(`RESET failed: ${response.message}`);
275
696
  }
276
697
  };
698
+ /**
699
+ * Decodes the Base64 JSON error challenge a server sends after a failed OAuth
700
+ * SASL exchange, falling back to the raw message when it is not valid Base64.
701
+ *
702
+ * The bytes are decoded as UTF-8 (via `TextDecoder`) so non-ASCII challenge
703
+ * messages are not corrupted, unlike decoding `atob`'s Latin-1 output directly.
704
+ *
705
+ * @param message The challenge text from the server's 334 response.
706
+ * @returns A human-readable description of the failure.
707
+ */
708
+ function decodeOAuth2Challenge(message) {
709
+ try {
710
+ const binary = atob(message.trim());
711
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
712
+ return new TextDecoder().decode(bytes);
713
+ } catch {
714
+ return message;
715
+ }
716
+ }
277
717
 
278
718
  //#endregion
279
719
  //#region src/dkim/canonicalize.ts
@@ -771,6 +1211,13 @@ var SmtpTransport = class {
771
1211
  poolSize;
772
1212
  connectionPool = [];
773
1213
  /**
1214
+ * A token manager shared across all pooled connections, present only when the
1215
+ * configured authentication uses OAuth 2.0. Sharing it ensures the
1216
+ * refresh-token exchange happens at most once per token lifetime instead of
1217
+ * once per connection.
1218
+ */
1219
+ tokenManager;
1220
+ /**
774
1221
  * Creates a new SMTP transport instance.
775
1222
  *
776
1223
  * @param config SMTP configuration including server details, authentication,
@@ -779,6 +1226,8 @@ var SmtpTransport = class {
779
1226
  constructor(config) {
780
1227
  this.config = config;
781
1228
  this.poolSize = config.poolSize ?? 5;
1229
+ const auth = config.auth;
1230
+ this.tokenManager = auth != null && ("accessToken" in auth || "refreshToken" in auth) ? new OAuth2TokenManager(auth) : void 0;
782
1231
  }
783
1232
  /**
784
1233
  * Sends a single email message via SMTP.
@@ -808,8 +1257,9 @@ var SmtpTransport = class {
808
1257
  */
809
1258
  async send(message, options) {
810
1259
  options?.signal?.throwIfAborted();
811
- const connection = await this.getConnection(options?.signal);
1260
+ let connection;
812
1261
  try {
1262
+ connection = await this.getConnection(options?.signal);
813
1263
  options?.signal?.throwIfAborted();
814
1264
  const smtpMessage = await convertMessage(message, this.config.dkim);
815
1265
  options?.signal?.throwIfAborted();
@@ -820,7 +1270,8 @@ var SmtpTransport = class {
820
1270
  messageId
821
1271
  };
822
1272
  } catch (error) {
823
- await this.discardConnection(connection);
1273
+ if (connection != null) await this.discardConnection(connection);
1274
+ options?.signal?.throwIfAborted();
824
1275
  return {
825
1276
  successful: false,
826
1277
  errorMessages: [error instanceof Error ? error.message : String(error)]
@@ -857,7 +1308,21 @@ var SmtpTransport = class {
857
1308
  */
858
1309
  async *sendMany(messages, options) {
859
1310
  options?.signal?.throwIfAborted();
860
- const connection = await this.getConnection(options?.signal);
1311
+ let connection;
1312
+ try {
1313
+ connection = await this.getConnection(options?.signal);
1314
+ } catch (error) {
1315
+ options?.signal?.throwIfAborted();
1316
+ const errorMessage = error instanceof Error ? error.message : String(error);
1317
+ for await (const _ of messages) {
1318
+ options?.signal?.throwIfAborted();
1319
+ yield {
1320
+ successful: false,
1321
+ errorMessages: [errorMessage]
1322
+ };
1323
+ }
1324
+ return;
1325
+ }
861
1326
  let connectionValid = true;
862
1327
  try {
863
1328
  const isAsyncIterable = Symbol.asyncIterator in messages;
@@ -879,6 +1344,7 @@ var SmtpTransport = class {
879
1344
  messageId
880
1345
  };
881
1346
  } catch (error) {
1347
+ options?.signal?.throwIfAborted();
882
1348
  connectionValid = false;
883
1349
  yield {
884
1350
  successful: false,
@@ -904,6 +1370,7 @@ var SmtpTransport = class {
904
1370
  messageId
905
1371
  };
906
1372
  } catch (error) {
1373
+ options?.signal?.throwIfAborted();
907
1374
  connectionValid = false;
908
1375
  yield {
909
1376
  successful: false,
@@ -921,8 +1388,13 @@ var SmtpTransport = class {
921
1388
  async getConnection(signal) {
922
1389
  signal?.throwIfAborted();
923
1390
  if (this.connectionPool.length > 0) return this.connectionPool.pop();
924
- const connection = new SmtpConnection(this.config);
925
- await this.connectAndSetup(connection, signal);
1391
+ const connection = new SmtpConnection(this.config, this.tokenManager);
1392
+ try {
1393
+ await this.connectAndSetup(connection, signal);
1394
+ } catch (error) {
1395
+ await this.discardConnection(connection);
1396
+ throw error;
1397
+ }
926
1398
  return connection;
927
1399
  }
928
1400
  async connectAndSetup(connection, signal) {
@@ -998,4 +1470,4 @@ var SmtpTransport = class {
998
1470
  };
999
1471
 
1000
1472
  //#endregion
1001
- export { SmtpTransport };
1473
+ export { SmtpAuthError, SmtpTransport };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upyo/smtp",
3
- "version": "0.5.0-dev.120",
3
+ "version": "0.5.0-dev.136",
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.0-dev.120+57c5878b"
56
+ "@upyo/core": "0.5.0-dev.136+adacf579"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@dotenvx/dotenvx": "^1.47.3",