domma-cms 1.6.37 → 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.
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/package.json +2 -2
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/oauth-panel.js +303 -0
- package/plugins/_lib/admin/mail/reader-view.js +126 -13
- package/plugins/_lib/mail/accounts.js +548 -225
- package/plugins/_lib/mail/imapPool.js +63 -13
- package/plugins/_lib/mail/oauth.js +453 -0
- package/plugins/_lib/mail/oauthRoutes.js +294 -0
- package/plugins/_lib/mail/readRoutes.js +23 -2
- package/plugins/_lib/mail/scheduler.js +30 -6
- package/plugins/blog/CLAUDE.md +11 -1
- package/plugins/blog/lib/cache.js +43 -0
- package/plugins/blog/lib/page.js +28 -9
- package/plugins/blog/plugin.js +14 -13
- package/plugins/blog/plugin.json +1 -1
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/cache.test.js +65 -0
- package/plugins/booking/CLAUDE.md +14 -2
- package/plugins/booking/admin/lib/diary-layout.js +18 -0
- package/plugins/booking/admin/views/diary.js +153 -152
- package/plugins/booking/admin/views/people.js +64 -66
- package/plugins/booking/admin/views/settings.js +31 -32
- package/plugins/booking/plugin.json +1 -1
- package/plugins/booking/tests/admin-diary.test.js +9 -1
- package/plugins/feedback/CLAUDE.md +62 -0
- package/plugins/feedback/admin/css/index.css +16 -1
- package/plugins/feedback/admin/lib/kit.js +106 -0
- package/plugins/feedback/admin/lib/settings.js +107 -0
- package/plugins/feedback/admin/views/feedback.js +4 -1
- package/plugins/feedback/admin/views/inbox.js +11 -2
- package/plugins/feedback/admin/views/send.js +74 -7
- package/plugins/feedback/docs/guide.md +9 -9
- package/plugins/feedback/lib/attachments.js +133 -0
- package/plugins/feedback/lib/cache.js +43 -0
- package/plugins/feedback/lib/receiver.js +36 -6
- package/plugins/feedback/lib/sender.js +89 -6
- package/plugins/feedback/lib/shape.js +111 -6
- package/plugins/feedback/plugin.js +173 -17
- package/plugins/feedback/plugin.json +2 -2
- package/plugins/feedback/tests/api.test.js +125 -3
- package/plugins/feedback/tests/settings.test.js +59 -0
- package/plugins/feedback/tests/shape.test.js +87 -1
- package/plugins/free-tier.lock.json +74 -62
- package/plugins/mail-reader/CLAUDE.md +15 -4
- package/plugins/mail-reader/plugin.json +1 -1
- package/plugins/shopping-cart/CLAUDE.md +35 -2
- package/plugins/shopping-cart/admin/css/index.css +3 -0
- package/plugins/shopping-cart/admin/views/orders.js +75 -23
- package/plugins/shopping-cart/admin/views/shop.js +1 -1
- package/plugins/shopping-cart/docs/guide.md +7 -3
- package/plugins/shopping-cart/lib/payments.js +15 -2
- package/plugins/shopping-cart/lib/refunds.js +51 -0
- package/plugins/shopping-cart/lib/sales.js +15 -8
- package/plugins/shopping-cart/lib/shop.js +38 -3
- package/plugins/shopping-cart/plugin.js +47 -19
- package/plugins/shopping-cart/plugin.json +2 -2
- package/plugins/shopping-cart/tests/api.test.js +52 -0
- package/plugins/shopping-cart/tests/commission.test.js +1 -1
- package/plugins/shopping-cart/tests/pure.test.js +16 -0
- package/plugins/shopping-cart/tests/refunds.test.js +42 -0
- package/plugins/support/CLAUDE.md +58 -0
- package/plugins/support/admin/css/index.css +16 -1
- package/plugins/support/admin/lib/kit.js +192 -8
- package/plugins/support/admin/lib/settings.js +123 -0
- package/plugins/support/admin/views/desk.js +55 -12
- package/plugins/support/admin/views/inbox.js +17 -4
- package/plugins/support/admin/views/support.js +4 -1
- package/plugins/support/docs/guide.md +7 -9
- package/plugins/support/lib/attachments.js +133 -0
- package/plugins/support/lib/cache.js +43 -0
- package/plugins/support/lib/receiver.js +45 -10
- package/plugins/support/lib/sender.js +96 -10
- package/plugins/support/lib/shape.js +153 -12
- package/plugins/support/plugin.js +159 -29
- package/plugins/support/plugin.json +2 -2
- package/plugins/support/tests/api.test.js +125 -3
- package/plugins/support/tests/settings.test.js +68 -0
- package/plugins/support/tests/shape.test.js +76 -2
- package/server/routes/outreach-public.js +2 -2
- package/server/services/request-log.js +3 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
574
|
+
const cleaned = (Array.isArray(identities) ? identities : []).map(entry => ({
|
|
575
|
+
...entry,
|
|
576
|
+
signatureHtml: sanitiseSignature(entry?.signatureHtml ?? '')
|
|
577
|
+
}));
|
|
313
578
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
vacation: {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
588
|
-
|
|
863
|
+
return withStoreLock(async () => {
|
|
864
|
+
const clean = String(address ?? '').trim().toLowerCase();
|
|
865
|
+
if (!clean) return null;
|
|
589
866
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
595
|
-
|
|
871
|
+
const current = Array.isArray(all[index].imageSenders) ? all[index].imageSenders : [];
|
|
872
|
+
if (current.includes(clean)) return all[index];
|
|
596
873
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
685
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
1011
|
+
const value = folders === null
|
|
1012
|
+
? null
|
|
1013
|
+
: [...new Set(folders.filter(f => typeof f === 'string' && f.trim()))];
|
|
729
1014
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
-
|
|
840
|
-
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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,
|