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

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
@@ -22,6 +22,7 @@ Features
22
22
  - TLS/SSL support
23
23
  - Connection pooling
24
24
  - Multiple authentication methods
25
+ - OAuth 2.0 authentication (SASL XOAUTH2 and OAUTHBEARER)
25
26
  - HTML and plain text email support
26
27
  - File attachments (regular and inline)
27
28
  - Multiple recipients (To, CC, BCC)
@@ -116,12 +117,46 @@ Configuration options
116
117
 
117
118
  ### `SmtpAuth`
118
119
 
120
+ `SmtpAuth` is a discriminated union of three strategies.
121
+
122
+ Username/password (`SmtpUserPassAuth`), discriminated by `pass`:
123
+
119
124
  | Option | Type | Default | Description |
120
125
  | -------- | ---------------------------------- | --------- | ----------- |
121
126
  | `user` | `string` | | Username |
122
127
  | `pass` | `string` | | Password |
123
128
  | `method` | `"plain" \| "login" \| "cram-md5"` | `"plain"` | Auth method |
124
129
 
130
+ OAuth 2.0 access token (`SmtpOAuth2TokenAuth`), discriminated by `accessToken`:
131
+
132
+ | Option | Type | Default | Description |
133
+ | ------------- | ------------------------------- | ----------- | ------------------------------------------- |
134
+ | `user` | `string` | | User (email address) |
135
+ | `accessToken` | `string \| OAuth2TokenProvider` | | Access token, or a callback returning one |
136
+ | `method` | `"xoauth2" \| "oauthbearer"` | `"xoauth2"` | SASL mechanism (auto-detected when omitted) |
137
+
138
+ OAuth 2.0 refresh-token flow (`SmtpOAuth2RefreshAuth`), discriminated by
139
+ `refreshToken`:
140
+
141
+ | Option | Type | Default | Description |
142
+ | --------------- | ---------------------------- | ----------- | ------------------------------------------- |
143
+ | `user` | `string` | | User (email address) |
144
+ | `clientId` | `string` | | OAuth 2.0 client identifier |
145
+ | `clientSecret` | `string` | | OAuth 2.0 client secret (optional) |
146
+ | `refreshToken` | `string` | | OAuth 2.0 refresh token |
147
+ | `tokenEndpoint` | `string` | | Token endpoint URL |
148
+ | `scope` | `string` | | Space-delimited scopes (optional) |
149
+ | `method` | `"xoauth2" \| "oauthbearer"` | `"xoauth2"` | SASL mechanism (auto-detected when omitted) |
150
+
151
+ The access token may be a static string or a callback (`OAuth2TokenProvider`)
152
+ that returns a fresh token on demand—use the callback to integrate an OAuth
153
+ client such as `google-auth-library` or `msal-node`. With the refresh-token
154
+ flow the transport runs the `refresh_token` grant itself, caching the access
155
+ token across pooled connections. See the
156
+ [OAuth 2.0 authentication guide][oauth-guide] for details.
157
+
158
+ [oauth-guide]: https://upyo.org/transports/smtp#oauth-2-0-authentication
159
+
125
160
 
126
161
  DKIM signing
127
162
  ------------
@@ -197,3 +232,40 @@ console.log(received[0].data); // Raw email content
197
232
 
198
233
  await server.stop();
199
234
  ~~~~
235
+
236
+ ### OAuth 2.0 end-to-end tests
237
+
238
+ The OAuth 2.0 end-to-end tests run against a real provider (e.g. Gmail or
239
+ Outlook) and are skipped unless the following environment variables are set:
240
+
241
+ | Variable | Description |
242
+ | ----------------------- | -------------------------------------- |
243
+ | `SMTP_OAUTH2_HOST` | SMTP host (e.g. `smtp.gmail.com`) |
244
+ | `SMTP_OAUTH2_PORT` | SMTP port (default `587`) |
245
+ | `SMTP_OAUTH2_SECURE` | `true` for implicit TLS |
246
+ | `SMTP_OAUTH2_USER` | User (email address) |
247
+ | `SMTP_OAUTH2_MECHANISM` | `xoauth2` or `oauthbearer` (optional) |
248
+ | `SMTP_OAUTH2_FROM` | Envelope sender (defaults to the user) |
249
+ | `SMTP_OAUTH2_TO` | Recipient (defaults to the user) |
250
+
251
+ Provide a token source as either a static access token:
252
+
253
+ | Variable | Description |
254
+ | -------------------------- | ------------------ |
255
+ | `SMTP_OAUTH2_ACCESS_TOKEN` | OAuth access token |
256
+
257
+ or the refresh-token flow:
258
+
259
+ | Variable | Description |
260
+ | ---------------------------- | --------------------------------- |
261
+ | `SMTP_OAUTH2_CLIENT_ID` | OAuth client identifier |
262
+ | `SMTP_OAUTH2_CLIENT_SECRET` | OAuth client secret (optional) |
263
+ | `SMTP_OAUTH2_REFRESH_TOKEN` | OAuth refresh token |
264
+ | `SMTP_OAUTH2_TOKEN_ENDPOINT` | Token endpoint URL |
265
+ | `SMTP_OAUTH2_SCOPE` | Space-delimited scopes (optional) |
266
+
267
+ A Gmail access token can be minted with [google-auth-library], and an Outlook
268
+ one with [msal-node].
269
+
270
+ [google-auth-library]: https://github.com/googleapis/google-auth-library-nodejs
271
+ [msal-node]: https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-node
package/dist/index.cjs CHANGED
@@ -53,15 +53,334 @@ function createSmtpConfig(config) {
53
53
  };
54
54
  }
55
55
 
56
+ //#endregion
57
+ //#region src/oauth2.ts
58
+ /**
59
+ * An error thrown when SMTP authentication fails, including OAuth 2.0 token
60
+ * acquisition and SASL exchange failures.
61
+ *
62
+ * @since 0.5.0
63
+ */
64
+ var SmtpAuthError = class extends Error {
65
+ /**
66
+ * Creates a new {@link SmtpAuthError}.
67
+ * @param message A human-readable description of the failure.
68
+ */
69
+ constructor(message) {
70
+ super(message);
71
+ this.name = "SmtpAuthError";
72
+ }
73
+ };
74
+ /**
75
+ * Encodes a string as Base64 in a cross-runtime, UTF-8-safe manner.
76
+ *
77
+ * Unlike calling `btoa()` directly, this first encodes the input as UTF-8 so
78
+ * that non-ASCII characters (e.g., in an internationalized address) do not
79
+ * throw.
80
+ *
81
+ * @param input The string to encode.
82
+ * @returns The Base64-encoded representation of the UTF-8 bytes of `input`.
83
+ */
84
+ function toBase64(input) {
85
+ const bytes = new TextEncoder().encode(input);
86
+ let binary = "";
87
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
88
+ return btoa(binary);
89
+ }
90
+ /**
91
+ * Builds the SASL `XOAUTH2` initial client response for SMTP authentication.
92
+ *
93
+ * The unencoded payload follows Google's XOAUTH2 format
94
+ * (`user=<user>^Aauth=Bearer <token>^A^A`, where `^A` is the `0x01` control
95
+ * character) and is returned Base64-encoded, ready to be sent as the argument
96
+ * of `AUTH XOAUTH2`.
97
+ *
98
+ * @param user The user (email address) to authenticate as.
99
+ * @param accessToken The OAuth 2.0 access token.
100
+ * @returns The Base64-encoded XOAUTH2 initial client response.
101
+ * @since 0.5.0
102
+ */
103
+ function formatXoauth2(user, accessToken) {
104
+ return toBase64(`user=${user}\x01auth=Bearer ${accessToken}\x01\x01`);
105
+ }
106
+ /**
107
+ * Builds the SASL `OAUTHBEARER` initial client response for SMTP
108
+ * authentication, as defined by [RFC 7628].
109
+ *
110
+ * The unencoded payload is
111
+ * `n,a=<user>,^A[host=<host>^A][port=<port>^A]auth=Bearer <token>^A^A`
112
+ * (where `^A` is the `0x01` control character) and is returned Base64-encoded,
113
+ * ready to be sent as the argument of `AUTH OAUTHBEARER`.
114
+ *
115
+ * [RFC 7628]: https://www.rfc-editor.org/rfc/rfc7628
116
+ *
117
+ * @param user The user (email address) to authenticate as.
118
+ * @param accessToken The OAuth 2.0 access token.
119
+ * @param host The server host to include as the `host` key/value, if any.
120
+ * @param port The server port to include as the `port` key/value, if any.
121
+ * @returns The Base64-encoded OAUTHBEARER initial client response.
122
+ * @since 0.5.0
123
+ */
124
+ function formatOauthbearer(user, accessToken, host, port) {
125
+ let response = `n,a=${escapeSaslName(user)},\x01`;
126
+ if (host != null) response += `host=${host}\x01`;
127
+ if (port != null) response += `port=${port}\x01`;
128
+ response += `auth=Bearer ${accessToken}\x01\x01`;
129
+ return toBase64(response);
130
+ }
131
+ /**
132
+ * Escapes a SASL name (GS2 `authzid`) per RFC 5801, encoding `,` as `=2C` and
133
+ * `=` as `=3D` so that identities containing those characters do not corrupt
134
+ * the GS2 header.
135
+ *
136
+ * @param name The SASL name to escape.
137
+ * @returns The escaped SASL name.
138
+ */
139
+ function escapeSaslName(name) {
140
+ return name.replace(/=/g, "=3D").replace(/,/g, "=2C");
141
+ }
142
+ /**
143
+ * Chooses an OAuth 2.0 SASL mechanism based on the mechanisms advertised by the
144
+ * server in its `AUTH` capability line.
145
+ *
146
+ * `XOAUTH2` is preferred when available (it is the de-facto standard supported
147
+ * by Gmail and Outlook); otherwise `OAUTHBEARER` is used when advertised. When
148
+ * neither is advertised, `xoauth2` is returned as a best-effort default.
149
+ *
150
+ * @param capabilities The capability lines parsed from the server's EHLO
151
+ * response.
152
+ * @returns The selected mechanism, either `"xoauth2"` or `"oauthbearer"`.
153
+ * @since 0.5.0
154
+ */
155
+ function selectOAuth2Mechanism(capabilities) {
156
+ const authLine = capabilities.find((cap) => cap.toUpperCase().startsWith("AUTH"));
157
+ const mechanisms = authLine == null ? [] : authLine.toUpperCase().split(/\s+/).slice(1);
158
+ if (mechanisms.includes("XOAUTH2")) return "xoauth2";
159
+ if (mechanisms.includes("OAUTHBEARER")) return "oauthbearer";
160
+ return "xoauth2";
161
+ }
162
+ /**
163
+ * Validates that an OAuth 2.0 token endpoint is safe to send client credentials
164
+ * to, requiring HTTPS except for loopback addresses (for local testing).
165
+ *
166
+ * @param endpoint The token endpoint URL to validate.
167
+ * @throws {TypeError} If the URL is malformed or uses an insecure scheme.
168
+ */
169
+ function assertSecureTokenEndpoint(endpoint) {
170
+ let url;
171
+ try {
172
+ url = new URL(endpoint);
173
+ } catch {
174
+ throw new TypeError(`Invalid OAuth 2.0 token endpoint URL: ${endpoint}`);
175
+ }
176
+ if (url.protocol === "https:") return;
177
+ const isLoopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
178
+ if (url.protocol === "http:" && isLoopback) return;
179
+ throw new TypeError("OAuth 2.0 token endpoint must use HTTPS to protect client credentials: " + endpoint);
180
+ }
181
+ /**
182
+ * The number of milliseconds before a cached access token's expiry at which it
183
+ * is considered stale and eligible for refresh.
184
+ */
185
+ const REFRESH_SAFETY_MARGIN_MS = 6e4;
186
+ /**
187
+ * The default access token lifetime (in seconds) assumed when the token
188
+ * endpoint omits `expires_in`.
189
+ */
190
+ const DEFAULT_EXPIRES_IN = 3600;
191
+ /**
192
+ * How long to wait for the token endpoint before aborting the request, so a
193
+ * stalled endpoint cannot block authentication indefinitely.
194
+ */
195
+ const TOKEN_REQUEST_TIMEOUT_MS = 6e4;
196
+ /** Maximum number of response-body characters to include in error messages. */
197
+ const MAX_ERROR_BODY_LENGTH = 500;
198
+ /**
199
+ * Truncates a token-endpoint response body so an oversized payload (e.g. a
200
+ * large proxy/CDN error page) does not bloat error messages and logs.
201
+ *
202
+ * @param text The response body text.
203
+ * @returns The text, truncated with an ellipsis if it exceeds the limit.
204
+ */
205
+ function truncateErrorBody(text) {
206
+ return text.length > MAX_ERROR_BODY_LENGTH ? `${text.slice(0, MAX_ERROR_BODY_LENGTH)}…` : text;
207
+ }
208
+ /**
209
+ * Mirrors a promise but rejects early if the given abort signal fires, without
210
+ * cancelling the underlying promise. This lets a single waiter abort its own
211
+ * wait on a shared operation without affecting other waiters.
212
+ *
213
+ * @param promise The promise to await.
214
+ * @param signal An optional abort signal that rejects the returned promise.
215
+ * @returns A promise that settles with `promise`, or rejects when `signal`
216
+ * aborts.
217
+ */
218
+ function abortable(promise, signal) {
219
+ if (signal == null) return promise;
220
+ return new Promise((resolve, reject) => {
221
+ const onAbort = () => reject(signal.reason);
222
+ if (signal.aborted) reject(signal.reason);
223
+ else signal.addEventListener("abort", onAbort, { once: true });
224
+ promise.then((value) => {
225
+ signal.removeEventListener("abort", onAbort);
226
+ resolve(value);
227
+ }, (error) => {
228
+ signal.removeEventListener("abort", onAbort);
229
+ reject(error);
230
+ });
231
+ });
232
+ }
233
+ /**
234
+ * Acquires and caches OAuth 2.0 access tokens for SMTP authentication.
235
+ *
236
+ * Depending on the {@link SmtpOAuth2Auth} configuration, the manager either
237
+ * returns a static token, invokes a provider callback on every request, or
238
+ * exchanges a refresh token for an access token at the configured token
239
+ * endpoint and caches it until shortly before it expires. A single manager is
240
+ * shared across all pooled connections of a transport so that the refresh-token
241
+ * exchange happens at most once per token lifetime.
242
+ *
243
+ * @since 0.5.0
244
+ */
245
+ var OAuth2TokenManager = class {
246
+ auth;
247
+ fetchFn;
248
+ cached;
249
+ pending;
250
+ /**
251
+ * Creates a new {@link OAuth2TokenManager}.
252
+ *
253
+ * @param auth The OAuth 2.0 authentication configuration.
254
+ * @param fetchFn The `fetch` implementation to use for the refresh-token
255
+ * exchange. Defaults to the global `fetch`; primarily
256
+ * overridden in tests.
257
+ * @throws {TypeError} If a refresh-token configuration has an insecure
258
+ * (non-HTTPS) token endpoint.
259
+ */
260
+ constructor(auth, fetchFn = fetch) {
261
+ if ("refreshToken" in auth) assertSecureTokenEndpoint(auth.tokenEndpoint);
262
+ this.auth = auth;
263
+ this.fetchFn = fetchFn;
264
+ }
265
+ /**
266
+ * Returns a valid OAuth 2.0 access token, acquiring or refreshing it as
267
+ * needed.
268
+ *
269
+ * @param signal An optional {@link AbortSignal} to cancel token acquisition.
270
+ * @returns The current access token.
271
+ * @throws {SmtpAuthError} If a refresh-token exchange fails or returns an
272
+ * unexpected response.
273
+ */
274
+ async getAccessToken(signal) {
275
+ signal?.throwIfAborted();
276
+ const auth = this.auth;
277
+ if ("accessToken" in auth) return typeof auth.accessToken === "function" ? await auth.accessToken(signal) : auth.accessToken;
278
+ const cached = this.cached;
279
+ if (cached != null && cached.expiresAt - REFRESH_SAFETY_MARGIN_MS > Date.now()) return cached.accessToken;
280
+ this.pending ??= this.refresh(auth).then((result) => {
281
+ this.cached = {
282
+ accessToken: result.accessToken,
283
+ expiresAt: Date.now() + result.expiresIn * 1e3
284
+ };
285
+ return result;
286
+ }).finally(() => {
287
+ this.pending = void 0;
288
+ });
289
+ const { accessToken } = await abortable(this.pending, signal);
290
+ return accessToken;
291
+ }
292
+ /**
293
+ * Exchanges the configured refresh token for a fresh access token.
294
+ *
295
+ * The request deliberately runs without a caller-specific abort signal; it is
296
+ * shared across concurrent callers, each of which applies its own
297
+ * cancellation separately (see {@link getAccessToken}).
298
+ *
299
+ * @param auth The refresh-token authentication configuration.
300
+ * @returns The new access token and its lifetime in seconds.
301
+ * @throws {SmtpAuthError} If the request fails or the response is invalid.
302
+ */
303
+ async refresh(auth) {
304
+ const body = new URLSearchParams({
305
+ grant_type: "refresh_token",
306
+ client_id: auth.clientId,
307
+ refresh_token: auth.refreshToken
308
+ });
309
+ if (auth.clientSecret != null) body.set("client_secret", auth.clientSecret);
310
+ if (auth.scope != null) body.set("scope", auth.scope);
311
+ const controller = new AbortController();
312
+ const timeout = setTimeout(() => {
313
+ controller.abort(/* @__PURE__ */ new Error("OAuth 2.0 token request timed out"));
314
+ }, TOKEN_REQUEST_TIMEOUT_MS);
315
+ let response;
316
+ let text;
317
+ try {
318
+ response = await this.fetchFn(auth.tokenEndpoint, {
319
+ method: "POST",
320
+ headers: {
321
+ "content-type": "application/x-www-form-urlencoded",
322
+ "accept": "application/json"
323
+ },
324
+ body,
325
+ signal: controller.signal
326
+ });
327
+ text = await response.text();
328
+ } catch (cause) {
329
+ throw new SmtpAuthError(`Failed to request an OAuth 2.0 access token from ${auth.tokenEndpoint}: ${cause instanceof Error ? cause.message : String(cause)}`);
330
+ } finally {
331
+ clearTimeout(timeout);
332
+ }
333
+ const safeText = truncateErrorBody(text);
334
+ if (!response.ok) throw new SmtpAuthError(`OAuth 2.0 token endpoint responded with HTTP ${response.status}: ${safeText}`);
335
+ let json;
336
+ try {
337
+ json = JSON.parse(text);
338
+ } catch {
339
+ throw new SmtpAuthError(`OAuth 2.0 token endpoint returned a non-JSON response: ${safeText}`);
340
+ }
341
+ 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}`);
342
+ const record = json;
343
+ let expiresIn = DEFAULT_EXPIRES_IN;
344
+ if (typeof record.expires_in === "number") expiresIn = record.expires_in;
345
+ else if (typeof record.expires_in === "string") {
346
+ const parsed = Number.parseInt(record.expires_in, 10);
347
+ if (Number.isFinite(parsed)) expiresIn = parsed;
348
+ }
349
+ return {
350
+ accessToken: record.access_token,
351
+ expiresIn
352
+ };
353
+ }
354
+ };
355
+
56
356
  //#endregion
57
357
  //#region src/smtp-connection.ts
358
+ /**
359
+ * The maximum length of an SMTP command line, including the terminating CRLF,
360
+ * as specified by RFC 5321 §4.5.3.1.4.
361
+ */
362
+ const MAX_COMMAND_LINE_LENGTH = 512;
363
+ /** The length of the CRLF terminator appended to every command. */
364
+ const CRLF_LENGTH = 2;
365
+ /**
366
+ * Whether a host refers to the local loopback interface, for which cleartext
367
+ * OAuth 2.0 authentication is permitted (e.g. local testing and development).
368
+ *
369
+ * @param host The host to check.
370
+ * @returns `true` if the host is a loopback address.
371
+ */
372
+ function isLoopbackHost(host) {
373
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
374
+ }
58
375
  var SmtpConnection = class {
59
376
  socket = null;
60
377
  config;
61
378
  authenticated = false;
62
379
  capabilities = [];
63
- constructor(config) {
380
+ tokenManager;
381
+ constructor(config, tokenManager) {
64
382
  this.config = createSmtpConfig(config);
383
+ this.tokenManager = tokenManager ?? null;
65
384
  }
66
385
  connect(signal) {
67
386
  if (this.socket) throw new Error("Connection already established");
@@ -231,29 +550,44 @@ var SmtpConnection = class {
231
550
  });
232
551
  }
233
552
  async authenticate(signal) {
234
- if (!this.config.auth) return;
553
+ const auth = this.config.auth;
554
+ if (!auth) return;
235
555
  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}`);
556
+ if (!this.capabilities.some((cap) => cap.toUpperCase().startsWith("AUTH"))) throw new SmtpAuthError("Server does not support authentication.");
557
+ if ("accessToken" in auth || "refreshToken" in auth) {
558
+ 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.");
559
+ const mechanism = auth.method ?? selectOAuth2Mechanism(this.capabilities);
560
+ switch (mechanism) {
561
+ case "xoauth2":
562
+ await this.authXoauth2(auth, signal);
563
+ break;
564
+ case "oauthbearer":
565
+ await this.authOauthbearer(auth, signal);
566
+ break;
567
+ default: throw new SmtpAuthError(`Unsupported authentication method: ${mechanism}`);
568
+ }
569
+ } else {
570
+ const method = auth.method ?? "plain";
571
+ switch (method) {
572
+ case "plain":
573
+ await this.authPlain(auth, signal);
574
+ break;
575
+ case "login":
576
+ await this.authLogin(auth, signal);
577
+ break;
578
+ default: throw new Error(`Unsupported authentication method: ${method}`);
579
+ }
246
580
  }
247
581
  this.authenticated = true;
248
582
  }
249
- async authPlain(signal) {
250
- const { user, pass } = this.config.auth;
583
+ async authPlain(auth, signal) {
584
+ const { user, pass } = auth;
251
585
  const credentials = btoa(`\0${user}\0${pass}`);
252
586
  const response = await this.sendCommand(`AUTH PLAIN ${credentials}`, signal);
253
587
  if (response.code !== 235) throw new Error(`Authentication failed: ${response.message}`);
254
588
  }
255
- async authLogin(signal) {
256
- const { user, pass } = this.config.auth;
589
+ async authLogin(auth, signal) {
590
+ const { user, pass } = auth;
257
591
  let response = await this.sendCommand("AUTH LOGIN", signal);
258
592
  if (response.code !== 334) throw new Error(`AUTH LOGIN failed: ${response.message}`);
259
593
  response = await this.sendCommand(btoa(user), signal);
@@ -261,6 +595,67 @@ var SmtpConnection = class {
261
595
  response = await this.sendCommand(btoa(pass), signal);
262
596
  if (response.code !== 235) throw new Error(`Password authentication failed: ${response.message}`);
263
597
  }
598
+ /**
599
+ * Resolves an OAuth 2.0 access token via the connection's token manager,
600
+ * creating a standalone manager from the auth config if none was injected.
601
+ */
602
+ async getOAuth2Token(auth, signal) {
603
+ this.tokenManager ??= new OAuth2TokenManager(auth);
604
+ return await this.tokenManager.getAccessToken(signal);
605
+ }
606
+ async authXoauth2(auth, signal) {
607
+ const token = await this.getOAuth2Token(auth, signal);
608
+ const initialResponse = formatXoauth2(auth.user, token);
609
+ const response = await this.sendSaslAuth("XOAUTH2", initialResponse, signal);
610
+ await this.finishOAuth2(response, "XOAUTH2", "", signal);
611
+ }
612
+ async authOauthbearer(auth, signal) {
613
+ const token = await this.getOAuth2Token(auth, signal);
614
+ const initialResponse = formatOauthbearer(auth.user, token, this.config.host, this.config.port);
615
+ const response = await this.sendSaslAuth("OAUTHBEARER", initialResponse, signal);
616
+ await this.finishOAuth2(response, "OAUTHBEARER", "AQ==", signal);
617
+ }
618
+ /**
619
+ * Sends a SASL `AUTH` command with its Base64 initial response.
620
+ *
621
+ * When the resulting command line would exceed the SMTP command-line length
622
+ * limit (e.g. a long Outlook JWT access token), RFC 4954 requires the client
623
+ * to omit the initial response and send it on its own line after the server's
624
+ * `334` challenge. This method transparently falls back to that two-step
625
+ * form, since some servers reject the over-long single-line command outright.
626
+ *
627
+ * @param mechanism The SASL mechanism name (e.g. `XOAUTH2`).
628
+ * @param initialResponse The Base64-encoded initial client response.
629
+ * @param signal An optional {@link AbortSignal}.
630
+ * @returns The server's response to the initial response.
631
+ */
632
+ async sendSaslAuth(mechanism, initialResponse, signal) {
633
+ const inlineCommand = `AUTH ${mechanism} ${initialResponse}`;
634
+ if (inlineCommand.length + CRLF_LENGTH <= MAX_COMMAND_LINE_LENGTH) return await this.sendCommand(inlineCommand, signal);
635
+ const challenge = await this.sendCommand(`AUTH ${mechanism}`, signal);
636
+ if (challenge.code !== 334) return challenge;
637
+ return await this.sendCommand(initialResponse, signal);
638
+ }
639
+ /**
640
+ * Interprets the server's reply to an OAuth SASL initial response, draining
641
+ * the failure challenge continuation when authentication is rejected.
642
+ *
643
+ * @throws {SmtpAuthError} If authentication did not succeed.
644
+ */
645
+ async finishOAuth2(response, mechanism, continuation, signal) {
646
+ if (response.code === 235) return;
647
+ if (response.code === 334) {
648
+ let finalMessage = "";
649
+ try {
650
+ const final = await this.sendCommand(continuation, signal);
651
+ finalMessage = ` (${final.message})`;
652
+ } catch {
653
+ signal?.throwIfAborted();
654
+ }
655
+ throw new SmtpAuthError(`${mechanism} authentication failed: ${decodeOAuth2Challenge(response.message)}${finalMessage}`);
656
+ }
657
+ throw new SmtpAuthError(`${mechanism} authentication failed: ${response.message}`);
658
+ }
264
659
  async sendMessage(message, signal) {
265
660
  const mailResponse = await this.sendCommand(`MAIL FROM:<${message.envelope.from}>`, signal);
266
661
  if (mailResponse.code !== 250) throw new Error(`MAIL FROM failed: ${mailResponse.message}`);
@@ -282,11 +677,14 @@ var SmtpConnection = class {
282
677
  return match ? match[1] : `smtp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
283
678
  }
284
679
  async quit() {
285
- if (!this.socket) return;
286
- try {
680
+ const socket = this.socket;
681
+ if (!socket) return;
682
+ if (socket.writable) try {
287
683
  await this.sendCommand("QUIT");
288
684
  } catch {}
289
- this.socket.destroy();
685
+ try {
686
+ socket.destroy();
687
+ } catch {}
290
688
  this.socket = null;
291
689
  this.authenticated = false;
292
690
  this.capabilities = [];
@@ -297,6 +695,20 @@ var SmtpConnection = class {
297
695
  if (response.code !== 250) throw new Error(`RESET failed: ${response.message}`);
298
696
  }
299
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
+ * @param message The challenge text from the server's 334 response.
703
+ * @returns A human-readable description of the failure.
704
+ */
705
+ function decodeOAuth2Challenge(message) {
706
+ try {
707
+ return atob(message.trim());
708
+ } catch {
709
+ return message;
710
+ }
711
+ }
300
712
 
301
713
  //#endregion
302
714
  //#region src/dkim/canonicalize.ts
@@ -794,6 +1206,13 @@ var SmtpTransport = class {
794
1206
  poolSize;
795
1207
  connectionPool = [];
796
1208
  /**
1209
+ * A token manager shared across all pooled connections, present only when the
1210
+ * configured authentication uses OAuth 2.0. Sharing it ensures the
1211
+ * refresh-token exchange happens at most once per token lifetime instead of
1212
+ * once per connection.
1213
+ */
1214
+ tokenManager;
1215
+ /**
797
1216
  * Creates a new SMTP transport instance.
798
1217
  *
799
1218
  * @param config SMTP configuration including server details, authentication,
@@ -802,6 +1221,8 @@ var SmtpTransport = class {
802
1221
  constructor(config) {
803
1222
  this.config = config;
804
1223
  this.poolSize = config.poolSize ?? 5;
1224
+ const auth = config.auth;
1225
+ this.tokenManager = auth != null && ("accessToken" in auth || "refreshToken" in auth) ? new OAuth2TokenManager(auth) : void 0;
805
1226
  }
806
1227
  /**
807
1228
  * Sends a single email message via SMTP.
@@ -831,8 +1252,9 @@ var SmtpTransport = class {
831
1252
  */
832
1253
  async send(message, options) {
833
1254
  options?.signal?.throwIfAborted();
834
- const connection = await this.getConnection(options?.signal);
1255
+ let connection;
835
1256
  try {
1257
+ connection = await this.getConnection(options?.signal);
836
1258
  options?.signal?.throwIfAborted();
837
1259
  const smtpMessage = await convertMessage(message, this.config.dkim);
838
1260
  options?.signal?.throwIfAborted();
@@ -843,7 +1265,8 @@ var SmtpTransport = class {
843
1265
  messageId
844
1266
  };
845
1267
  } catch (error) {
846
- await this.discardConnection(connection);
1268
+ if (connection != null) await this.discardConnection(connection);
1269
+ options?.signal?.throwIfAborted();
847
1270
  return {
848
1271
  successful: false,
849
1272
  errorMessages: [error instanceof Error ? error.message : String(error)]
@@ -880,7 +1303,21 @@ var SmtpTransport = class {
880
1303
  */
881
1304
  async *sendMany(messages, options) {
882
1305
  options?.signal?.throwIfAborted();
883
- const connection = await this.getConnection(options?.signal);
1306
+ let connection;
1307
+ try {
1308
+ connection = await this.getConnection(options?.signal);
1309
+ } catch (error) {
1310
+ options?.signal?.throwIfAborted();
1311
+ const errorMessage = error instanceof Error ? error.message : String(error);
1312
+ for await (const _ of messages) {
1313
+ options?.signal?.throwIfAborted();
1314
+ yield {
1315
+ successful: false,
1316
+ errorMessages: [errorMessage]
1317
+ };
1318
+ }
1319
+ return;
1320
+ }
884
1321
  let connectionValid = true;
885
1322
  try {
886
1323
  const isAsyncIterable = Symbol.asyncIterator in messages;
@@ -902,6 +1339,7 @@ var SmtpTransport = class {
902
1339
  messageId
903
1340
  };
904
1341
  } catch (error) {
1342
+ options?.signal?.throwIfAborted();
905
1343
  connectionValid = false;
906
1344
  yield {
907
1345
  successful: false,
@@ -927,6 +1365,7 @@ var SmtpTransport = class {
927
1365
  messageId
928
1366
  };
929
1367
  } catch (error) {
1368
+ options?.signal?.throwIfAborted();
930
1369
  connectionValid = false;
931
1370
  yield {
932
1371
  successful: false,
@@ -944,8 +1383,13 @@ var SmtpTransport = class {
944
1383
  async getConnection(signal) {
945
1384
  signal?.throwIfAborted();
946
1385
  if (this.connectionPool.length > 0) return this.connectionPool.pop();
947
- const connection = new SmtpConnection(this.config);
948
- await this.connectAndSetup(connection, signal);
1386
+ const connection = new SmtpConnection(this.config, this.tokenManager);
1387
+ try {
1388
+ await this.connectAndSetup(connection, signal);
1389
+ } catch (error) {
1390
+ await this.discardConnection(connection);
1391
+ throw error;
1392
+ }
949
1393
  return connection;
950
1394
  }
951
1395
  async connectAndSetup(connection, signal) {
@@ -1021,4 +1465,5 @@ var SmtpTransport = class {
1021
1465
  };
1022
1466
 
1023
1467
  //#endregion
1468
+ exports.SmtpAuthError = SmtpAuthError;
1024
1469
  exports.SmtpTransport = SmtpTransport;