@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 +44 -16
- package/dist/index.js +44 -16
- package/package.json +2 -2
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
|
|
222
|
-
|
|
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
|
|
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() +
|
|
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
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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)
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
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
|
|
199
|
-
|
|
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
|
|
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() +
|
|
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
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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)
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
56
|
+
"@upyo/core": "0.5.0-dev.136+adacf579"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@dotenvx/dotenvx": "^1.47.3",
|