@upyo/smtp 0.5.0-dev.128 → 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.cjs CHANGED
@@ -154,7 +154,7 @@ function escapeSaslName(name) {
154
154
  */
155
155
  function selectOAuth2Mechanism(capabilities) {
156
156
  const authLine = capabilities.find((cap) => cap.toUpperCase().startsWith("AUTH"));
157
- const mechanisms = authLine == null ? [] : authLine.toUpperCase().split(/\s+/).slice(1);
157
+ const mechanisms = authLine == null ? [] : authLine.toUpperCase().replace(/=/g, " ").split(/\s+/).slice(1);
158
158
  if (mechanisms.includes("XOAUTH2")) return "xoauth2";
159
159
  if (mechanisms.includes("OAUTHBEARER")) return "oauthbearer";
160
160
  return "xoauth2";
@@ -218,8 +218,9 @@ function truncateErrorBody(text) {
218
218
  function abortable(promise, signal) {
219
219
  if (signal == null) return promise;
220
220
  return new Promise((resolve, reject) => {
221
- const onAbort = () => reject(signal.reason);
222
- if (signal.aborted) reject(signal.reason);
221
+ const abortReason = () => signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
222
+ const onAbort = () => reject(abortReason());
223
+ if (signal.aborted) reject(abortReason());
223
224
  else signal.addEventListener("abort", onAbort, { once: true });
224
225
  promise.then((value) => {
225
226
  signal.removeEventListener("abort", onAbort);
@@ -276,11 +277,13 @@ var OAuth2TokenManager = class {
276
277
  const auth = this.auth;
277
278
  if ("accessToken" in auth) return typeof auth.accessToken === "function" ? await auth.accessToken(signal) : auth.accessToken;
278
279
  const cached = this.cached;
279
- if (cached != null && cached.expiresAt - REFRESH_SAFETY_MARGIN_MS > Date.now()) return cached.accessToken;
280
+ if (cached != null && cached.expiresAt > Date.now()) return cached.accessToken;
280
281
  this.pending ??= this.refresh(auth).then((result) => {
282
+ const lifetimeMs = result.expiresIn * 1e3;
283
+ const safetyMargin = Math.min(REFRESH_SAFETY_MARGIN_MS, lifetimeMs / 2);
281
284
  this.cached = {
282
285
  accessToken: result.accessToken,
283
- expiresAt: Date.now() + result.expiresIn * 1e3
286
+ expiresAt: Date.now() + lifetimeMs - safetyMargin
284
287
  };
285
288
  return result;
286
289
  }).finally(() => {
@@ -314,8 +317,9 @@ var OAuth2TokenManager = class {
314
317
  }, TOKEN_REQUEST_TIMEOUT_MS);
315
318
  let response;
316
319
  let text;
320
+ const fetchFn = this.fetchFn;
317
321
  try {
318
- response = await this.fetchFn(auth.tokenEndpoint, {
322
+ response = await fetchFn(auth.tokenEndpoint, {
319
323
  method: "POST",
320
324
  headers: {
321
325
  "content-type": "application/x-www-form-urlencoded",
@@ -340,12 +344,12 @@ var OAuth2TokenManager = class {
340
344
  }
341
345
  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
346
  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
- }
347
+ const rawExpiresIn = record.expires_in;
348
+ let parsedExpiresIn;
349
+ if (typeof rawExpiresIn === "number") parsedExpiresIn = rawExpiresIn;
350
+ else if (typeof rawExpiresIn === "string" && rawExpiresIn.trim() !== "") parsedExpiresIn = Number(rawExpiresIn);
351
+ else parsedExpiresIn = NaN;
352
+ const expiresIn = Number.isFinite(parsedExpiresIn) && parsedExpiresIn >= 0 ? parsedExpiresIn : DEFAULT_EXPIRES_IN;
349
353
  return {
350
354
  accessToken: record.access_token,
351
355
  expiresIn
@@ -363,6 +367,12 @@ const MAX_COMMAND_LINE_LENGTH = 512;
363
367
  /** The length of the CRLF terminator appended to every command. */
364
368
  const CRLF_LENGTH = 2;
365
369
  /**
370
+ * How long, in milliseconds, to wait for the graceful `QUIT` to flush during
371
+ * teardown before giving up, so an unresponsive server cannot block shutdown
372
+ * for the full socket timeout.
373
+ */
374
+ const QUIT_TIMEOUT_MS = 5e3;
375
+ /**
366
376
  * Whether a host refers to the local loopback interface, for which cleartext
367
377
  * OAuth 2.0 authentication is permitted (e.g. local testing and development).
368
378
  *
@@ -679,9 +689,22 @@ var SmtpConnection = class {
679
689
  async quit() {
680
690
  const socket = this.socket;
681
691
  if (!socket) return;
682
- if (socket.writable) try {
683
- await this.sendCommand("QUIT");
684
- } catch {}
692
+ if (socket.writable) await new Promise((resolve) => {
693
+ const done = () => {
694
+ clearTimeout(timer);
695
+ socket.off("error", done);
696
+ socket.off("close", done);
697
+ resolve();
698
+ };
699
+ const timer = setTimeout(done, QUIT_TIMEOUT_MS);
700
+ socket.once("error", done);
701
+ socket.once("close", done);
702
+ try {
703
+ socket.write("QUIT\r\n", done);
704
+ } catch {
705
+ done();
706
+ }
707
+ });
685
708
  try {
686
709
  socket.destroy();
687
710
  } catch {}
@@ -699,12 +722,17 @@ var SmtpConnection = class {
699
722
  * Decodes the Base64 JSON error challenge a server sends after a failed OAuth
700
723
  * SASL exchange, falling back to the raw message when it is not valid Base64.
701
724
  *
725
+ * The bytes are decoded as UTF-8 (via `TextDecoder`) so non-ASCII challenge
726
+ * messages are not corrupted, unlike decoding `atob`'s Latin-1 output directly.
727
+ *
702
728
  * @param message The challenge text from the server's 334 response.
703
729
  * @returns A human-readable description of the failure.
704
730
  */
705
731
  function decodeOAuth2Challenge(message) {
706
732
  try {
707
- return atob(message.trim());
733
+ const binary = atob(message.trim());
734
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
735
+ return new TextDecoder().decode(bytes);
708
736
  } catch {
709
737
  return message;
710
738
  }
package/dist/index.js CHANGED
@@ -131,7 +131,7 @@ function escapeSaslName(name) {
131
131
  */
132
132
  function selectOAuth2Mechanism(capabilities) {
133
133
  const authLine = capabilities.find((cap) => cap.toUpperCase().startsWith("AUTH"));
134
- const mechanisms = authLine == null ? [] : authLine.toUpperCase().split(/\s+/).slice(1);
134
+ const mechanisms = authLine == null ? [] : authLine.toUpperCase().replace(/=/g, " ").split(/\s+/).slice(1);
135
135
  if (mechanisms.includes("XOAUTH2")) return "xoauth2";
136
136
  if (mechanisms.includes("OAUTHBEARER")) return "oauthbearer";
137
137
  return "xoauth2";
@@ -195,8 +195,9 @@ function truncateErrorBody(text) {
195
195
  function abortable(promise, signal) {
196
196
  if (signal == null) return promise;
197
197
  return new Promise((resolve, reject) => {
198
- const onAbort = () => reject(signal.reason);
199
- if (signal.aborted) reject(signal.reason);
198
+ const abortReason = () => signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
199
+ const onAbort = () => reject(abortReason());
200
+ if (signal.aborted) reject(abortReason());
200
201
  else signal.addEventListener("abort", onAbort, { once: true });
201
202
  promise.then((value) => {
202
203
  signal.removeEventListener("abort", onAbort);
@@ -253,11 +254,13 @@ var OAuth2TokenManager = class {
253
254
  const auth = this.auth;
254
255
  if ("accessToken" in auth) return typeof auth.accessToken === "function" ? await auth.accessToken(signal) : auth.accessToken;
255
256
  const cached = this.cached;
256
- if (cached != null && cached.expiresAt - REFRESH_SAFETY_MARGIN_MS > Date.now()) return cached.accessToken;
257
+ if (cached != null && cached.expiresAt > Date.now()) return cached.accessToken;
257
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);
258
261
  this.cached = {
259
262
  accessToken: result.accessToken,
260
- expiresAt: Date.now() + result.expiresIn * 1e3
263
+ expiresAt: Date.now() + lifetimeMs - safetyMargin
261
264
  };
262
265
  return result;
263
266
  }).finally(() => {
@@ -291,8 +294,9 @@ var OAuth2TokenManager = class {
291
294
  }, TOKEN_REQUEST_TIMEOUT_MS);
292
295
  let response;
293
296
  let text;
297
+ const fetchFn = this.fetchFn;
294
298
  try {
295
- response = await this.fetchFn(auth.tokenEndpoint, {
299
+ response = await fetchFn(auth.tokenEndpoint, {
296
300
  method: "POST",
297
301
  headers: {
298
302
  "content-type": "application/x-www-form-urlencoded",
@@ -317,12 +321,12 @@ var OAuth2TokenManager = class {
317
321
  }
318
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}`);
319
323
  const record = json;
320
- let expiresIn = DEFAULT_EXPIRES_IN;
321
- if (typeof record.expires_in === "number") expiresIn = record.expires_in;
322
- else if (typeof record.expires_in === "string") {
323
- const parsed = Number.parseInt(record.expires_in, 10);
324
- if (Number.isFinite(parsed)) expiresIn = parsed;
325
- }
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;
326
330
  return {
327
331
  accessToken: record.access_token,
328
332
  expiresIn
@@ -340,6 +344,12 @@ const MAX_COMMAND_LINE_LENGTH = 512;
340
344
  /** The length of the CRLF terminator appended to every command. */
341
345
  const CRLF_LENGTH = 2;
342
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
+ /**
343
353
  * Whether a host refers to the local loopback interface, for which cleartext
344
354
  * OAuth 2.0 authentication is permitted (e.g. local testing and development).
345
355
  *
@@ -656,9 +666,22 @@ var SmtpConnection = class {
656
666
  async quit() {
657
667
  const socket = this.socket;
658
668
  if (!socket) return;
659
- if (socket.writable) try {
660
- await this.sendCommand("QUIT");
661
- } catch {}
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
+ });
662
685
  try {
663
686
  socket.destroy();
664
687
  } catch {}
@@ -676,12 +699,17 @@ var SmtpConnection = class {
676
699
  * Decodes the Base64 JSON error challenge a server sends after a failed OAuth
677
700
  * SASL exchange, falling back to the raw message when it is not valid Base64.
678
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
+ *
679
705
  * @param message The challenge text from the server's 334 response.
680
706
  * @returns A human-readable description of the failure.
681
707
  */
682
708
  function decodeOAuth2Challenge(message) {
683
709
  try {
684
- return atob(message.trim());
710
+ const binary = atob(message.trim());
711
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
712
+ return new TextDecoder().decode(bytes);
685
713
  } catch {
686
714
  return message;
687
715
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upyo/smtp",
3
- "version": "0.5.0-dev.128",
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.128+6e126027"
56
+ "@upyo/core": "0.5.0-dev.136+adacf579"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@dotenvx/dotenvx": "^1.47.3",