dsh-email 0.10.7 → 0.11.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/README.en.md +81 -8
- package/README.md +61 -9
- package/lib/client.js +2248 -182
- package/lib/config.d.ts +141 -1
- package/lib/config.js +231 -10
- package/lib/index.d.ts +6 -2
- package/lib/index.js +3 -2
- package/lib/mail-client.d.ts +90 -1
- package/lib/mail-client.js +214 -33
- package/lib/oauth2.d.ts +124 -0
- package/lib/oauth2.js +417 -0
- package/lib/runtime.js +16 -3
- package/lib/settings.d.ts +22 -1
- package/lib/settings.js +122 -34
- package/lib/tool-contract.js +1 -1
- package/lib/tools.js +20 -5
- package/lib/web.d.ts +186 -2
- package/lib/web.js +861 -14
- package/package.json +1 -1
package/lib/mail-client.js
CHANGED
|
@@ -2,6 +2,7 @@ import { ImapFlow } from 'imapflow';
|
|
|
2
2
|
import nodemailer from 'nodemailer';
|
|
3
3
|
import { mkdir, stat, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { getFreshAccessToken, OAuth2Error } from './oauth2.js';
|
|
5
6
|
import { flattenAddresses, parseRawMessage, sanitizeFilename } from './parse.js';
|
|
6
7
|
export class MailError extends Error {
|
|
7
8
|
constructor(message) {
|
|
@@ -12,6 +13,66 @@ export class MailError extends Error {
|
|
|
12
13
|
export function messageOf(error, fallback) {
|
|
13
14
|
return error instanceof Error && error.message !== '' ? error.message : fallback;
|
|
14
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Replace anything credential-shaped in a server's own error text before it
|
|
18
|
+
* reaches a user.
|
|
19
|
+
*
|
|
20
|
+
* IMAP and SMTP servers routinely quote back the authentication string they
|
|
21
|
+
* rejected. For XOAUTH2 that string is `user=…\x01auth=Bearer <token>\x01\x01`,
|
|
22
|
+
* usually base64'd — so the raw message carries a live access token, and these
|
|
23
|
+
* messages are rendered in the settings panel, returned by the mail tools, and
|
|
24
|
+
* pasted into bug reports.
|
|
25
|
+
*
|
|
26
|
+
* Two shapes are masked: a JWT (three base64url segments, which is what every
|
|
27
|
+
* OAuth2 access token looks like) and a long base64 run (the quoted XOAUTH2
|
|
28
|
+
* blob). The replacement keeps the length so a report still says how big the
|
|
29
|
+
* thing was, without saying what it was.
|
|
30
|
+
*/
|
|
31
|
+
export function redactCredentials(text) {
|
|
32
|
+
return text
|
|
33
|
+
.replace(/[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{8,}/g, match => `<已隐去 ${match.length} 字符的令牌>`)
|
|
34
|
+
.replace(/[A-Za-z0-9+/]{40,}={0,2}/g, match => `<已隐去 ${match.length} 字符的凭据>`);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The IMAP `auth` block for one account. Pure so the shape the library
|
|
38
|
+
* receives is testable without a socket: an OAuth2 account authenticates with
|
|
39
|
+
* `accessToken` (imapflow then runs AUTHENTICATE XOAUTH2) and a password
|
|
40
|
+
* account with `pass`, exactly as before.
|
|
41
|
+
*/
|
|
42
|
+
export function imapAuthOf(cfg, accessToken) {
|
|
43
|
+
return cfg.authKind === 'oauth2'
|
|
44
|
+
? { user: cfg.user, accessToken: accessToken ?? '' }
|
|
45
|
+
: { user: cfg.user, pass: cfg.password };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Nodemailer consumes an OAuth2 token through accessToken, not pass.
|
|
49
|
+
* Refresh remains owned by this plugin; no refresh credentials leave here.
|
|
50
|
+
*/
|
|
51
|
+
export function smtpAuthOf(cfg, accessToken) {
|
|
52
|
+
return cfg.authKind === 'oauth2'
|
|
53
|
+
? { type: 'OAuth2', user: cfg.user, accessToken: accessToken ?? '' }
|
|
54
|
+
: { user: cfg.user, pass: cfg.password };
|
|
55
|
+
}
|
|
56
|
+
/** The message an OAuth2 account gets when the mailbox has to be logged into again. */
|
|
57
|
+
export const OAUTH2_RELOGIN_MESSAGE = '邮箱登录失败:请到设置页重新登录(Microsoft 账号使用设备码登录,不使用授权码)';
|
|
58
|
+
/**
|
|
59
|
+
* True for the errors both libraries report when the server rejects the
|
|
60
|
+
* credentials. An expired access token is indistinguishable from a wrong
|
|
61
|
+
* password at this level, so the connection retries once with a forced refresh
|
|
62
|
+
* before it believes the token is really dead.
|
|
63
|
+
*/
|
|
64
|
+
export function looksLikeAuthFailure(error) {
|
|
65
|
+
const raw = messageOf(error, '').toLowerCase();
|
|
66
|
+
if (raw === '')
|
|
67
|
+
return false;
|
|
68
|
+
return raw.includes('authentication')
|
|
69
|
+
|| raw.includes('authenticate')
|
|
70
|
+
|| raw.includes('auth failed')
|
|
71
|
+
|| raw.includes('login')
|
|
72
|
+
|| raw.includes('command failed')
|
|
73
|
+
|| raw.includes('invalid credentials')
|
|
74
|
+
|| raw.includes('xoauth2');
|
|
75
|
+
}
|
|
15
76
|
/** True when any bodyStructure node declares an attachment disposition. */
|
|
16
77
|
function structureHasAttachment(node) {
|
|
17
78
|
if (node === null || node === undefined || typeof node !== 'object')
|
|
@@ -209,12 +270,12 @@ export class EmailPool {
|
|
|
209
270
|
const cfg = this.account(name);
|
|
210
271
|
return this.enqueue(name, () => this.imapRun(name, cfg, folder, readOnly, run, signal), signal);
|
|
211
272
|
}
|
|
212
|
-
createImap(cfg) {
|
|
273
|
+
createImap(auth, cfg) {
|
|
213
274
|
const client = new ImapFlow({
|
|
214
275
|
host: cfg.imap.host,
|
|
215
276
|
port: cfg.imap.port,
|
|
216
277
|
secure: cfg.imap.secure,
|
|
217
|
-
auth
|
|
278
|
+
auth,
|
|
218
279
|
logger: false,
|
|
219
280
|
connectionTimeout: cfg.imap.connectionTimeoutMs ?? 30000,
|
|
220
281
|
greetingTimeout: 30000,
|
|
@@ -234,6 +295,54 @@ export class EmailPool {
|
|
|
234
295
|
});
|
|
235
296
|
return client;
|
|
236
297
|
}
|
|
298
|
+
/**
|
|
299
|
+
* Dial and authenticate one fresh IMAP connection.
|
|
300
|
+
*
|
|
301
|
+
* A password account connects once. An OAuth2 account connects with a fresh
|
|
302
|
+
* access token and, when the server rejects it, refreshes once and tries
|
|
303
|
+
* again: a token that expired between the freshness check and the dial is
|
|
304
|
+
* indistinguishable from a wrong password at the socket, and guessing wrong
|
|
305
|
+
* would send the user through a browser login for nothing.
|
|
306
|
+
*/
|
|
307
|
+
async connectImap(name, cfg, forceToken = false) {
|
|
308
|
+
const oauth2 = cfg.authKind === 'oauth2';
|
|
309
|
+
const attempt = async (token) => {
|
|
310
|
+
const client = this.createImap(imapAuthOf(cfg, token), cfg);
|
|
311
|
+
await client.connect();
|
|
312
|
+
return client;
|
|
313
|
+
};
|
|
314
|
+
let token;
|
|
315
|
+
if (oauth2) {
|
|
316
|
+
try {
|
|
317
|
+
token = await getFreshAccessToken(name, cfg, { force: forceToken });
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
throw this.oauth2ErrorOf(error);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
return await attempt(token);
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (!oauth2 || !looksLikeAuthFailure(error))
|
|
328
|
+
throw error;
|
|
329
|
+
try {
|
|
330
|
+
token = await getFreshAccessToken(name, cfg, { force: true });
|
|
331
|
+
}
|
|
332
|
+
catch (refreshError) {
|
|
333
|
+
throw this.oauth2ErrorOf(refreshError);
|
|
334
|
+
}
|
|
335
|
+
return await attempt(token);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/** The token store's own errors are already actionable; never dress them as IMAP failures. */
|
|
339
|
+
oauth2ErrorOf(error) {
|
|
340
|
+
if (error instanceof OAuth2Error)
|
|
341
|
+
return new MailError(error.message);
|
|
342
|
+
if (error instanceof MailError)
|
|
343
|
+
return error;
|
|
344
|
+
return new MailError(OAUTH2_RELOGIN_MESSAGE + '(' + redactCredentials(messageOf(error, '未知错误')) + ')');
|
|
345
|
+
}
|
|
237
346
|
async imapRun(name, cfg, folder, readOnly, run, signal) {
|
|
238
347
|
let entry = this.imaps.get(name);
|
|
239
348
|
let activeClient = entry?.client;
|
|
@@ -252,11 +361,9 @@ export class EmailPool {
|
|
|
252
361
|
if (entry === undefined || !entry.client.usable) {
|
|
253
362
|
if (entry !== undefined)
|
|
254
363
|
await this.evictImap(name);
|
|
255
|
-
const client = this.
|
|
364
|
+
const client = await this.connectImap(name, cfg);
|
|
256
365
|
activeClient = client;
|
|
257
366
|
signal?.throwIfAborted();
|
|
258
|
-
await client.connect();
|
|
259
|
-
signal?.throwIfAborted();
|
|
260
367
|
entry = { client, selected: null, selectedReadOnly: true, lastUsed: Date.now(), inUse: 0 };
|
|
261
368
|
this.imaps.set(name, entry);
|
|
262
369
|
}
|
|
@@ -279,7 +386,7 @@ export class EmailPool {
|
|
|
279
386
|
catch (error) {
|
|
280
387
|
await this.evictImap(name);
|
|
281
388
|
signal?.throwIfAborted();
|
|
282
|
-
throw this.normalizeImapError(error, folder);
|
|
389
|
+
throw this.normalizeImapError(error, folder, cfg.authKind);
|
|
283
390
|
}
|
|
284
391
|
finally {
|
|
285
392
|
signal?.removeEventListener('abort', onAbort);
|
|
@@ -287,11 +394,14 @@ export class EmailPool {
|
|
|
287
394
|
entry.inUse = Math.max(0, entry.inUse - 1);
|
|
288
395
|
}
|
|
289
396
|
}
|
|
290
|
-
normalizeImapError(error, folder) {
|
|
397
|
+
normalizeImapError(error, folder, authKind = 'password') {
|
|
291
398
|
const raw = messageOf(error, 'IMAP 操作失败');
|
|
292
399
|
const lower = raw.toLowerCase();
|
|
293
400
|
if (lower.includes('authentication') || lower.includes('login')) {
|
|
294
|
-
|
|
401
|
+
// An OAuth2 account has no 授权码 to check: the only fix is a new login.
|
|
402
|
+
return new MailError(authKind === 'oauth2'
|
|
403
|
+
? OAUTH2_RELOGIN_MESSAGE + '(' + raw + ')'
|
|
404
|
+
: '邮箱登录失败:' + raw + '(请检查 user 与授权码)');
|
|
295
405
|
}
|
|
296
406
|
if (lower.includes('nonselect') || lower.includes('does not exist') || lower.includes('nonexistent')) {
|
|
297
407
|
return new MailError('找不到邮箱文件夹 "' + (folder ?? '') + '":' + raw);
|
|
@@ -333,7 +443,12 @@ export class EmailPool {
|
|
|
333
443
|
transporter.close();
|
|
334
444
|
this.smtps.clear();
|
|
335
445
|
}
|
|
336
|
-
|
|
446
|
+
/**
|
|
447
|
+
* A pooled transporter for one account. The token is captured when the
|
|
448
|
+
* transporter is built; an OAuth2 token that turns out to be stale is
|
|
449
|
+
* re-minted in sendMail, which rebuilds the transporter.
|
|
450
|
+
*/
|
|
451
|
+
transporter(name, cfg, accessToken) {
|
|
337
452
|
let t = this.smtps.get(name);
|
|
338
453
|
if (t === undefined) {
|
|
339
454
|
t = nodemailer.createTransport({
|
|
@@ -341,7 +456,7 @@ export class EmailPool {
|
|
|
341
456
|
host: cfg.smtp.host,
|
|
342
457
|
port: cfg.smtp.port,
|
|
343
458
|
secure: cfg.smtp.secure,
|
|
344
|
-
auth:
|
|
459
|
+
auth: smtpAuthOf(cfg, accessToken),
|
|
345
460
|
connectionTimeout: 30000,
|
|
346
461
|
greetingTimeout: 10000,
|
|
347
462
|
socketTimeout: 60000,
|
|
@@ -352,27 +467,62 @@ export class EmailPool {
|
|
|
352
467
|
}
|
|
353
468
|
return t;
|
|
354
469
|
}
|
|
355
|
-
|
|
470
|
+
dropTransporter(name, transporter) {
|
|
471
|
+
if (this.smtps.get(name) === transporter)
|
|
472
|
+
this.smtps.delete(name);
|
|
473
|
+
transporter.close();
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Send through the pooled transporter while making cancellation close it.
|
|
477
|
+
*
|
|
478
|
+
* An OAuth2 transporter carries a token that was minted when it was built,
|
|
479
|
+
* so a rejection is retried once against a freshly built one (and a fresh
|
|
480
|
+
* form of whatever stored token state exists). Password accounts keep the
|
|
481
|
+
* single attempt they always had.
|
|
482
|
+
*/
|
|
356
483
|
async sendMail(name, cfg, message, signal) {
|
|
357
484
|
signal?.throwIfAborted();
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
485
|
+
const attempt = async (forceToken) => {
|
|
486
|
+
const token = cfg.authKind === 'oauth2' ? await getFreshAccessToken(name, cfg, { force: forceToken }) : undefined;
|
|
487
|
+
const transporter = this.transporter(name, cfg, token);
|
|
488
|
+
const onAbort = () => {
|
|
489
|
+
this.dropTransporter(name, transporter);
|
|
490
|
+
};
|
|
491
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
492
|
+
try {
|
|
493
|
+
const info = await transporter.sendMail(message);
|
|
494
|
+
signal?.throwIfAborted();
|
|
495
|
+
return info;
|
|
496
|
+
}
|
|
497
|
+
finally {
|
|
498
|
+
signal?.removeEventListener('abort', onAbort);
|
|
499
|
+
}
|
|
363
500
|
};
|
|
364
|
-
signal?.addEventListener('abort', onAbort, { once: true });
|
|
365
501
|
try {
|
|
366
|
-
|
|
367
|
-
signal?.throwIfAborted();
|
|
368
|
-
return info;
|
|
502
|
+
return await attempt(false);
|
|
369
503
|
}
|
|
370
504
|
catch (error) {
|
|
371
505
|
signal?.throwIfAborted();
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
506
|
+
if (cfg.authKind !== 'oauth2')
|
|
507
|
+
throw error;
|
|
508
|
+
// A pooled connection that already authenticated can fail for reasons no
|
|
509
|
+
// token can fix (a rejected recipient, a full mailbox). Only a credential
|
|
510
|
+
// rejection is worth a second, freshly-tokened attempt — and a token
|
|
511
|
+
// store that refused outright is reported as itself.
|
|
512
|
+
if (!looksLikeAuthFailure(error))
|
|
513
|
+
throw this.oauth2ErrorOf(error);
|
|
514
|
+
// The cached transporter holds the old token: it has to go, or the retry
|
|
515
|
+
// would reuse the very credential that was just refused.
|
|
516
|
+
const stale = this.smtps.get(name);
|
|
517
|
+
if (stale !== undefined)
|
|
518
|
+
this.dropTransporter(name, stale);
|
|
519
|
+
try {
|
|
520
|
+
return await attempt(true);
|
|
521
|
+
}
|
|
522
|
+
catch (retryError) {
|
|
523
|
+
signal?.throwIfAborted();
|
|
524
|
+
throw this.oauth2ErrorOf(retryError);
|
|
525
|
+
}
|
|
376
526
|
}
|
|
377
527
|
}
|
|
378
528
|
async list(accountName, folder, limit, offset, unreadOnly, since, until, signal) {
|
|
@@ -416,8 +566,12 @@ export class EmailPool {
|
|
|
416
566
|
const folderName = folder || cfg.inboxFolder;
|
|
417
567
|
return this.withImap(name, folderName, async (client) => {
|
|
418
568
|
// No nested OR and no TEXT search: several servers (QQ among them)
|
|
419
|
-
// silently answer those with empty
|
|
420
|
-
//
|
|
569
|
+
// silently answer those with empty results, and some answer with a
|
|
570
|
+
// non-empty list that has nothing to do with the query at all (QQ again:
|
|
571
|
+
// an impossible keyword still「matches」every uid in the folder). The
|
|
572
|
+
// server's hit list is therefore a hint, not an answer: confirm it
|
|
573
|
+
// against the envelopes before reporting anything, otherwise scan
|
|
574
|
+
// locally.
|
|
421
575
|
const dateRange = {};
|
|
422
576
|
if (since !== undefined)
|
|
423
577
|
dateRange.since = since;
|
|
@@ -430,18 +584,45 @@ export class EmailPool {
|
|
|
430
584
|
client.search({ cc: query, ...dateRange }, { uid: true }),
|
|
431
585
|
]);
|
|
432
586
|
signal?.throwIfAborted();
|
|
433
|
-
const uids = [...new Set(found.flatMap(result => result === false ? [] : result))].sort((a, b) =>
|
|
434
|
-
uids.
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
587
|
+
const uids = [...new Set(found.flatMap(result => result === false ? [] : result))].sort((a, b) => b - a);
|
|
588
|
+
if (uids.length > 0) {
|
|
589
|
+
const confirmed = await this.searchHits(client, uids, query, limit, signal);
|
|
590
|
+
if (confirmed.length > 0) {
|
|
591
|
+
// The server's list holds up, so its size is reported as the match
|
|
592
|
+
// count; only rows that were confirmed are ever handed out.
|
|
593
|
+
return { account: name, query, count: uids.length, folder: folderName, messages: confirmed.slice(0, limit) };
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
// Nothing believable came back (empty answer, or hits that did not
|
|
597
|
+
// survive verification): scan the newest messages locally instead.
|
|
598
|
+
if (this.settings.bodySearchFallback) {
|
|
438
599
|
const messages = await this.searchBodies(client, query, folderName, limit, since, until, signal);
|
|
439
600
|
return { account: name, query, count: messages.length, folder: folderName, messages };
|
|
440
601
|
}
|
|
441
|
-
|
|
442
|
-
return { account: name, query, count: uids.length, folder: folderName, messages };
|
|
602
|
+
return { account: name, query, count: 0, folder: folderName, messages: [] };
|
|
443
603
|
}, true, signal);
|
|
444
604
|
}
|
|
605
|
+
/**
|
|
606
|
+
* Confirm server-side hits against the mailbox itself: fetch the envelopes
|
|
607
|
+
* of the newest candidates — the same window the body-scan fallback looks at
|
|
608
|
+
* — and keep only those that really carry the query in subject/from/to/cc,
|
|
609
|
+
* the four fields the server was asked about. No body is downloaded here,
|
|
610
|
+
* and uids the server made up simply return nothing.
|
|
611
|
+
*/
|
|
612
|
+
async searchHits(client, uids, query, limit, signal) {
|
|
613
|
+
const sample = uids.slice(0, Math.min(uids.length, Math.max(this.settings.bodySearchLimit, limit)));
|
|
614
|
+
signal?.throwIfAborted();
|
|
615
|
+
const fetched = await client.fetchAll(sample, { uid: true, envelope: true, flags: true, size: true, bodyStructure: true }, { uid: true });
|
|
616
|
+
signal?.throwIfAborted();
|
|
617
|
+
return fetched
|
|
618
|
+
.filter(message => {
|
|
619
|
+
const envelope = message.envelope;
|
|
620
|
+
const addressText = [envelope?.from, envelope?.to, envelope?.cc].map(flattenAddressText).join(' ');
|
|
621
|
+
return messageMatchesQuery(envelope?.subject ?? '', addressText, '', query);
|
|
622
|
+
})
|
|
623
|
+
.map(message => listedFrom(message, message.size, structureHasAttachment(message.bodyStructure)))
|
|
624
|
+
.sort((a, b) => b.uid - a.uid);
|
|
625
|
+
}
|
|
445
626
|
/** Client-side scan of the tail of the mailbox, newest first. */
|
|
446
627
|
async searchBodies(client, query, folder, limit, since, until, signal) {
|
|
447
628
|
signal?.throwIfAborted();
|
package/lib/oauth2.d.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { type ResolvedEmailConfig } from './config.js';
|
|
2
|
+
/** The authority that serves both endpoints. `common` accepts any work/school/personal tenant. */
|
|
3
|
+
export declare const OAUTH2_TENANT = "common";
|
|
4
|
+
export declare const DEVICE_CODE_URL: string;
|
|
5
|
+
export declare const TOKEN_URL: string;
|
|
6
|
+
/**
|
|
7
|
+
* The delegated permissions the plugin needs: offline_access (a refresh token),
|
|
8
|
+
* IMAP.AccessAsUser.All and SMTP.Send. All three must be granted on the app
|
|
9
|
+
* registration, otherwise the token request answers AADSTS65001.
|
|
10
|
+
*/
|
|
11
|
+
export declare const OAUTH2_SCOPES: string[];
|
|
12
|
+
export declare const OAUTH2_SCOPE_TEXT: string;
|
|
13
|
+
/** Reuse an access token only while this much of its life is left. */
|
|
14
|
+
export declare const ACCESS_TOKEN_MARGIN_MS: number;
|
|
15
|
+
/** Every request to the authority is bounded: a hung login must not hang a mail tool. */
|
|
16
|
+
export declare const OAUTH2_REQUEST_TIMEOUT_MS = 15000;
|
|
17
|
+
/** The one message every「no token yet」path reports, so the fix is always the same. */
|
|
18
|
+
export declare const NOT_LOGGED_IN_MESSAGE = "\u5C1A\u672A\u767B\u5F55\uFF1A\u8BF7\u5148\u5728\u8BBE\u7F6E\u9875\u5B8C\u6210\u8BBE\u5907\u7801\u767B\u5F55";
|
|
19
|
+
/**
|
|
20
|
+
* Reported when an OAuth2 account has no application to log in through. The
|
|
21
|
+
* plugin ships no third-party registration, so this is a setup step, not a
|
|
22
|
+
* failure — and it says where to go and what to type.
|
|
23
|
+
*/
|
|
24
|
+
export declare const NO_CLIENT_ID_MESSAGE = "\u5C1A\u672A\u914D\u7F6E OAuth2 \u5E94\u7528\uFF1A\u8BF7\u5728\u8BBE\u7F6E\u9875\u8BE5\u8D26\u53F7\u7684\u300C\u5E94\u7528\uFF08\u5BA2\u6237\u7AEF\uFF09ID\u300D\u91CC\u586B\u5165\u4F60\u81EA\u5DF1\u6CE8\u518C\u7684 Azure \u516C\u5171\u5BA2\u6237\u7AEF ID\uFF08\u514D\u8D39\uFF0C\u6CE8\u518C\u6B65\u9AA4\u89C1 README \u7684\u300COutlook OAuth2\u300D\u4E00\u8282\uFF09\u3002\u672C\u63D2\u4EF6\u4E0D\u5185\u7F6E\u4EFB\u4F55\u7B2C\u4E09\u65B9\u5E94\u7528\u6CE8\u518C\uFF0C\u56E0\u6B64\u6CA1\u6709\u5B83\u5C31\u65E0\u6CD5\u5F00\u59CB\u8BBE\u5907\u7801\u767B\u5F55";
|
|
25
|
+
/** Where the refresh/access tokens live. Kept out of the settings namespace on purpose. */
|
|
26
|
+
export declare function oauth2TokenFile(): string;
|
|
27
|
+
export interface OAuth2TokenEntry {
|
|
28
|
+
/** The mailbox address the token was issued for. */
|
|
29
|
+
user: string;
|
|
30
|
+
clientId: string;
|
|
31
|
+
refreshToken: string;
|
|
32
|
+
accessToken: string;
|
|
33
|
+
/** Absolute ms timestamp; 0 means「unknown, treat as expired」. */
|
|
34
|
+
expiresAt: number;
|
|
35
|
+
}
|
|
36
|
+
export interface OAuth2TokenStore {
|
|
37
|
+
version: 1;
|
|
38
|
+
accounts: Record<string, OAuth2TokenEntry>;
|
|
39
|
+
}
|
|
40
|
+
/** The part of a resolved account this module needs. */
|
|
41
|
+
export type OAuth2AccountConfig = Pick<ResolvedEmailConfig, 'user' | 'clientId'>;
|
|
42
|
+
/** Raised for every expected OAuth2 failure; the message is already user-facing Chinese. */
|
|
43
|
+
export declare class OAuth2Error extends Error {
|
|
44
|
+
constructor(message: string);
|
|
45
|
+
}
|
|
46
|
+
export interface DeviceFlowStart {
|
|
47
|
+
/** verification_uri: the page the user opens. */
|
|
48
|
+
url: string;
|
|
49
|
+
/** user_code: what the user types there. */
|
|
50
|
+
code: string;
|
|
51
|
+
/** Seconds between two polls, as the authority asked. */
|
|
52
|
+
interval: number;
|
|
53
|
+
/** Seconds until the device code itself expires. */
|
|
54
|
+
expiresIn: number;
|
|
55
|
+
}
|
|
56
|
+
/** The Chinese guidance for an AADSTS code, or undefined when it is not mapped. */
|
|
57
|
+
export declare function mapAadstsMessage(code: string | number): string | undefined;
|
|
58
|
+
interface OAuthFailure {
|
|
59
|
+
/** authorization_pending / slow_down: keep polling. */
|
|
60
|
+
pending: boolean;
|
|
61
|
+
/** slow_down: the poll interval must grow. */
|
|
62
|
+
slowDown: boolean;
|
|
63
|
+
/** invalid_grant: the stored refresh token is dead and must be dropped. */
|
|
64
|
+
clearToken: boolean;
|
|
65
|
+
message: string;
|
|
66
|
+
code: string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Turn one failed token/device-code response into a verdict. `pending` is a
|
|
70
|
+
* normal state, not an error: the user simply has not finished in the browser.
|
|
71
|
+
*/
|
|
72
|
+
export declare function classifyOAuthFailure(payload: unknown, httpStatus?: number): OAuthFailure;
|
|
73
|
+
/** Read the store. A missing or unreadable file is 「no tokens」, never a crash. */
|
|
74
|
+
export declare function readTokenStore(): OAuth2TokenStore;
|
|
75
|
+
/** Persist the store. Owner-only where the platform honours the mode; utf8, no BOM. */
|
|
76
|
+
export declare function writeTokenStore(store: OAuth2TokenStore): void;
|
|
77
|
+
/** Drop one account's tokens (a dead refresh token, or a mailbox that moved). */
|
|
78
|
+
export declare function clearTokenFor(name: string): boolean;
|
|
79
|
+
export type OAuth2State = 'none' | 'pending' | 'logged-in';
|
|
80
|
+
/**
|
|
81
|
+
* What the settings card shows for one account: logged-in wins over a flow
|
|
82
|
+
* that is merely in progress, and an expired flow is not「pending」any more.
|
|
83
|
+
*
|
|
84
|
+
* `configuredUser` is the address the account is configured with right now.
|
|
85
|
+
* A token issued for a *different* mailbox is not a login for this account —
|
|
86
|
+
* reporting it as one would show 「已登录」 on a card whose tools all fail, so
|
|
87
|
+
* the verdict is「none」and the user is sent through the flow again. An empty
|
|
88
|
+
* configured address cannot disagree with anything and keeps the token.
|
|
89
|
+
*/
|
|
90
|
+
export declare function oauth2StateOf(name: string, configuredUser?: string, configuredClientId?: string): {
|
|
91
|
+
state: OAuth2State;
|
|
92
|
+
user?: string;
|
|
93
|
+
};
|
|
94
|
+
export declare function isDeviceFlowPending(name: string): boolean;
|
|
95
|
+
export declare function clientIdOf(cfg: OAuth2AccountConfig): string;
|
|
96
|
+
/**
|
|
97
|
+
* Step 1: ask for a device code. One flow per account is kept in memory; two
|
|
98
|
+
* concurrent calls share the same request instead of creating two codes.
|
|
99
|
+
*/
|
|
100
|
+
export declare function startDeviceFlow(name: string, cfg: OAuth2AccountConfig): Promise<DeviceFlowStart>;
|
|
101
|
+
export type OAuth2PollResult = {
|
|
102
|
+
status: 'ok';
|
|
103
|
+
user: string;
|
|
104
|
+
} | {
|
|
105
|
+
status: 'pending';
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* Step 2: one poll. `pending` is the ordinary answer until the user finishes in
|
|
109
|
+
* the browser; on success the token is persisted before this returns.
|
|
110
|
+
*/
|
|
111
|
+
export declare function pollDeviceFlow(name: string): Promise<OAuth2PollResult>;
|
|
112
|
+
/**
|
|
113
|
+
* A usable access token for one account: the cached one while more than two
|
|
114
|
+
* minutes of its life are left, otherwise a refresh. Concurrent callers share a
|
|
115
|
+
* single refresh request, so a burst of tool calls never mints several tokens
|
|
116
|
+
* (Microsoft rotates the refresh token, and losing that race logs the user out).
|
|
117
|
+
*
|
|
118
|
+
* `force` re-mints even a token that still looks fresh: the only way to tell a
|
|
119
|
+
* revoked token from a network failure after a server-side rejection.
|
|
120
|
+
*/
|
|
121
|
+
export declare function getFreshAccessToken(name: string, cfg: OAuth2AccountConfig, options?: {
|
|
122
|
+
force?: boolean;
|
|
123
|
+
}): Promise<string>;
|
|
124
|
+
export {};
|