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.
@@ -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: { user: cfg.user, pass: cfg.password },
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.createImap(cfg);
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
- return new MailError('邮箱登录失败:' + raw + '(请检查 user 与授权码)');
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
- transporter(name, cfg) {
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: { user: cfg.user, pass: cfg.password },
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
- /** Send through the pooled transporter while making cancellation close it. */
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 transporter = this.transporter(name, cfg);
359
- const onAbort = () => {
360
- if (this.smtps.get(name) === transporter)
361
- this.smtps.delete(name);
362
- transporter.close();
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
- const info = await transporter.sendMail(message);
367
- signal?.throwIfAborted();
368
- return info;
502
+ return await attempt(false);
369
503
  }
370
504
  catch (error) {
371
505
  signal?.throwIfAborted();
372
- throw error;
373
- }
374
- finally {
375
- signal?.removeEventListener('abort', onAbort);
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 or match-everything results.
420
- // subject/from/to/cc searches unioned client-side behave well everywhere.
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) => a - b);
434
- uids.reverse();
435
- if (uids.length === 0 && this.settings.bodySearchFallback) {
436
- // Server-side search found nothing: fall back to a client-side scan of
437
- // the most recent messages (subject/from/body), capped for time.
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
- const messages = await this.fetchListed(client, uids.slice(0, limit), signal);
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();
@@ -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 {};