domma-cms 1.6.38 → 1.6.40

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.
@@ -35,7 +35,44 @@ import {normaliseUndoSeconds} from '../admin/mail/scheduling.js';
35
35
  import {normaliseVacation} from '../admin/mail/vacation.js';
36
36
  import {normaliseTemplates} from '../admin/mail/templates.js';
37
37
  import {sanitiseSignature} from './render.js';
38
- import {open, seal} from './secretbox.js';
38
+ import {MailKeyError, open, seal} from './secretbox.js';
39
+
40
+ /**
41
+ * A mailbox signed in with Microsoft or Google whose sign-in no longer works.
42
+ *
43
+ * A MailKeyError on purpose: every caller that already turns "the stored
44
+ * password cannot be opened" into a 409 and a settings prompt does the same
45
+ * here, with nothing to learn. `statusCode` makes a route that lets it escape
46
+ * answer 409 with the sentence rather than a 500.
47
+ */
48
+ export class OAuthReconnectError extends MailKeyError {
49
+ constructor(message = 'This mailbox\'s sign-in has expired or was withdrawn. Reconnect it under Mail Settings > Mailboxes.') {
50
+ super(message);
51
+ this.name = 'OAuthReconnectError';
52
+ this.needsReconnect = true;
53
+ this.statusCode = 409;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Is this mailbox signed in with a provider (OAuth2) rather than a password?
59
+ *
60
+ * @param {object|null} account
61
+ * @returns {boolean}
62
+ */
63
+ export function isOAuth(account) {
64
+ return account?.auth === 'oauth' && !!account.oauth;
65
+ }
66
+
67
+ /**
68
+ * Does this mailbox need its owner to sign in again?
69
+ *
70
+ * @param {object|null} account
71
+ * @returns {boolean}
72
+ */
73
+ export function needsReconnect(account) {
74
+ return isOAuth(account) && account.oauth.status !== 'ok';
75
+ }
39
76
 
40
77
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
41
78
 
@@ -132,11 +169,36 @@ async function readAll() {
132
169
  */
133
170
  async function writeAll(accounts) {
134
171
  await fs.mkdir(path.dirname(STORE_FILE), {recursive: true});
135
- const tmp = STORE_FILE + '.tmp';
172
+ // A name of its own per write: two writers sharing one `.tmp` could rename
173
+ // each other's half-written file into place.
174
+ const tmp = `${STORE_FILE}.${process.pid}.${randomUUID()}.tmp`;
136
175
  await fs.writeFile(tmp, JSON.stringify(accounts, null, 2) + '\n', {encoding: 'utf8', mode: 0o600});
137
176
  await fs.rename(tmp, STORE_FILE);
138
177
  }
139
178
 
179
+ /** The tail of the one queue every read-modify-write of the store waits in. */
180
+ let storeQueue = Promise.resolve();
181
+
182
+ /**
183
+ * Run a read-modify-write of the store with nothing else writing meanwhile.
184
+ *
185
+ * Every exported function that reads the store and writes it back goes
186
+ * through here. Without it a token renewal and, say, a rules save that
187
+ * overlap each write back the copy they read, and one of them is lost -
188
+ * the renewal's rotated refresh token included, which is a lost sign-in.
189
+ * Single process (as the pool is); never nest - a locked function must not
190
+ * call another one.
191
+ *
192
+ * @template T
193
+ * @param {() => Promise<T>} fn
194
+ * @returns {Promise<T>}
195
+ */
196
+ function withStoreLock(fn) {
197
+ const run = storeQueue.then(fn);
198
+ storeQueue = run.catch(() => {});
199
+ return run;
200
+ }
201
+
140
202
  /**
141
203
  * Strip everything the browser must never see.
142
204
  *
@@ -145,9 +207,13 @@ async function writeAll(accounts) {
145
207
  */
146
208
  export function toPublic(account) {
147
209
  if (!account) return null;
148
- const {passwordSealed, userId, smtp, ...rest} = account;
210
+ const {passwordSealed, userId, smtp, oauth, ...rest} = account;
149
211
  return {
150
212
  ...rest,
213
+ // Rebuilt field by field for the same reason as `smtp` below: the
214
+ // sealed client secret and tokens must never reach a browser.
215
+ ...(oauth ? {oauth: oauthToPublic(oauth)} : {}),
216
+ auth: isOAuth(account) ? 'oauth' : 'password',
151
217
  // Pulled out and rebuilt rather than spread through: `smtp` carries a
152
218
  // sealed password of its own, and a top-level destructure does not
153
219
  // reach it. Spreading the record verbatim shipped that ciphertext to
@@ -157,6 +223,178 @@ export function toPublic(account) {
157
223
  };
158
224
  }
159
225
 
226
+ /**
227
+ * The sign-in facts the admin may see.
228
+ *
229
+ * @param {object} oauth
230
+ * @returns {object}
231
+ */
232
+ function oauthToPublic(oauth) {
233
+ return {
234
+ provider: oauth.provider,
235
+ clientId: oauth.clientId,
236
+ tenant: oauth.tenant ?? null,
237
+ status: oauth.status ?? 'reconnect',
238
+ error: oauth.error ?? null,
239
+ connectedAt: oauth.connectedAt ?? null,
240
+ refreshedAt: oauth.refreshedAt ?? null,
241
+ hasClientSecret: typeof oauth.clientSecretSealed === 'string' && oauth.clientSecretSealed.length > 0
242
+ };
243
+ }
244
+
245
+ /**
246
+ * Create a mailbox signed in with a provider, or move an existing one onto
247
+ * the sign-in (Reconnect, or "switch from a password").
248
+ *
249
+ * The IMAP and SMTP servers are the provider's own - the token works nowhere
250
+ * else - so they are written here rather than typed. A stored password is
251
+ * dropped: keeping it would leave a credential nobody uses in the file.
252
+ *
253
+ * @param {string} userId
254
+ * @param {string|null} accountId
255
+ * @param {{label: string, username: string, provider: string, clientId: string, tenant: string|null,
256
+ * clientSecret: string, refreshToken: string, accessToken: string, expiresAt: number,
257
+ * scope: string, imap: object, smtp: object}} f
258
+ * @returns {Promise<object|null>} the stored record, or null if not theirs
259
+ */
260
+ export async function saveOAuthAccount(userId, accountId, f) {
261
+ return withStoreLock(async () => {
262
+ const all = await readAll();
263
+ const now = new Date().toISOString();
264
+ const previous = accountId ? all.find(a => a.id === accountId && a.userId === userId) : null;
265
+ const oauth = {
266
+ // A new sign-in is a new generation: a renewal that started under the
267
+ // old one must not write its tokens over this (see updateOAuth).
268
+ gen: (Number(previous?.oauth?.gen) || 0) + 1,
269
+ provider: f.provider,
270
+ clientId: f.clientId,
271
+ tenant: f.tenant ?? null,
272
+ clientSecretSealed: seal(f.clientSecret),
273
+ refreshTokenSealed: seal(f.refreshToken),
274
+ accessTokenSealed: seal(f.accessToken),
275
+ expiresAt: f.expiresAt,
276
+ scope: f.scope ?? '',
277
+ status: 'ok',
278
+ error: null,
279
+ connectedAt: now,
280
+ refreshedAt: now
281
+ };
282
+ const fields = {
283
+ auth: 'oauth',
284
+ host: f.imap.host,
285
+ port: f.imap.port,
286
+ secure: f.imap.secure,
287
+ username: f.username,
288
+ oauth
289
+ };
290
+
291
+ if (accountId) {
292
+ const index = all.findIndex(a => a.id === accountId && a.userId === userId);
293
+ if (index === -1) return null;
294
+ const {passwordSealed, ...existing} = all[index];
295
+ const keepSmtp = existing.smtp ?? {};
296
+ all[index] = {
297
+ ...existing,
298
+ ...fields,
299
+ label: f.label || existing.label,
300
+ smtp: {
301
+ ...keepSmtp,
302
+ host: f.smtp.host, port: f.smtp.port, secure: f.smtp.secure,
303
+ username: f.username, passwordSealed: null,
304
+ from: keepSmtp.from || f.username,
305
+ sentFolder: keepSmtp.sentFolder ?? null
306
+ },
307
+ updatedAt: now
308
+ };
309
+ await writeAll(all);
310
+ return all[index];
311
+ }
312
+
313
+ const record = {
314
+ id: randomUUID(),
315
+ userId,
316
+ label: f.label || f.username,
317
+ ...fields,
318
+ smtp: {host: f.smtp.host, port: f.smtp.port, secure: f.smtp.secure, username: f.username,
319
+ passwordSealed: null, from: f.username, sentFolder: null},
320
+ createdAt: now,
321
+ updatedAt: now
322
+ };
323
+ all.push(record);
324
+ await writeAll(all);
325
+ return record;
326
+ });
327
+ }
328
+
329
+ /**
330
+ * Change part of a mailbox's stored sign-in: a renewed token, a rotated
331
+ * refresh token, "needs reconnecting", or a disconnect.
332
+ *
333
+ * Does NOT touch `updatedAt`: that stamp means "the person changed the
334
+ * settings", and the free reader's unread badge keys its failure back-off on
335
+ * it - an hourly token renewal would otherwise reset the back-off every hour.
336
+ *
337
+ * `expectGen`: apply only if the stored sign-in is still that generation.
338
+ * `requireOk`: apply only if it is still working. `bumpGen`: this patch IS a
339
+ * new state (Disconnect) that older in-flight work must not overwrite.
340
+ * Returns null when the patch was dropped (or the mailbox is gone).
341
+ *
342
+ * @param {string} userId
343
+ * @param {string} accountId
344
+ * @param {object} patch
345
+ * @param {{expectGen?: number, requireOk?: boolean, bumpGen?: boolean}} [options]
346
+ * @returns {Promise<object|null>}
347
+ */
348
+ export async function updateOAuth(userId, accountId, patch, {expectGen, requireOk = false, bumpGen = false} = {}) {
349
+ return withStoreLock(async () => {
350
+ const all = await readAll();
351
+ const index = all.findIndex(a => a.id === accountId && a.userId === userId);
352
+ if (index === -1 || !all[index].oauth) return null;
353
+ const current = all[index].oauth;
354
+ // The sign-in this patch was worked out against is no longer the stored
355
+ // one (a Reconnect or Disconnect happened meanwhile): drop it rather than
356
+ // undo the newer state.
357
+ if (expectGen !== undefined && (Number(current.gen) || 0) !== expectGen) return null;
358
+ // A renewal only ever lands on a working sign-in, never revives a dead one.
359
+ if (requireOk && current.status !== 'ok') return null;
360
+ const next = {...current, ...patch};
361
+ if (bumpGen) next.gen = (Number(current.gen) || 0) + 1;
362
+ all[index] = {...all[index], oauth: next};
363
+ await writeAll(all);
364
+ return all[index];
365
+ });
366
+ }
367
+
368
+ /**
369
+ * The renewal hook a connection carries. Imported lazily: oauth.js imports
370
+ * this module, and a static import back would be a cycle.
371
+ *
372
+ * @param {object} account
373
+ * @returns {(force: boolean) => Promise<{accessToken: string, expiresAt: number}>}
374
+ */
375
+ function refresher(account) {
376
+ return async (force = false) => (await import('./oauth.js')).refresherFor(account)(force);
377
+ }
378
+
379
+ /**
380
+ * The stored access token for a signed-in mailbox, or throw when the sign-in
381
+ * is dead. An expired token is still handed out: the pool renews it before
382
+ * use, and nodemailer through its provisionCallback.
383
+ *
384
+ * @param {object} account
385
+ * @returns {{accessToken: string, expiresAt: number}}
386
+ */
387
+ function storedAccess(account) {
388
+ if (needsReconnect(account)) throw new OAuthReconnectError(account.oauth.error || undefined);
389
+ let accessToken = '';
390
+ try {
391
+ accessToken = account.oauth.accessTokenSealed ? open(account.oauth.accessTokenSealed) : '';
392
+ } catch {
393
+ // Unreadable (the key moved): treat as expired, the refresh decides.
394
+ }
395
+ return {accessToken, expiresAt: accessToken ? Number(account.oauth.expiresAt) || 0 : 0};
396
+ }
397
+
160
398
  /**
161
399
  * Store the outgoing server for a mailbox.
162
400
  *
@@ -172,28 +410,30 @@ export function toPublic(account) {
172
410
  * @returns {Promise<object|null>}
173
411
  */
174
412
  export async function setSmtp(userId, accountId, smtp) {
175
- const all = await readAll();
176
- const index = all.findIndex(a => a.id === accountId && a.userId === userId);
177
- if (index === -1) return null;
413
+ return withStoreLock(async () => {
414
+ const all = await readAll();
415
+ const index = all.findIndex(a => a.id === accountId && a.userId === userId);
416
+ if (index === -1) return null;
178
417
 
179
- const existing = all[index].smtp ?? {};
180
- all[index] = {
181
- ...all[index],
182
- smtp: {
183
- host: smtp.host,
184
- port: smtp.port,
185
- secure: smtp.secure,
186
- username: smtp.username,
187
- passwordSealed: smtp.password
188
- ? seal(smtp.password)
189
- : (existing.passwordSealed ?? null),
190
- from: smtp.from,
191
- sentFolder: smtp.sentFolder ?? null
192
- },
193
- updatedAt: new Date().toISOString()
194
- };
195
- await writeAll(all);
196
- return all[index];
418
+ const existing = all[index].smtp ?? {};
419
+ all[index] = {
420
+ ...all[index],
421
+ smtp: {
422
+ host: smtp.host,
423
+ port: smtp.port,
424
+ secure: smtp.secure,
425
+ username: smtp.username,
426
+ passwordSealed: smtp.password
427
+ ? seal(smtp.password)
428
+ : (existing.passwordSealed ?? null),
429
+ from: smtp.from,
430
+ sentFolder: smtp.sentFolder ?? null
431
+ },
432
+ updatedAt: new Date().toISOString()
433
+ };
434
+ await writeAll(all);
435
+ return all[index];
436
+ });
197
437
  }
198
438
 
199
439
  /**
@@ -214,9 +454,33 @@ export function toSmtpConnection(account, identityId = null) {
214
454
  const smtp = account?.smtp;
215
455
  if (!smtp?.host) return null;
216
456
 
217
- const pass = smtp.passwordSealed
218
- ? open(smtp.passwordSealed)
219
- : open(account.passwordSealed);
457
+ let auth;
458
+ if (isOAuth(account)) {
459
+ const {accessToken, expiresAt} = storedAccess(account);
460
+ const renew = refresher(account);
461
+ auth = {
462
+ type: 'OAuth2',
463
+ user: smtp.username || account.username,
464
+ accessToken,
465
+ // 0 = unknown: nodemailer then asks provisionCallback straight away.
466
+ ...(expiresAt ? {expires: expiresAt - 60_000} : {}),
467
+ // nodemailer calls this when the token has expired (renew=false)
468
+ // and once more after a refused AUTH XOAUTH2 (renew=true) - which
469
+ // is exactly "refresh on an auth failure, once".
470
+ provisionCallback: (user, renewNow, callback) => {
471
+ // Both branches guarded, and a final catch: a throw inside
472
+ // nodemailer's callback must never become an unhandled rejection.
473
+ renew(Boolean(renewNow))
474
+ .then(r => callback(null, r.accessToken, r.expiresAt), err => callback(err))
475
+ .catch(() => {});
476
+ }
477
+ };
478
+ } else {
479
+ const pass = smtp.passwordSealed
480
+ ? open(smtp.passwordSealed)
481
+ : open(account.passwordSealed);
482
+ auth = {user: smtp.username || account.username, pass};
483
+ }
220
484
 
221
485
  const identity = findIdentity(normaliseIdentityList(account), identityId);
222
486
  const mailboxAddress = smtp.from || account.username;
@@ -226,7 +490,7 @@ export function toSmtpConnection(account, identityId = null) {
226
490
  host: smtp.host,
227
491
  port: smtp.port,
228
492
  secure: smtp.secure !== false,
229
- auth: {user: smtp.username || account.username, pass}
493
+ auth
230
494
  },
231
495
  // Still the bare address, as it always was. The display name travels
232
496
  // beside it rather than folded into it, because the two go to
@@ -302,23 +566,25 @@ export function identitiesToPublic(account) {
302
566
  * @returns {Promise<object|null>}
303
567
  */
304
568
  export async function setIdentities(userId, accountId, identities) {
305
- const all = await readAll();
306
- const index = all.findIndex(a => a.id === accountId && a.userId === userId);
307
- if (index === -1) return null;
569
+ return withStoreLock(async () => {
570
+ const all = await readAll();
571
+ const index = all.findIndex(a => a.id === accountId && a.userId === userId);
572
+ if (index === -1) return null;
308
573
 
309
- const cleaned = (Array.isArray(identities) ? identities : []).map(entry => ({
310
- ...entry,
311
- signatureHtml: sanitiseSignature(entry?.signatureHtml ?? '')
312
- }));
574
+ const cleaned = (Array.isArray(identities) ? identities : []).map(entry => ({
575
+ ...entry,
576
+ signatureHtml: sanitiseSignature(entry?.signatureHtml ?? '')
577
+ }));
313
578
 
314
- const {identity, ...rest} = all[index];
315
- all[index] = {
316
- ...rest,
317
- identities: normaliseIdentityList({identities: cleaned}),
318
- updatedAt: new Date().toISOString()
319
- };
320
- await writeAll(all);
321
- return all[index];
579
+ const {identity, ...rest} = all[index];
580
+ all[index] = {
581
+ ...rest,
582
+ identities: normaliseIdentityList({identities: cleaned}),
583
+ updatedAt: new Date().toISOString()
584
+ };
585
+ await writeAll(all);
586
+ return all[index];
587
+ });
322
588
  }
323
589
 
324
590
  /**
@@ -343,22 +609,24 @@ export function templatesToPublic(account) {
343
609
  * @returns {Promise<object|null>}
344
610
  */
345
611
  export async function setTemplates(userId, accountId, templates) {
346
- const all = await readAll();
347
- const index = all.findIndex(a => a.id === accountId && a.userId === userId);
348
- if (index === -1) return null;
349
-
350
- const cleaned = (Array.isArray(templates) ? templates : []).map(entry => ({
351
- ...entry,
352
- bodyHtml: sanitiseSignature(entry?.bodyHtml ?? '')
353
- }));
354
-
355
- all[index] = {
356
- ...all[index],
357
- templates: normaliseTemplates({templates: cleaned}),
358
- updatedAt: new Date().toISOString()
359
- };
360
- await writeAll(all);
361
- return all[index];
612
+ return withStoreLock(async () => {
613
+ const all = await readAll();
614
+ const index = all.findIndex(a => a.id === accountId && a.userId === userId);
615
+ if (index === -1) return null;
616
+
617
+ const cleaned = (Array.isArray(templates) ? templates : []).map(entry => ({
618
+ ...entry,
619
+ bodyHtml: sanitiseSignature(entry?.bodyHtml ?? '')
620
+ }));
621
+
622
+ all[index] = {
623
+ ...all[index],
624
+ templates: normaliseTemplates({templates: cleaned}),
625
+ updatedAt: new Date().toISOString()
626
+ };
627
+ await writeAll(all);
628
+ return all[index];
629
+ });
362
630
  }
363
631
 
364
632
  /**
@@ -386,18 +654,20 @@ export function rulesToPublic(account) {
386
654
  * @returns {Promise<object|null>}
387
655
  */
388
656
  export async function setRules(userId, accountId, rules) {
389
- const all = await readAll();
390
- const index = all.findIndex(a => a.id === accountId && a.userId === userId);
391
- if (index === -1) return null;
392
-
393
- all[index] = {
394
- ...all[index],
395
- rules: normaliseRules({rules}),
396
- rulesFrom: all[index].rulesFrom ?? new Date().toISOString(),
397
- updatedAt: new Date().toISOString()
398
- };
399
- await writeAll(all);
400
- return all[index];
657
+ return withStoreLock(async () => {
658
+ const all = await readAll();
659
+ const index = all.findIndex(a => a.id === accountId && a.userId === userId);
660
+ if (index === -1) return null;
661
+
662
+ all[index] = {
663
+ ...all[index],
664
+ rules: normaliseRules({rules}),
665
+ rulesFrom: all[index].rulesFrom ?? new Date().toISOString(),
666
+ updatedAt: new Date().toISOString()
667
+ };
668
+ await writeAll(all);
669
+ return all[index];
670
+ });
401
671
  }
402
672
 
403
673
  /**
@@ -434,23 +704,25 @@ export function undoSendSeconds(account) {
434
704
  * @returns {Promise<object|null>}
435
705
  */
436
706
  export async function setSendingOptions(userId, accountId, options) {
437
- const all = await readAll();
438
- const index = all.findIndex(a => a.id === accountId && a.userId === userId);
439
- if (index === -1) return null;
440
-
441
- const folders = Array.isArray(options?.ruleFolders)
442
- ? [...new Set(options.ruleFolders.filter(f => typeof f === 'string' && f.trim()))]
443
- : null;
444
-
445
- all[index] = {
446
- ...all[index],
447
- undoSendSeconds: normaliseUndoSeconds(options?.undoSendSeconds),
448
- snoozeFolder: String(options?.snoozeFolder ?? '').trim() || null,
449
- ...(folders ? {ruleFolders: folders} : {}),
450
- updatedAt: new Date().toISOString()
451
- };
452
- await writeAll(all);
453
- return all[index];
707
+ return withStoreLock(async () => {
708
+ const all = await readAll();
709
+ const index = all.findIndex(a => a.id === accountId && a.userId === userId);
710
+ if (index === -1) return null;
711
+
712
+ const folders = Array.isArray(options?.ruleFolders)
713
+ ? [...new Set(options.ruleFolders.filter(f => typeof f === 'string' && f.trim()))]
714
+ : null;
715
+
716
+ all[index] = {
717
+ ...all[index],
718
+ undoSendSeconds: normaliseUndoSeconds(options?.undoSendSeconds),
719
+ snoozeFolder: String(options?.snoozeFolder ?? '').trim() || null,
720
+ ...(folders ? {ruleFolders: folders} : {}),
721
+ updatedAt: new Date().toISOString()
722
+ };
723
+ await writeAll(all);
724
+ return all[index];
725
+ });
454
726
  }
455
727
 
456
728
  /**
@@ -499,22 +771,24 @@ export function vacationToPublic(account) {
499
771
  * @returns {Promise<object|null>}
500
772
  */
501
773
  export async function setVacation(userId, accountId, vacation) {
502
- const all = await readAll();
503
- const index = all.findIndex(a => a.id === accountId && a.userId === userId);
504
- if (index === -1) return null;
505
-
506
- all[index] = {
507
- ...all[index],
508
- vacation: normaliseVacation({
509
- vacation: {
510
- ...vacation,
511
- bodyHtml: sanitiseSignature(vacation?.bodyHtml ?? '')
512
- }
513
- }),
514
- updatedAt: new Date().toISOString()
515
- };
516
- await writeAll(all);
517
- return all[index];
774
+ return withStoreLock(async () => {
775
+ const all = await readAll();
776
+ const index = all.findIndex(a => a.id === accountId && a.userId === userId);
777
+ if (index === -1) return null;
778
+
779
+ all[index] = {
780
+ ...all[index],
781
+ vacation: normaliseVacation({
782
+ vacation: {
783
+ ...vacation,
784
+ bodyHtml: sanitiseSignature(vacation?.bodyHtml ?? '')
785
+ }
786
+ }),
787
+ updatedAt: new Date().toISOString()
788
+ };
789
+ await writeAll(all);
790
+ return all[index];
791
+ });
518
792
  }
519
793
 
520
794
  /** The folder snoozed mail waits in, for a mailbox that has not chosen one. */
@@ -545,25 +819,27 @@ export function snoozeFolder(account) {
545
819
  * @returns {Promise<object|null>}
546
820
  */
547
821
  export async function setIdentity(userId, accountId, identity) {
548
- const all = await readAll();
549
- const index = all.findIndex(a => a.id === accountId && a.userId === userId);
550
- if (index === -1) return null;
551
-
552
- all[index] = {
553
- ...all[index],
554
- // Sanitised HERE, not in the route that happens to be the only caller
555
- // today. This markup is handed back to an editor that sets it as
556
- // innerHTML and mailed to third parties, so the guarantee has to hold
557
- // for the next caller too - an import, a fleet tool, a second route -
558
- // none of which would think to reach for the sanitiser themselves.
559
- identity: normaliseIdentity({
560
- ...identity,
561
- signatureHtml: sanitiseSignature(identity?.signatureHtml ?? '')
562
- }),
563
- updatedAt: new Date().toISOString()
564
- };
565
- await writeAll(all);
566
- return all[index];
822
+ return withStoreLock(async () => {
823
+ const all = await readAll();
824
+ const index = all.findIndex(a => a.id === accountId && a.userId === userId);
825
+ if (index === -1) return null;
826
+
827
+ all[index] = {
828
+ ...all[index],
829
+ // Sanitised HERE, not in the route that happens to be the only caller
830
+ // today. This markup is handed back to an editor that sets it as
831
+ // innerHTML and mailed to third parties, so the guarantee has to hold
832
+ // for the next caller too - an import, a fleet tool, a second route -
833
+ // none of which would think to reach for the sanitiser themselves.
834
+ identity: normaliseIdentity({
835
+ ...identity,
836
+ signatureHtml: sanitiseSignature(identity?.signatureHtml ?? '')
837
+ }),
838
+ updatedAt: new Date().toISOString()
839
+ };
840
+ await writeAll(all);
841
+ return all[index];
842
+ });
567
843
  }
568
844
 
569
845
  /**
@@ -584,19 +860,21 @@ export async function setIdentity(userId, accountId, identity) {
584
860
  * @returns {Promise<object|null>}
585
861
  */
586
862
  export async function trustImageSender(userId, accountId, address) {
587
- const clean = String(address ?? '').trim().toLowerCase();
588
- if (!clean) return null;
863
+ return withStoreLock(async () => {
864
+ const clean = String(address ?? '').trim().toLowerCase();
865
+ if (!clean) return null;
589
866
 
590
- const all = await readAll();
591
- const index = all.findIndex(a => a.id === accountId && a.userId === userId);
592
- if (index === -1) return null;
867
+ const all = await readAll();
868
+ const index = all.findIndex(a => a.id === accountId && a.userId === userId);
869
+ if (index === -1) return null;
593
870
 
594
- const current = Array.isArray(all[index].imageSenders) ? all[index].imageSenders : [];
595
- if (current.includes(clean)) return all[index];
871
+ const current = Array.isArray(all[index].imageSenders) ? all[index].imageSenders : [];
872
+ if (current.includes(clean)) return all[index];
596
873
 
597
- all[index] = {...all[index], imageSenders: [...current, clean], updatedAt: new Date().toISOString()};
598
- await writeAll(all);
599
- return all[index];
874
+ all[index] = {...all[index], imageSenders: [...current, clean], updatedAt: new Date().toISOString()};
875
+ await writeAll(all);
876
+ return all[index];
877
+ });
600
878
  }
601
879
 
602
880
  /**
@@ -608,19 +886,21 @@ export async function trustImageSender(userId, accountId, address) {
608
886
  * @returns {Promise<object|null>}
609
887
  */
610
888
  export async function untrustImageSender(userId, accountId, address) {
611
- const clean = String(address ?? '').trim().toLowerCase();
612
- const all = await readAll();
613
- const index = all.findIndex(a => a.id === accountId && a.userId === userId);
614
- if (index === -1) return null;
615
-
616
- const current = Array.isArray(all[index].imageSenders) ? all[index].imageSenders : [];
617
- all[index] = {
618
- ...all[index],
619
- imageSenders: current.filter(a => a !== clean),
620
- updatedAt: new Date().toISOString()
621
- };
622
- await writeAll(all);
623
- return all[index];
889
+ return withStoreLock(async () => {
890
+ const clean = String(address ?? '').trim().toLowerCase();
891
+ const all = await readAll();
892
+ const index = all.findIndex(a => a.id === accountId && a.userId === userId);
893
+ if (index === -1) return null;
894
+
895
+ const current = Array.isArray(all[index].imageSenders) ? all[index].imageSenders : [];
896
+ all[index] = {
897
+ ...all[index],
898
+ imageSenders: current.filter(a => a !== clean),
899
+ updatedAt: new Date().toISOString()
900
+ };
901
+ await writeAll(all);
902
+ return all[index];
903
+ });
624
904
  }
625
905
 
626
906
  /**
@@ -636,13 +916,15 @@ export async function untrustImageSender(userId, accountId, address) {
636
916
  * @returns {Promise<object|null>}
637
917
  */
638
918
  export async function setRememberImageSenders(userId, accountId, enabled) {
639
- const all = await readAll();
640
- const index = all.findIndex(a => a.id === accountId && a.userId === userId);
641
- if (index === -1) return null;
919
+ return withStoreLock(async () => {
920
+ const all = await readAll();
921
+ const index = all.findIndex(a => a.id === accountId && a.userId === userId);
922
+ if (index === -1) return null;
642
923
 
643
- all[index] = {...all[index], rememberImageSenders: !!enabled, updatedAt: new Date().toISOString()};
644
- await writeAll(all);
645
- return all[index];
924
+ all[index] = {...all[index], rememberImageSenders: !!enabled, updatedAt: new Date().toISOString()};
925
+ await writeAll(all);
926
+ return all[index];
927
+ });
646
928
  }
647
929
 
648
930
  /**
@@ -681,17 +963,19 @@ export const POLL_INTERVALS = [3, 5, 10, 15];
681
963
  * @returns {Promise<{ok: true, account: object} | {ok: false, error: string}>}
682
964
  */
683
965
  export async function setPollInterval(userId, accountId, minutes) {
684
- if (minutes !== null && !POLL_INTERVALS.includes(minutes)) {
685
- return {ok: false, error: `Poll interval must be one of ${POLL_INTERVALS.join(', ')} minutes.`};
686
- }
966
+ return withStoreLock(async () => {
967
+ if (minutes !== null && !POLL_INTERVALS.includes(minutes)) {
968
+ return {ok: false, error: `Poll interval must be one of ${POLL_INTERVALS.join(', ')} minutes.`};
969
+ }
687
970
 
688
- const all = await readAll();
689
- const index = all.findIndex(a => a.id === accountId && a.userId === userId);
690
- if (index === -1) return {ok: false, error: 'That mailbox does not exist.'};
971
+ const all = await readAll();
972
+ const index = all.findIndex(a => a.id === accountId && a.userId === userId);
973
+ if (index === -1) return {ok: false, error: 'That mailbox does not exist.'};
691
974
 
692
- all[index] = {...all[index], pollIntervalMinutes: minutes, updatedAt: new Date().toISOString()};
693
- await writeAll(all);
694
- return {ok: true, account: all[index]};
975
+ all[index] = {...all[index], pollIntervalMinutes: minutes, updatedAt: new Date().toISOString()};
976
+ await writeAll(all);
977
+ return {ok: true, account: all[index]};
978
+ });
695
979
  }
696
980
 
697
981
  /**
@@ -719,17 +1003,19 @@ export async function listAllAccounts() {
719
1003
  * @returns {Promise<object|null>} the stored record, or null if not theirs
720
1004
  */
721
1005
  export async function setSyncFolders(userId, accountId, folders) {
722
- const all = await readAll();
723
- const index = all.findIndex(a => a.id === accountId && a.userId === userId);
724
- if (index === -1) return null;
1006
+ return withStoreLock(async () => {
1007
+ const all = await readAll();
1008
+ const index = all.findIndex(a => a.id === accountId && a.userId === userId);
1009
+ if (index === -1) return null;
725
1010
 
726
- const value = folders === null
727
- ? null
728
- : [...new Set(folders.filter(f => typeof f === 'string' && f.trim()))];
1011
+ const value = folders === null
1012
+ ? null
1013
+ : [...new Set(folders.filter(f => typeof f === 'string' && f.trim()))];
729
1014
 
730
- all[index] = {...all[index], syncFolders: value, updatedAt: new Date().toISOString()};
731
- await writeAll(all);
732
- return all[index];
1015
+ all[index] = {...all[index], syncFolders: value, updatedAt: new Date().toISOString()};
1016
+ await writeAll(all);
1017
+ return all[index];
1018
+ });
733
1019
  }
734
1020
 
735
1021
  /**
@@ -747,6 +1033,15 @@ export function validate(input, existing) {
747
1033
  const secure = input.secure !== false;
748
1034
  const password = typeof input.password === 'string' ? input.password : '';
749
1035
 
1036
+ // A signed-in mailbox keeps its provider's servers and address: only the
1037
+ // name is the person's to change here. A sign-in is changed by Reconnect.
1038
+ if (isOAuth(existing)) {
1039
+ return {ok: true, value: {
1040
+ label: label || existing.label, host: existing.host, port: existing.port,
1041
+ secure: existing.secure, username: existing.username, password: '', oauth: true
1042
+ }};
1043
+ }
1044
+
750
1045
  if (!host) return {ok: false, error: 'A mail server hostname is required.'};
751
1046
  if (!username) return {ok: false, error: 'A username is required.'};
752
1047
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
@@ -798,18 +1093,20 @@ export async function listAccounts(userId) {
798
1093
  * @returns {Promise<object|null>} the stored record, or null if not theirs
799
1094
  */
800
1095
  export async function setDefaultAccount(userId, accountId, isDefault) {
801
- const all = await readAll();
802
- const index = all.findIndex(a => a.id === accountId && a.userId === userId);
803
- if (index === -1) return null;
804
-
805
- const now = new Date().toISOString();
806
- for (let i = 0; i < all.length; i++) {
807
- if (all[i].userId !== userId) continue;
808
- const want = i === index ? !!isDefault : (isDefault ? false : all[i].isDefault === true);
809
- if ((all[i].isDefault === true) !== want) all[i] = {...all[i], isDefault: want, updatedAt: now};
810
- }
811
- await writeAll(all);
812
- return all[index];
1096
+ return withStoreLock(async () => {
1097
+ const all = await readAll();
1098
+ const index = all.findIndex(a => a.id === accountId && a.userId === userId);
1099
+ if (index === -1) return null;
1100
+
1101
+ const now = new Date().toISOString();
1102
+ for (let i = 0; i < all.length; i++) {
1103
+ if (all[i].userId !== userId) continue;
1104
+ const want = i === index ? !!isDefault : (isDefault ? false : all[i].isDefault === true);
1105
+ if ((all[i].isDefault === true) !== want) all[i] = {...all[i], isDefault: want, updatedAt: now};
1106
+ }
1107
+ await writeAll(all);
1108
+ return all[index];
1109
+ });
813
1110
  }
814
1111
 
815
1112
  /**
@@ -836,42 +1133,49 @@ export async function getAccount(userId, accountId) {
836
1133
  * @returns {Promise<object|null>} the stored record, or null if not theirs
837
1134
  */
838
1135
  export async function saveAccount(userId, accountId, fields) {
839
- const all = await readAll();
840
- const now = new Date().toISOString();
1136
+ return withStoreLock(async () => {
1137
+ const all = await readAll();
1138
+ const now = new Date().toISOString();
1139
+
1140
+ if (accountId) {
1141
+ const index = all.findIndex(a => a.id === accountId && a.userId === userId);
1142
+ if (index === -1) return null;
1143
+ const existing = all[index];
1144
+ if (isOAuth(existing)) {
1145
+ all[index] = {...existing, label: fields.label, updatedAt: now};
1146
+ await writeAll(all);
1147
+ return all[index];
1148
+ }
1149
+ all[index] = {
1150
+ ...existing,
1151
+ label: fields.label,
1152
+ host: fields.host,
1153
+ port: fields.port,
1154
+ secure: fields.secure,
1155
+ username: fields.username,
1156
+ passwordSealed: fields.password ? seal(fields.password) : existing.passwordSealed,
1157
+ updatedAt: now
1158
+ };
1159
+ await writeAll(all);
1160
+ return all[index];
1161
+ }
841
1162
 
842
- if (accountId) {
843
- const index = all.findIndex(a => a.id === accountId && a.userId === userId);
844
- if (index === -1) return null;
845
- const existing = all[index];
846
- all[index] = {
847
- ...existing,
1163
+ const record = {
1164
+ id: randomUUID(),
1165
+ userId,
848
1166
  label: fields.label,
849
1167
  host: fields.host,
850
1168
  port: fields.port,
851
1169
  secure: fields.secure,
852
1170
  username: fields.username,
853
- passwordSealed: fields.password ? seal(fields.password) : existing.passwordSealed,
1171
+ passwordSealed: seal(fields.password),
1172
+ createdAt: now,
854
1173
  updatedAt: now
855
1174
  };
1175
+ all.push(record);
856
1176
  await writeAll(all);
857
- return all[index];
858
- }
859
-
860
- const record = {
861
- id: randomUUID(),
862
- userId,
863
- label: fields.label,
864
- host: fields.host,
865
- port: fields.port,
866
- secure: fields.secure,
867
- username: fields.username,
868
- passwordSealed: seal(fields.password),
869
- createdAt: now,
870
- updatedAt: now
871
- };
872
- all.push(record);
873
- await writeAll(all);
874
- return record;
1177
+ return record;
1178
+ });
875
1179
  }
876
1180
 
877
1181
  /**
@@ -882,11 +1186,13 @@ export async function saveAccount(userId, accountId, fields) {
882
1186
  * @returns {Promise<boolean>} true when something was removed
883
1187
  */
884
1188
  export async function deleteAccount(userId, accountId) {
885
- const all = await readAll();
886
- const next = all.filter(a => !(a.id === accountId && a.userId === userId));
887
- if (next.length === all.length) return false;
888
- await writeAll(next);
889
- return true;
1189
+ return withStoreLock(async () => {
1190
+ const all = await readAll();
1191
+ const next = all.filter(a => !(a.id === accountId && a.userId === userId));
1192
+ if (next.length === all.length) return false;
1193
+ await writeAll(next);
1194
+ return true;
1195
+ });
890
1196
  }
891
1197
 
892
1198
  /**
@@ -899,11 +1205,13 @@ export async function deleteAccount(userId, accountId) {
899
1205
  * @returns {Promise<number>} how many were removed
900
1206
  */
901
1207
  export async function deleteAccountsForUser(userId) {
902
- const all = await readAll();
903
- const next = all.filter(a => a.userId !== userId);
904
- const removed = all.length - next.length;
905
- if (removed) await writeAll(next);
906
- return removed;
1208
+ return withStoreLock(async () => {
1209
+ const all = await readAll();
1210
+ const next = all.filter(a => a.userId !== userId);
1211
+ const removed = all.length - next.length;
1212
+ if (removed) await writeAll(next);
1213
+ return removed;
1214
+ });
907
1215
  }
908
1216
 
909
1217
  /**
@@ -919,12 +1227,14 @@ export async function deleteAccountsForUser(userId) {
919
1227
  * @returns {Promise<number>} how many were removed
920
1228
  */
921
1229
  export async function pruneOrphans(existingUserIds) {
922
- const live = existingUserIds instanceof Set ? existingUserIds : new Set(existingUserIds);
923
- const all = await readAll();
924
- const next = all.filter(a => live.has(a.userId));
925
- const removed = all.length - next.length;
926
- if (removed) await writeAll(next);
927
- return removed;
1230
+ return withStoreLock(async () => {
1231
+ const live = existingUserIds instanceof Set ? existingUserIds : new Set(existingUserIds);
1232
+ const all = await readAll();
1233
+ const next = all.filter(a => live.has(a.userId));
1234
+ const removed = all.length - next.length;
1235
+ if (removed) await writeAll(next);
1236
+ return removed;
1237
+ });
928
1238
  }
929
1239
 
930
1240
  /**
@@ -937,6 +1247,19 @@ export async function pruneOrphans(existingUserIds) {
937
1247
  * @returns {{host: string, port: number, secure: boolean, auth: {user: string, pass: string}}}
938
1248
  */
939
1249
  export function toConnection(account) {
1250
+ if (isOAuth(account)) {
1251
+ const {accessToken, expiresAt} = storedAccess(account);
1252
+ return {
1253
+ host: account.host,
1254
+ port: account.port,
1255
+ secure: account.secure,
1256
+ auth: {user: account.username, accessToken},
1257
+ // Read by the pool (imapPool.js): renew under a minute before
1258
+ // expiry, and once after a refused login. ImapFlow never sees them.
1259
+ expiresAt,
1260
+ refresh: refresher(account)
1261
+ };
1262
+ }
940
1263
  return {
941
1264
  host: account.host,
942
1265
  port: account.port,