domma-cms 1.6.38 → 1.6.39

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.
@@ -73,6 +73,52 @@ export function createPool(config) {
73
73
  };
74
74
  }
75
75
 
76
+ /**
77
+ * Open and sign in one client, renewing an OAuth2 token on the way.
78
+ *
79
+ * A password connection is a single attempt, as it always was. A signed-in
80
+ * (OAuth2) one carries `refresh` from `toConnection()`: the stored token is
81
+ * taken (renewed first when under a minute is left), and renewed once more -
82
+ * forced - when the server refuses it anyway (revoked early, a clock out
83
+ * of step). Never more than once: a second refusal is the answer.
84
+ *
85
+ * @param {object} connection
86
+ * @param {(client: ImapFlow) => void} [prepare] - listeners to add before connect
87
+ * @returns {Promise<ImapFlow>}
88
+ */
89
+ async function connectClient(connection, prepare = () => {}) {
90
+ let auth = connection.auth;
91
+ const canRenew = typeof connection.refresh === 'function';
92
+ if (canRenew) {
93
+ // Asked every time, not only near expiry: it reads the STORED
94
+ // sign-in (no network while the token is good), so a connection
95
+ // object built a while ago neither presents a token another
96
+ // request has since replaced, nor tries one already known dead.
97
+ const r = await connection.refresh(false);
98
+ auth = {user: auth.user, accessToken: r.accessToken};
99
+ }
100
+
101
+ const attempt = async (withAuth) => {
102
+ const client = new ImapFlow(clientOptions({...connection, auth: withAuth}));
103
+ prepare(client);
104
+ try {
105
+ await client.connect();
106
+ return client;
107
+ } catch (err) {
108
+ try { client.close(); } catch { /* already down */ }
109
+ throw err;
110
+ }
111
+ };
112
+
113
+ try {
114
+ return await attempt(auth);
115
+ } catch (err) {
116
+ if (!canRenew || !err?.authenticationFailed) throw err;
117
+ const r = await connection.refresh(true);
118
+ return attempt({user: auth.user, accessToken: r.accessToken});
119
+ }
120
+ }
121
+
76
122
  /**
77
123
  * Drop a pooled connection, closing it best-effort.
78
124
  *
@@ -115,14 +161,14 @@ export function createPool(config) {
115
161
  }
116
162
 
117
163
  const connect = (async () => {
118
- const client = new ImapFlow(clientOptions(connection));
119
- // ImapFlow emits 'error' on an already-failed connection; without a
120
- // listener that becomes an unhandled event and takes the process out.
121
- client.on('error', () => evict(userId));
122
- client.on('close', () => {
123
- if (pool.get(userId)?.client === client) pool.delete(userId);
164
+ const client = await connectClient(connection, (c) => {
165
+ // ImapFlow emits 'error' on an already-failed connection; without a
166
+ // listener that becomes an unhandled event and takes the process out.
167
+ c.on('error', () => evict(userId));
168
+ c.on('close', () => {
169
+ if (pool.get(userId)?.client === c) pool.delete(userId);
170
+ });
124
171
  });
125
- await client.connect();
126
172
  pool.set(userId, {client, fingerprint: fp, lastUsed: Date.now()});
127
173
  return client;
128
174
  })();
@@ -193,20 +239,19 @@ export function createPool(config) {
193
239
  * @returns {Promise<{ok: true, greeting: string|null} | {ok: false, error: string}>}
194
240
  */
195
241
  async function test(connection) {
196
- const client = new ImapFlow(clientOptions(connection));
197
- client.on('error', () => {});
242
+ let client = null;
198
243
  try {
199
- await client.connect();
244
+ client = await connectClient(connection, c => c.on('error', () => {}));
200
245
  const greeting = client.serverInfo?.greeting ?? null;
201
246
  await client.logout();
202
247
  return {ok: true, greeting};
203
248
  } catch (err) {
204
249
  try {
205
- client.close();
250
+ client?.close();
206
251
  } catch {
207
252
  // Already down - nothing to close.
208
253
  }
209
- return {ok: false, error: describeError(err, connection)};
254
+ return {ok: false, error: err?.needsReconnect ? err.message : describeError(err, connection)};
210
255
  }
211
256
  }
212
257
 
@@ -255,7 +300,7 @@ export function createPool(config) {
255
300
  // Never hold the process open on account of an idle mail connection.
256
301
  reaper.unref?.();
257
302
 
258
- return {withClient, withMailbox, test, release, releaseUser, closeAll, get size() {
303
+ return {withClient, withMailbox, test, release, releaseUser, closeAll, connectClient, get size() {
259
304
  return pool.size;
260
305
  }};
261
306
  }
@@ -276,6 +321,11 @@ export function createPool(config) {
276
321
  */
277
322
  export function describeError(err, connection = {}) {
278
323
  if (!err) return 'Unknown error.';
324
+ if (err.needsReconnect) return err.message;
325
+ // A signed-in (OAuth2) mailbox has no password to blame.
326
+ if ((err.authenticationFailed || err.code === 'EAUTH' || err.code === 'EOAUTH2') && (connection.auth?.accessToken !== undefined || connection.auth?.type === 'OAuth2')) {
327
+ return 'The mail server refused the sign-in. If it keeps happening, Reconnect the mailbox.';
328
+ }
279
329
  if (err.authenticationFailed || err.code === 'EAUTH') return 'The mail server rejected that username and password.';
280
330
 
281
331
  const host = connection.host || 'The mail server';
@@ -0,0 +1,453 @@
1
+ /**
2
+ * OAuth2 sign-in for mailboxes (XOAUTH2) - Microsoft 365 / Outlook.com and Gmail.
3
+ *
4
+ * A mailbox can be signed in with the provider instead of a password: no app
5
+ * password, and a sign-in the provider can withdraw. The customer registers
6
+ * THEIR OWN app with the provider (the Docs Pro cloud-copies design) - the
7
+ * site never holds a client id of ours, so nobody else's app or verification
8
+ * stands between a customer and their own mail.
9
+ *
10
+ * What is kept (sealed with secretbox, the same key as mailbox passwords):
11
+ * the client secret, the refresh token and the current access token.
12
+ *
13
+ * ## Refreshing
14
+ *
15
+ * An access token lasts about an hour. It is renewed at the point of use, not
16
+ * on a timer: IMAP and SMTP only present it when a connection is opened, and
17
+ * a pooled IMAP connection stays signed in after its token has expired.
18
+ *
19
+ * - before expiry: `toConnection()` carries `expiresAt` and a `refresh`
20
+ * function; the pool renews when under a minute is left.
21
+ * - on an auth failure: the pool (and nodemailer, through its
22
+ * `provisionCallback`) asks for a forced renewal and tries ONCE more.
23
+ *
24
+ * A refresh the provider REFUSES (invalid_grant and friends - revoked,
25
+ * expired, a Google "Testing" app's 7-day limit, a client secret that ran
26
+ * out) marks the account "needs reconnecting" and tells its owner. From then
27
+ * on `toConnection()` throws OAuthReconnectError without touching the network,
28
+ * so the poller cannot hammer a dead sign-in. A refresh that merely could not
29
+ * be done (network, 5xx) changes nothing and is tried again next time.
30
+ *
31
+ * Microsoft ROTATES refresh tokens: every refresh may answer a new one, and the
32
+ * new one is saved. Google answers none on refresh; the stored one is kept.
33
+ *
34
+ * Endpoints and scopes from the vendors' documentation (2026-10):
35
+ * - learn.microsoft.com .../v2-oauth2-auth-code-flow and
36
+ * .../how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth
37
+ * - developers.google.com .../oauth2/web-server and .../gmail/imap/xoauth2-protocol
38
+ *
39
+ * @module _lib/mail/oauth
40
+ */
41
+ import {createHash, randomBytes} from 'node:crypto';
42
+
43
+ import * as accounts from './accounts.js';
44
+ import {OAuthReconnectError} from './accounts.js';
45
+ import {open, seal} from './secretbox.js';
46
+
47
+ /** The tenant id Microsoft gives every personal (Outlook.com) account in an id_token. */
48
+ const MS_CONSUMER_TENANT = '9188040d-6c67-4c5b-b112-36a304b66dad';
49
+
50
+ /** Renew when less than this is left, so a token never expires mid-handshake. */
51
+ export const REFRESH_SKEW_MS = 60_000;
52
+
53
+ const MS_MAIL_SCOPES = ['https://outlook.office.com/IMAP.AccessAsUser.All', 'https://outlook.office.com/SMTP.Send'];
54
+
55
+ /**
56
+ * The two providers. Hosts are fixed: the token only works against the
57
+ * provider's own servers, so there is nothing for the customer to type and no
58
+ * host to guard.
59
+ */
60
+ export const PROVIDERS = {
61
+ microsoft: {
62
+ id: 'microsoft',
63
+ label: 'Microsoft 365 / Outlook.com',
64
+ short: 'Microsoft',
65
+ needsTenant: true,
66
+ authorizeUrl: (tenant) => `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`,
67
+ tokenUrl: (tenant) => `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
68
+ // openid + email only to learn the address signed in; all the mail
69
+ // scopes are one resource (outlook.office.com), which Microsoft
70
+ // requires of a single token.
71
+ scopes: [...MS_MAIL_SCOPES, 'offline_access', 'openid', 'email'],
72
+ refreshScope: [...MS_MAIL_SCOPES, 'offline_access'].join(' '),
73
+ // Recommended for confidential clients too (the auth-code-flow page).
74
+ pkce: true,
75
+ extraAuthParams: {response_mode: 'query', prompt: 'select_account'},
76
+ imap: {host: 'outlook.office365.com', port: 993, secure: true},
77
+ smtp: {host: 'smtp.office365.com', port: 587, secure: false},
78
+ // Outlook.com's own outgoing server (support.microsoft.com, Outlook.com settings).
79
+ smtpPersonal: {host: 'smtp-mail.outlook.com', port: 587, secure: false},
80
+ // Microsoft offers no endpoint for an app to hand one refresh token back.
81
+ revokeUrl: null
82
+ },
83
+ google: {
84
+ id: 'google',
85
+ label: 'Gmail / Google Workspace',
86
+ short: 'Google',
87
+ needsTenant: false,
88
+ authorizeUrl: () => 'https://accounts.google.com/o/oauth2/v2/auth',
89
+ tokenUrl: () => 'https://oauth2.googleapis.com/token',
90
+ scopes: ['https://mail.google.com/', 'openid', 'email'],
91
+ refreshScope: null,
92
+ // PKCE as well as state + secret (Google accepts S256 code_challenge).
93
+ pkce: true,
94
+ // offline + consent, or Google sends no refresh token on a second connect.
95
+ extraAuthParams: {access_type: 'offline', prompt: 'consent', include_granted_scopes: 'true'},
96
+ imap: {host: 'imap.gmail.com', port: 993, secure: true},
97
+ smtp: {host: 'smtp.gmail.com', port: 465, secure: true},
98
+ smtpPersonal: null,
99
+ revokeUrl: 'https://oauth2.googleapis.com/revoke'
100
+ }
101
+ };
102
+
103
+ /** Token-endpoint errors that no retry will clear: the person has to sign in again. */
104
+ const PERMANENT = new Set(['invalid_grant', 'invalid_client', 'unauthorized_client', 'interaction_required',
105
+ 'consent_required', 'invalid_scope', 'access_denied']);
106
+
107
+ /** Swappable for tests - nothing real is reachable from a test run. */
108
+ let fetchImpl = (...args) => globalThis.fetch(...args);
109
+ /** Told when an account needs reconnecting, and when it is connected again. */
110
+ let hooks = {};
111
+
112
+ /**
113
+ * @param {{fetch?: Function, onNeedsReconnect?: Function, onConnected?: Function}} options
114
+ * @returns {void}
115
+ */
116
+ export function configureOAuth({fetch, onNeedsReconnect, onConnected} = {}) {
117
+ if (fetch) fetchImpl = fetch;
118
+ if (onNeedsReconnect !== undefined || onConnected !== undefined) {
119
+ hooks = {...hooks, ...(onNeedsReconnect !== undefined ? {onNeedsReconnect} : {}),
120
+ ...(onConnected !== undefined ? {onConnected} : {})};
121
+ }
122
+ }
123
+
124
+ /** Tests: back to the real network and no listeners. */
125
+ export function _resetOAuth() {
126
+ fetchImpl = (...args) => globalThis.fetch(...args);
127
+ hooks = {};
128
+ inFlight.clear();
129
+ }
130
+
131
+ /**
132
+ * A tenant segment for Microsoft's URLs: `common`, `organizations`,
133
+ * `consumers` or a tenant id/domain. Anything else falls back to `common`
134
+ * rather than being pasted into a URL.
135
+ *
136
+ * @param {string} value
137
+ * @returns {string}
138
+ */
139
+ export function cleanTenant(value) {
140
+ const t = String(value ?? '').trim();
141
+ // Dot-separated labels only: no empty label, no `..`, nothing that could
142
+ // walk a path segment.
143
+ return t.length >= 2 && t.length <= 100 && /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*$/.test(t) ? t : 'common';
144
+ }
145
+
146
+ /**
147
+ * PKCE pair (RFC 7636, S256).
148
+ *
149
+ * @returns {{verifier: string, challenge: string}}
150
+ */
151
+ export function pkcePair() {
152
+ const verifier = randomBytes(48).toString('base64url');
153
+ return {verifier, challenge: createHash('sha256').update(verifier).digest('base64url')};
154
+ }
155
+
156
+ /**
157
+ * The provider's sign-in address. Pure.
158
+ *
159
+ * @param {{provider: string, clientId: string, tenant?: string, redirectUri: string, state: string,
160
+ * codeChallenge?: string, loginHint?: string}} o
161
+ * @returns {string}
162
+ */
163
+ export function authorizeUrl({provider, clientId, tenant, redirectUri, state, codeChallenge, loginHint}) {
164
+ const p = PROVIDERS[provider];
165
+ if (!p) throw new Error('Unknown provider.');
166
+ const q = new URLSearchParams({
167
+ client_id: String(clientId ?? '').trim(),
168
+ response_type: 'code',
169
+ redirect_uri: redirectUri,
170
+ scope: p.scopes.join(' '),
171
+ state,
172
+ ...p.extraAuthParams
173
+ });
174
+ if (p.pkce && codeChallenge) {
175
+ q.set('code_challenge', codeChallenge);
176
+ q.set('code_challenge_method', 'S256');
177
+ }
178
+ if (loginHint) q.set('login_hint', loginHint);
179
+ return `${p.authorizeUrl(cleanTenant(tenant))}?${q}`;
180
+ }
181
+
182
+ /**
183
+ * The claims of an id_token. NOT verified: it came straight from the token
184
+ * endpoint over TLS in answer to our own request (OpenID Connect Core 3.1.3.7
185
+ * allows that), and it is used only to learn the address signed in.
186
+ *
187
+ * @param {string} idToken
188
+ * @returns {object}
189
+ */
190
+ export function idTokenClaims(idToken) {
191
+ try {
192
+ const part = String(idToken ?? '').split('.')[1];
193
+ return part ? JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) : {};
194
+ } catch {
195
+ return {};
196
+ }
197
+ }
198
+
199
+ /**
200
+ * The mailbox address and outgoing server a token response points at. Pure.
201
+ *
202
+ * @param {string} provider
203
+ * @param {object} claims - idTokenClaims()
204
+ * @returns {{email: string, smtp: {host: string, port: number, secure: boolean}}}
205
+ */
206
+ export function mailboxFor(provider, claims) {
207
+ const p = PROVIDERS[provider];
208
+ const email = String(claims?.email || claims?.preferred_username || claims?.upn || '').trim().toLowerCase();
209
+ const personal = provider === 'microsoft' && claims?.tid === MS_CONSUMER_TENANT;
210
+ return {email: /@/.test(email) ? email : '', smtp: personal && p.smtpPersonal ? p.smtpPersonal : p.smtp};
211
+ }
212
+
213
+ /**
214
+ * Does this stored sign-in need renewing before use? Pure.
215
+ *
216
+ * @param {{expiresAt?: number, accessTokenSealed?: string}} oauth
217
+ * @param {number} [now]
218
+ * @returns {boolean}
219
+ */
220
+ export function needsRefresh(oauth, now = Date.now()) {
221
+ if (!oauth?.accessTokenSealed) return true;
222
+ return !(Number(oauth.expiresAt) > now + REFRESH_SKEW_MS);
223
+ }
224
+
225
+ /**
226
+ * Sort a token endpoint failure into "sign in again" or "try later". Pure.
227
+ *
228
+ * @param {number} status
229
+ * @param {object|null} body
230
+ * @returns {{permanent: boolean, code: string, message: string}}
231
+ */
232
+ export function classifyTokenError(status, body) {
233
+ const code = String(body?.error ?? '').trim();
234
+ const said = String(body?.error_description ?? '').replace(/[\r\n\t]+/g, ' ').split(/Trace ID:/i)[0].trim().slice(0, 200);
235
+ const permanent = PERMANENT.has(code) || (status === 400 && !code) || status === 401;
236
+ let message;
237
+ if (code === 'invalid_client') message = 'The app\'s client secret was refused - it may have expired. Make a new one, then Reconnect.';
238
+ else if (code === 'invalid_grant') message = 'The sign-in has expired or was withdrawn. Reconnect to sign in again.';
239
+ else if (permanent) message = `The provider refused to renew the sign-in${said ? `: ${said}` : ''}. Reconnect to sign in again.`;
240
+ else message = `The provider could not renew the sign-in just now${said ? ` (${said})` : ` (${status || 'no answer'})`}. It will be tried again.`;
241
+ return {permanent, code, message};
242
+ }
243
+
244
+ /**
245
+ * POST a form to a fixed provider endpoint. Redirects are refused (the hosts
246
+ * are known; a 3xx is not something to follow with a secret in the body).
247
+ *
248
+ * @returns {Promise<{status: number, body: object|null}>}
249
+ */
250
+ async function postForm(url, params) {
251
+ const res = await fetchImpl(url, {
252
+ method: 'POST',
253
+ headers: {'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json'},
254
+ body: new URLSearchParams(params).toString(),
255
+ redirect: 'manual',
256
+ signal: AbortSignal.timeout(20_000)
257
+ });
258
+ let body = null;
259
+ try { body = await res.json(); } catch { body = null; }
260
+ return {status: res.status, ok: res.ok, body};
261
+ }
262
+
263
+ /**
264
+ * Trade the code from the callback for tokens.
265
+ *
266
+ * @param {{provider: string, clientId: string, clientSecret: string, tenant?: string,
267
+ * code: string, redirectUri: string, codeVerifier?: string}} o
268
+ * @returns {Promise<{accessToken: string, refreshToken: string, expiresAt: number, scope: string, claims: object}>}
269
+ */
270
+ export async function exchangeCode({provider, clientId, clientSecret, tenant, code, redirectUri, codeVerifier}) {
271
+ const p = PROVIDERS[provider];
272
+ const params = {client_id: clientId, client_secret: clientSecret, code, redirect_uri: redirectUri, grant_type: 'authorization_code'};
273
+ if (p.pkce && codeVerifier) params.code_verifier = codeVerifier;
274
+ let r;
275
+ try {
276
+ r = await postForm(p.tokenUrl(cleanTenant(tenant)), params);
277
+ } catch (err) {
278
+ throw new Error(`${p.short} could not be reached to finish the sign-in: ${err.message}`);
279
+ }
280
+ if (!r.ok || !r.body?.access_token) {
281
+ const why = classifyTokenError(r.status, r.body);
282
+ throw new Error(`${p.short} refused the sign-in${why.code ? ` (${why.code})` : ''}: ${String(r.body?.error_description || r.status).split(/Trace ID:/i)[0].trim().slice(0, 200)}`);
283
+ }
284
+ if (!r.body.refresh_token) {
285
+ throw new Error(provider === 'google'
286
+ ? 'Google sent no refresh token. Remove the app from your Google account\'s third-party access, then Connect again.'
287
+ : 'Microsoft sent no refresh token - is offline_access among the app\'s API permissions?');
288
+ }
289
+ if (provider === 'google' && r.body.scope && !String(r.body.scope).split(' ').includes('https://mail.google.com/')) {
290
+ throw new Error('Mail access was not granted - tick it on Google\'s consent screen, then Connect again.');
291
+ }
292
+ return {
293
+ accessToken: r.body.access_token,
294
+ refreshToken: r.body.refresh_token,
295
+ expiresAt: Date.now() + (Number(r.body.expires_in) || 3600) * 1000,
296
+ scope: String(r.body.scope ?? ''),
297
+ claims: idTokenClaims(r.body.id_token)
298
+ };
299
+ }
300
+
301
+ /** One refresh per account at a time - a rotated refresh token must not be spent twice. */
302
+ const inFlight = new Map();
303
+
304
+ /**
305
+ * Renew an account's access token (or hand back the stored one while it is
306
+ * still good, unless `force`). Saves the new token, and a rotated refresh
307
+ * token. Throws OAuthReconnectError when the provider refuses for good (the
308
+ * account is marked and its owner told), a plain Error when it merely failed.
309
+ *
310
+ * @param {string} userId
311
+ * @param {string} accountId
312
+ * @param {{force?: boolean}} [options]
313
+ * @returns {Promise<{accessToken: string, expiresAt: number, account: object}>}
314
+ */
315
+ export function refreshAccount(userId, accountId, {force = false} = {}) {
316
+ const key = `${userId}:${accountId}`;
317
+ const running = inFlight.get(key);
318
+ if (running) return running;
319
+ const job = doRefresh(userId, accountId, force).finally(() => inFlight.delete(key));
320
+ inFlight.set(key, job);
321
+ return job;
322
+ }
323
+
324
+ async function doRefresh(userId, accountId, force) {
325
+ // Always the stored record: another caller may have rotated the token since
326
+ // this account object was read.
327
+ const account = await accounts.getAccount(userId, accountId);
328
+ if (!account || !accounts.isOAuth(account)) throw new Error('That mailbox is not signed in with a provider.');
329
+ if (accounts.needsReconnect(account)) throw new OAuthReconnectError(account.oauth?.error || undefined);
330
+ const o = account.oauth;
331
+ if (!force && !needsRefresh(o)) {
332
+ return {accessToken: open(o.accessTokenSealed), expiresAt: Number(o.expiresAt), account};
333
+ }
334
+
335
+ const p = PROVIDERS[o.provider];
336
+ // The generation this renewal works against - see updateOAuth.
337
+ const gen = Number(o.gen) || 0;
338
+ let clientSecret;
339
+ let refreshToken;
340
+ try {
341
+ clientSecret = open(o.clientSecretSealed);
342
+ refreshToken = open(o.refreshTokenSealed);
343
+ } catch (err) {
344
+ // The key moved (MAIL_SECRET added, say): nothing to refresh with.
345
+ return markBroken(account, err.message);
346
+ }
347
+
348
+ const params = {client_id: o.clientId, client_secret: clientSecret, refresh_token: refreshToken, grant_type: 'refresh_token'};
349
+ if (p.refreshScope) params.scope = p.refreshScope;
350
+ let r;
351
+ try {
352
+ r = await postForm(p.tokenUrl(cleanTenant(o.tenant)), params);
353
+ } catch (err) {
354
+ throw new Error(`${p.short} could not be reached to renew the sign-in: ${err.message}`);
355
+ }
356
+ if (!r.ok || !r.body?.access_token) {
357
+ const why = classifyTokenError(r.status, r.body);
358
+ if (why.permanent) return markBroken(account, why.message);
359
+ throw new Error(why.message);
360
+ }
361
+
362
+ const expiresAt = Date.now() + (Number(r.body.expires_in) || 3600) * 1000;
363
+ const saved = await accounts.updateOAuth(userId, accountId, {
364
+ accessTokenSealed: seal(r.body.access_token),
365
+ expiresAt,
366
+ // Rotation: Microsoft answers a new one and the old must be discarded.
367
+ ...(r.body.refresh_token && r.body.refresh_token !== refreshToken ? {refreshTokenSealed: seal(r.body.refresh_token)} : {}),
368
+ refreshedAt: new Date().toISOString(),
369
+ error: null
370
+ }, {expectGen: gen, requireOk: true});
371
+ if (!saved) {
372
+ // Reconnected or Disconnected while this renewal was on the wire: the
373
+ // stored state is newer and wins. Answer from it, never over it.
374
+ const now = await accounts.getAccount(userId, accountId);
375
+ if (!now || accounts.needsReconnect(now)) throw new OAuthReconnectError(now?.oauth?.error || undefined);
376
+ try {
377
+ return {accessToken: open(now.oauth.accessTokenSealed), expiresAt: Number(now.oauth.expiresAt), account: now};
378
+ } catch {
379
+ throw new Error('The sign-in changed while it was being renewed. Try again.');
380
+ }
381
+ }
382
+ return {accessToken: r.body.access_token, expiresAt, account: saved};
383
+ }
384
+
385
+ /** Mark an account as needing a new sign-in, tell its owner, and throw. */
386
+ async function markBroken(account, message) {
387
+ const wasOk = account.oauth?.status === 'ok';
388
+ // Only the sign-in that was refused: a fresh one made meanwhile (Reconnect)
389
+ // is a different generation and must not be marked broken by the old one.
390
+ const saved = await accounts.updateOAuth(account.userId, account.id,
391
+ {status: 'reconnect', error: message, accessTokenSealed: null},
392
+ {expectGen: Number(account.oauth?.gen) || 0});
393
+ if (!saved) throw new Error('The sign-in changed while it was being renewed. Try again.');
394
+ if (wasOk) {
395
+ try { hooks.onNeedsReconnect?.(saved ?? account, message); } catch { /* a notice never breaks mail */ }
396
+ }
397
+ throw new OAuthReconnectError(message);
398
+ }
399
+
400
+ /**
401
+ * The function `toConnection()` hangs on a connection: renew (or force a
402
+ * renewal) and answer the new auth for that protocol.
403
+ *
404
+ * @param {object} account
405
+ * @returns {(force: boolean) => Promise<{accessToken: string, expiresAt: number}>}
406
+ */
407
+ export function refresherFor(account) {
408
+ return async (force = false) => {
409
+ const r = await refreshAccount(account.userId, account.id, {force});
410
+ return {accessToken: r.accessToken, expiresAt: r.expiresAt};
411
+ };
412
+ }
413
+
414
+ /**
415
+ * Hand the refresh token back to the provider, where it can be (Google).
416
+ * Best-effort: a sign-in we forget is gone from here either way.
417
+ *
418
+ * @param {object} account
419
+ * @returns {Promise<{revoked: boolean, supported: boolean}>}
420
+ */
421
+ export async function revokeAccount(account) {
422
+ const p = PROVIDERS[account?.oauth?.provider];
423
+ if (!p?.revokeUrl) return {revoked: false, supported: false};
424
+ let token;
425
+ try {
426
+ token = open(account.oauth.refreshTokenSealed);
427
+ } catch {
428
+ return {revoked: false, supported: true};
429
+ }
430
+ try {
431
+ const r = await postForm(p.revokeUrl, {token});
432
+ return {revoked: r.ok, supported: true};
433
+ } catch {
434
+ return {revoked: false, supported: true};
435
+ }
436
+ }
437
+
438
+ /** Tell the listener an account is connected again (the callback). */
439
+ export function announceConnected(account) {
440
+ try { hooks.onConnected?.(account); } catch { /* ditto */ }
441
+ }
442
+
443
+ /**
444
+ * What the admin is told about the providers - no secrets.
445
+ *
446
+ * @returns {object[]}
447
+ */
448
+ export function describeProviders() {
449
+ return Object.values(PROVIDERS).map(p => ({
450
+ id: p.id, label: p.label, short: p.short, needsTenant: p.needsTenant,
451
+ imap: p.imap, smtp: p.smtp, scopes: p.scopes, canRevoke: Boolean(p.revokeUrl)
452
+ }));
453
+ }