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.
Files changed (82) hide show
  1. package/admin/dist/domma/domma-tools.css +3 -3
  2. package/admin/dist/domma/domma-tools.min.js +3 -3
  3. package/package.json +2 -2
  4. package/plugins/_lib/admin/mail/mail.css +1 -1
  5. package/plugins/_lib/admin/mail/oauth-panel.js +303 -0
  6. package/plugins/_lib/admin/mail/reader-view.js +126 -13
  7. package/plugins/_lib/mail/accounts.js +548 -225
  8. package/plugins/_lib/mail/imapPool.js +63 -13
  9. package/plugins/_lib/mail/oauth.js +453 -0
  10. package/plugins/_lib/mail/oauthRoutes.js +294 -0
  11. package/plugins/_lib/mail/readRoutes.js +23 -2
  12. package/plugins/_lib/mail/scheduler.js +30 -6
  13. package/plugins/blog/CLAUDE.md +11 -1
  14. package/plugins/blog/lib/cache.js +43 -0
  15. package/plugins/blog/lib/page.js +28 -9
  16. package/plugins/blog/plugin.js +14 -13
  17. package/plugins/blog/plugin.json +1 -1
  18. package/plugins/blog/plugin.public.js +1 -1
  19. package/plugins/blog/tests/cache.test.js +65 -0
  20. package/plugins/booking/CLAUDE.md +14 -2
  21. package/plugins/booking/admin/lib/diary-layout.js +18 -0
  22. package/plugins/booking/admin/views/diary.js +153 -152
  23. package/plugins/booking/admin/views/people.js +64 -66
  24. package/plugins/booking/admin/views/settings.js +31 -32
  25. package/plugins/booking/plugin.json +1 -1
  26. package/plugins/booking/tests/admin-diary.test.js +9 -1
  27. package/plugins/feedback/CLAUDE.md +62 -0
  28. package/plugins/feedback/admin/css/index.css +16 -1
  29. package/plugins/feedback/admin/lib/kit.js +106 -0
  30. package/plugins/feedback/admin/lib/settings.js +107 -0
  31. package/plugins/feedback/admin/views/feedback.js +4 -1
  32. package/plugins/feedback/admin/views/inbox.js +11 -2
  33. package/plugins/feedback/admin/views/send.js +74 -7
  34. package/plugins/feedback/docs/guide.md +9 -9
  35. package/plugins/feedback/lib/attachments.js +133 -0
  36. package/plugins/feedback/lib/cache.js +43 -0
  37. package/plugins/feedback/lib/receiver.js +36 -6
  38. package/plugins/feedback/lib/sender.js +89 -6
  39. package/plugins/feedback/lib/shape.js +111 -6
  40. package/plugins/feedback/plugin.js +173 -17
  41. package/plugins/feedback/plugin.json +2 -2
  42. package/plugins/feedback/tests/api.test.js +125 -3
  43. package/plugins/feedback/tests/settings.test.js +59 -0
  44. package/plugins/feedback/tests/shape.test.js +87 -1
  45. package/plugins/free-tier.lock.json +74 -62
  46. package/plugins/mail-reader/CLAUDE.md +15 -4
  47. package/plugins/mail-reader/plugin.json +1 -1
  48. package/plugins/shopping-cart/CLAUDE.md +35 -2
  49. package/plugins/shopping-cart/admin/css/index.css +3 -0
  50. package/plugins/shopping-cart/admin/views/orders.js +75 -23
  51. package/plugins/shopping-cart/admin/views/shop.js +1 -1
  52. package/plugins/shopping-cart/docs/guide.md +7 -3
  53. package/plugins/shopping-cart/lib/payments.js +15 -2
  54. package/plugins/shopping-cart/lib/refunds.js +51 -0
  55. package/plugins/shopping-cart/lib/sales.js +15 -8
  56. package/plugins/shopping-cart/lib/shop.js +38 -3
  57. package/plugins/shopping-cart/plugin.js +47 -19
  58. package/plugins/shopping-cart/plugin.json +2 -2
  59. package/plugins/shopping-cart/tests/api.test.js +52 -0
  60. package/plugins/shopping-cart/tests/commission.test.js +1 -1
  61. package/plugins/shopping-cart/tests/pure.test.js +16 -0
  62. package/plugins/shopping-cart/tests/refunds.test.js +42 -0
  63. package/plugins/support/CLAUDE.md +58 -0
  64. package/plugins/support/admin/css/index.css +16 -1
  65. package/plugins/support/admin/lib/kit.js +192 -8
  66. package/plugins/support/admin/lib/settings.js +123 -0
  67. package/plugins/support/admin/views/desk.js +55 -12
  68. package/plugins/support/admin/views/inbox.js +17 -4
  69. package/plugins/support/admin/views/support.js +4 -1
  70. package/plugins/support/docs/guide.md +7 -9
  71. package/plugins/support/lib/attachments.js +133 -0
  72. package/plugins/support/lib/cache.js +43 -0
  73. package/plugins/support/lib/receiver.js +45 -10
  74. package/plugins/support/lib/sender.js +96 -10
  75. package/plugins/support/lib/shape.js +153 -12
  76. package/plugins/support/plugin.js +159 -29
  77. package/plugins/support/plugin.json +2 -2
  78. package/plugins/support/tests/api.test.js +125 -3
  79. package/plugins/support/tests/settings.test.js +68 -0
  80. package/plugins/support/tests/shape.test.js +76 -2
  81. package/server/routes/outreach-public.js +2 -2
  82. package/server/services/request-log.js +3 -1
@@ -0,0 +1,294 @@
1
+ /**
2
+ * The OAuth2 sign-in routes for mailboxes: Connect, the provider's callback,
3
+ * Disconnect and Test. Registered by Email Pro (which feature-detects this
4
+ * file - an engine without it simply offers no sign-in); the free reader does
5
+ * not register them, but reads a signed-in mailbox all the same.
6
+ *
7
+ * Each customer registers THEIR OWN app with Microsoft or Google - the Docs
8
+ * Pro cloud-copies design. The admin form shows this site's exact redirect
9
+ * URI with a guide; the client secret goes in sealed and never comes out.
10
+ *
11
+ * The callback is the one route without a session. Its `state` is a one-time
12
+ * ticket, ten minutes long, naming the person who pressed Connect, and the
13
+ * callback refuses a browser signed in to this site as somebody else.
14
+ *
15
+ * @module _lib/mail/oauthRoutes
16
+ */
17
+ import {createHash, randomBytes, timingSafeEqual} from 'node:crypto';
18
+
19
+ import {originOf, trustedOrigin} from '../../../server/services/passwordReset.js';
20
+ import {resolveViewer} from '../../../server/services/viewerSession.js';
21
+ import * as accounts from './accounts.js';
22
+ import {MailKeyError, open, seal} from './secretbox.js';
23
+ import {verifySmtp} from './send.js';
24
+ import {
25
+ announceConnected, authorizeUrl, cleanTenant, configureOAuth, describeProviders, exchangeCode,
26
+ mailboxFor, pkcePair, PROVIDERS, revokeAccount
27
+ } from './oauth.js';
28
+
29
+ const STATE_TTL = 10 * 60 * 1000;
30
+ /** Pending sign-ins kept at once - a ceiling on what a busy (or hostile) admin can pile up. */
31
+ const MAX_PENDING = 200;
32
+ /** Pending sign-ins per person: a new one beyond this replaces their oldest. */
33
+ const MAX_PENDING_PER_USER = 5;
34
+
35
+ const sha256 = (v) => createHash('sha256').update(String(v)).digest();
36
+
37
+ /**
38
+ * The browser-binding cookie's name for one sign-in. Per state, so two
39
+ * Connects in two tabs do not overwrite each other's cookie.
40
+ *
41
+ * @param {string} state
42
+ * @returns {string}
43
+ */
44
+ export function bindCookieName(state) {
45
+ return `dm_mail_oauth_${sha256(state).toString('hex').slice(0, 16)}`;
46
+ }
47
+
48
+ /**
49
+ * One cookie's value from a raw Cookie header (no cookie plugin assumed).
50
+ *
51
+ * @param {string|undefined} header
52
+ * @param {string} name
53
+ * @returns {string}
54
+ */
55
+ export function readCookie(header, name) {
56
+ for (const part of String(header ?? '').split(';')) {
57
+ const at = part.indexOf('=');
58
+ if (at !== -1 && part.slice(0, at).trim() === name) return part.slice(at + 1).trim();
59
+ }
60
+ return '';
61
+ }
62
+
63
+ const text = (v, max = 300) => String(v ?? '').trim().slice(0, max);
64
+ // JSON inside a <script>: nothing in it can close the element early.
65
+ const js = (v) => JSON.stringify(v).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
66
+ const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
67
+
68
+ /**
69
+ * @param {object} fastify
70
+ * @param {{authenticate: Function, pool: object, pluginName: string, config?: object,
71
+ * onNeedsReconnect?: Function, onConnected?: Function, fetch?: Function}} options
72
+ */
73
+ export function registerOAuthRoutes(fastify, {authenticate, pool, pluginName, config = {}, onNeedsReconnect, onConnected, fetch}) {
74
+ configureOAuth({fetch, onNeedsReconnect: onNeedsReconnect ?? null, onConnected: onConnected ?? null});
75
+
76
+ /** state -> {userId, accountId, provider, clientId, clientSecretSealed, tenant, label, username, verifier, redirectUri, expires} */
77
+ const pending = new Map();
78
+ const userId = (request) => request.user?.id ?? request.user?.sub ?? null;
79
+
80
+ /** This site's exact return address - what the customer registers with the provider. */
81
+ async function redirectUriFor(request) {
82
+ const origin = await trustedOrigin(originOf(request)).catch(() => '') || originOf(request);
83
+ return `${origin}/api/plugins/${pluginName}/oauth/callback`;
84
+ }
85
+
86
+ function sweep() {
87
+ const now = Date.now();
88
+ for (const [k, v] of pending) if (v.expires < now) pending.delete(k);
89
+ }
90
+
91
+ /** GET /oauth/status - providers and the redirect URI, for the form's guide. */
92
+ fastify.get('/oauth/status', {preHandler: [authenticate]}, async (request) => {
93
+ const redirectUri = await redirectUriFor(request);
94
+ return {available: true, redirectUri, https: redirectUri.startsWith('https://'), providers: describeProviders()};
95
+ });
96
+
97
+ /**
98
+ * POST /oauth/start - where to send the person to sign in.
99
+ * Body: {accountId?, provider, clientId, clientSecret (blank = keep the saved one), tenant?, label?, username?}
100
+ */
101
+ fastify.post('/oauth/start', {preHandler: [authenticate], config: {rateLimit: {max: 20, timeWindow: '1 minute'}}}, async (request, reply) => {
102
+ const uid = userId(request);
103
+ if (!uid) return reply.code(401).send({error: 'Authentication required.'});
104
+ const body = request.body ?? {};
105
+ const provider = text(body.provider, 20);
106
+ if (!PROVIDERS[provider]) return reply.code(400).send({error: 'Choose Microsoft or Google.'});
107
+
108
+ const accountId = body.accountId ? text(body.accountId, 80) : null;
109
+ const existing = accountId ? await accounts.getAccount(uid, accountId) : null;
110
+ if (accountId && !existing) return reply.code(404).send({error: 'That mailbox does not exist.'});
111
+
112
+ const clientId = text(body.clientId, 200);
113
+ if (!clientId) return reply.code(400).send({error: PROVIDERS[provider].id === 'microsoft' ? 'The Application (client) ID is needed.' : 'The Client ID is needed.'});
114
+
115
+ // Blank secret = the one already saved, but only for the same app.
116
+ let clientSecret = text(body.clientSecret, 2000);
117
+ if (!clientSecret) {
118
+ const o = existing?.oauth;
119
+ if (o?.clientSecretSealed && o.provider === provider && o.clientId === clientId) {
120
+ try {
121
+ clientSecret = open(o.clientSecretSealed);
122
+ } catch (err) {
123
+ if (err instanceof MailKeyError) return reply.code(409).send({error: 'The saved client secret can no longer be read - type it again.'});
124
+ throw err;
125
+ }
126
+ }
127
+ }
128
+ if (!clientSecret) return reply.code(400).send({error: 'The client secret is needed.'});
129
+
130
+ const username = text(body.username, 254).toLowerCase();
131
+ if (username && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(username)) return reply.code(400).send({error: 'That is not an email address.'});
132
+
133
+ sweep();
134
+ // Per person first: their oldest waiting sign-in makes room. Then the
135
+ // site-wide ceiling, which only many people at once can reach.
136
+ const mine = [...pending.entries()].filter(([, v]) => v.userId === uid).sort((a, b) => a[1].expires - b[1].expires);
137
+ while (mine.length >= MAX_PENDING_PER_USER) pending.delete(mine.shift()[0]);
138
+ if (pending.size >= MAX_PENDING) return reply.code(429).send({error: 'Too many sign-ins are waiting. Try again in a few minutes.'});
139
+
140
+ const state = randomBytes(24).toString('base64url');
141
+ const {verifier, challenge} = pkcePair();
142
+ const redirectUri = await redirectUriFor(request);
143
+ const tenant = PROVIDERS[provider].needsTenant ? cleanTenant(body.tenant) : null;
144
+ // Binds the sign-in to THIS browser: the callback refuses one that does
145
+ // not bring the cookie back. Without it, an anonymous browser could
146
+ // finish a sign-in someone else started (send a colleague the
147
+ // provider link, and their mailbox lands under your account).
148
+ // HttpOnly, SameSite=Lax (sent on the provider's top-level redirect
149
+ // back), scoped to the callback path, gone in ten minutes.
150
+ const bind = randomBytes(24).toString('base64url');
151
+ const callbackPath = new URL(redirectUri).pathname;
152
+ reply.header('Set-Cookie', `${bindCookieName(state)}=${bind}; Path=${callbackPath}; Max-Age=${STATE_TTL / 1000}; HttpOnly; SameSite=Lax`
153
+ + (redirectUri.startsWith('https://') ? '; Secure' : ''));
154
+ pending.set(state, {
155
+ userId: uid, accountId, provider, clientId, clientSecretSealed: seal(clientSecret), tenant,
156
+ label: text(body.label, 80), username, verifier, redirectUri, bindHash: sha256(bind), expires: Date.now() + STATE_TTL
157
+ });
158
+ return {
159
+ url: authorizeUrl({provider, clientId, tenant, redirectUri, state, codeChallenge: challenge,
160
+ loginHint: username || existing?.username || ''}),
161
+ redirectUri
162
+ };
163
+ });
164
+
165
+ /**
166
+ * GET /oauth/callback - the provider sends the person back here. Answers a
167
+ * small page that tells the mail screen (its opener) and closes itself.
168
+ */
169
+ fastify.get('/oauth/callback', {config: {rateLimit: {max: 30, timeWindow: '1 minute'}}}, async (request, reply) => {
170
+ const q = request.query ?? {};
171
+ const key = String(q.state ?? '');
172
+ const ticket = pending.get(key);
173
+ pending.delete(key);
174
+
175
+ const page = (ok, message, origin, accountId = null) => reply
176
+ .type('text/html; charset=utf-8')
177
+ .header('Cache-Control', 'no-store')
178
+ .header('Referrer-Policy', 'no-referrer')
179
+ .header('X-Robots-Tag', 'noindex')
180
+ .send(`<!doctype html><meta charset="utf-8"><meta name="robots" content="noindex"><title>${ok ? 'Mailbox connected' : 'Not connected'}</title>`
181
+ + `<body style="font-family:system-ui,sans-serif;display:grid;place-items:center;min-height:90vh;margin:0"><main style="max-width:30rem;text-align:center;padding:1rem">`
182
+ + `<h1 style="font-size:1.3rem">${ok ? 'Mailbox connected' : 'Not connected'}</h1><p>${esc(message)}</p><p>You can close this window.</p></main>`
183
+ + `<script>try{window.opener&&window.opener.postMessage(${js({type: 'domma-mail-oauth', ok, message, accountId})},${js(origin || '')});}catch(e){}${ok ? 'setTimeout(function(){window.close();},1200);' : ''}</script>`);
184
+
185
+ if (!ticket || ticket.expires < Date.now()) {
186
+ return page(false, 'This sign-in link has expired or was already used. Go back to Email and press Connect again.', null);
187
+ }
188
+ const origin = new URL(ticket.redirectUri).origin;
189
+ // Used up either way; clear it.
190
+ const cookieName = bindCookieName(key);
191
+ reply.header('Set-Cookie', `${cookieName}=; Path=${new URL(ticket.redirectUri).pathname}; Max-Age=0; HttpOnly; SameSite=Lax`);
192
+ if (q.error) return page(false, `The sign-in was not completed: ${text(q.error_description || q.error, 200)}`, origin);
193
+
194
+ // The browser that pressed Connect, and no other.
195
+ const bound = readCookie(request.headers.cookie, cookieName);
196
+ if (!bound || !timingSafeEqual(sha256(bound), ticket.bindHash)) {
197
+ return page(false, `This sign-in has to be finished in the browser that pressed Connect, with the admin open at ${origin}. Press Connect again there.`, origin);
198
+ }
199
+
200
+ // Signed in to this site as somebody else: refuse - whoever pressed
201
+ // Connect must be the one who finishes it.
202
+ const viewer = await resolveViewer(request).catch(() => null);
203
+ if (viewer?.id && viewer.id !== ticket.userId) {
204
+ return page(false, 'This sign-in was started by someone else. Press Connect yourself.', origin);
205
+ }
206
+ if (ticket.accountId && !(await accounts.getAccount(ticket.userId, ticket.accountId))) {
207
+ return page(false, 'That mailbox no longer exists.', origin);
208
+ }
209
+
210
+ try {
211
+ const clientSecret = open(ticket.clientSecretSealed);
212
+ const got = await exchangeCode({
213
+ provider: ticket.provider, clientId: ticket.clientId, clientSecret, tenant: ticket.tenant,
214
+ code: text(q.code, 4000), redirectUri: ticket.redirectUri, codeVerifier: ticket.verifier
215
+ });
216
+ const box = mailboxFor(ticket.provider, got.claims);
217
+ // Typed wins: a Microsoft 365 shared mailbox is signed in to by a
218
+ // person but addressed as itself (XOAUTH2 user = the shared address).
219
+ const username = ticket.username || box.email;
220
+ if (!username) {
221
+ return page(false, 'The provider did not say which mailbox was signed in to. Type the email address in the form and Connect again.', origin);
222
+ }
223
+ const p = PROVIDERS[ticket.provider];
224
+ const saved = await accounts.saveOAuthAccount(ticket.userId, ticket.accountId, {
225
+ label: ticket.label || username,
226
+ username,
227
+ provider: ticket.provider,
228
+ clientId: ticket.clientId,
229
+ tenant: ticket.tenant,
230
+ clientSecret,
231
+ refreshToken: got.refreshToken,
232
+ accessToken: got.accessToken,
233
+ expiresAt: got.expiresAt,
234
+ scope: got.scope,
235
+ imap: p.imap,
236
+ smtp: box.smtp
237
+ });
238
+ if (!saved) return page(false, 'That mailbox no longer exists.', origin);
239
+ // A connection opened with the old details must not be reused.
240
+ pool.release(`${ticket.userId}:${saved.id}`);
241
+ announceConnected(saved);
242
+ return page(true, `${username} is connected through ${p.short}.`, origin, saved.id);
243
+ } catch (err) {
244
+ fastify.log.warn(`[${pluginName}] mailbox sign-in failed: ${err.message}`);
245
+ return page(false, text(err.message, 300), origin);
246
+ }
247
+ });
248
+
249
+ /** POST /oauth/:id/disconnect - hand the sign-in back (where the provider allows) and forget it. */
250
+ fastify.post('/oauth/:id/disconnect', {preHandler: [authenticate]}, async (request, reply) => {
251
+ const uid = userId(request);
252
+ if (!uid) return reply.code(401).send({error: 'Authentication required.'});
253
+ const account = await accounts.getAccount(uid, request.params.id);
254
+ if (!account) return reply.code(404).send({error: 'That mailbox does not exist.'});
255
+ if (!accounts.isOAuth(account)) return reply.code(400).send({error: 'This mailbox uses a password, not a sign-in.'});
256
+
257
+ const result = await revokeAccount(account);
258
+ pool.release(`${uid}:${account.id}`);
259
+ // A new generation: a renewal already on the wire must not write a
260
+ // working token back over the disconnect (see accounts.updateOAuth).
261
+ const saved = await accounts.updateOAuth(uid, account.id, {
262
+ status: 'disconnected',
263
+ error: 'Disconnected. Reconnect to use this mailbox again.',
264
+ refreshTokenSealed: null,
265
+ accessTokenSealed: null,
266
+ expiresAt: 0
267
+ }, {bumpGen: true});
268
+ return {account: accounts.toPublic(saved), revoked: result.revoked, revokeSupported: result.supported};
269
+ });
270
+
271
+ /** POST /oauth/:id/test - sign in to both servers with the stored sign-in. */
272
+ fastify.post('/oauth/:id/test', {preHandler: [authenticate], config: {rateLimit: {max: 10, timeWindow: '1 minute'}}}, async (request, reply) => {
273
+ const uid = userId(request);
274
+ if (!uid) return reply.code(401).send({error: 'Authentication required.'});
275
+ const account = await accounts.getAccount(uid, request.params.id);
276
+ if (!account) return reply.code(404).send({error: 'That mailbox does not exist.'});
277
+ if (!accounts.isOAuth(account)) return reply.code(400).send({error: 'This mailbox uses a password, not a sign-in.'});
278
+
279
+ let imap;
280
+ let smtp;
281
+ try {
282
+ imap = await pool.test(accounts.toConnection(account));
283
+ } catch (err) {
284
+ imap = {ok: false, error: err.message};
285
+ }
286
+ try {
287
+ const out = accounts.toSmtpConnection(account);
288
+ smtp = out ? await verifySmtp(out.transport, config.allowInsecureTLS === true) : {ok: false, error: 'No outgoing server.'};
289
+ } catch (err) {
290
+ smtp = {ok: false, error: err.message};
291
+ }
292
+ return {ok: imap.ok && smtp.ok, imap, smtp};
293
+ });
294
+ }
@@ -140,7 +140,10 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
140
140
  };
141
141
  } catch (err) {
142
142
  if (err instanceof MailKeyError) {
143
- reply.code(409).send({error: err.message, needsPassword: true, accountId: account.id});
143
+ // needsPassword stays true for a dead sign-in too: older
144
+ // screens already open settings on it, which is the right place.
145
+ reply.code(409).send({error: err.message, needsPassword: true,
146
+ ...(err.needsReconnect ? {needsReconnect: true} : {}), accountId: account.id});
144
147
  return null;
145
148
  }
146
149
  throw err;
@@ -245,7 +248,8 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
245
248
 
246
249
  const result = accounts.validate(request.body ?? {}, existing);
247
250
  if (!result.ok) return reply.code(400).send({error: result.error});
248
- const where = await guardHost(result.value.host);
251
+ // A signed-in mailbox's servers are the provider's and cannot be edited here.
252
+ const where = result.value.oauth ? {ok: true} : await guardHost(result.value.host);
249
253
  if (!where.ok) return reply.code(400).send({error: where.error});
250
254
 
251
255
  const saved = await accounts.saveAccount(uid, request.params.id, result.value);
@@ -270,8 +274,15 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
270
274
  if (!uid) return reply.code(401).send({error: 'Authentication required.'});
271
275
 
272
276
  pool.release(`${uid}:${request.params.id}`);
277
+ const existing = await accounts.getAccount(uid, request.params.id);
273
278
  const deleted = await accounts.deleteAccount(uid, request.params.id);
274
279
  if (!deleted) return reply.code(404).send({error: 'That mailbox does not exist.'});
280
+ // A signed-in mailbox hands its sign-in back where the provider allows
281
+ // it (Google). Best-effort and after the delete: forgetting it here
282
+ // must not wait on, or fail because of, the provider.
283
+ if (accounts.isOAuth?.(existing)) {
284
+ import('./oauth.js').then(m => m.revokeAccount(existing)).catch(() => {});
285
+ }
275
286
  return {deleted: true};
276
287
  });
277
288
 
@@ -292,6 +303,16 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
292
303
 
293
304
  const result = accounts.validate(body, existing);
294
305
  if (!result.ok) return reply.code(400).send({error: result.error});
306
+ // A signed-in mailbox is tested as stored: the provider's own server,
307
+ // its token renewed if need be. Nothing typed is involved.
308
+ if (result.value.oauth) {
309
+ try {
310
+ return await pool.test(accounts.toConnection(existing));
311
+ } catch (err) {
312
+ if (err instanceof MailKeyError) return {ok: false, error: err.message};
313
+ throw err;
314
+ }
315
+ }
295
316
  const where = await guardHost(result.value.host);
296
317
  if (!where.ok) return reply.code(400).send({error: where.error});
297
318
 
@@ -40,6 +40,17 @@ const MAX_ATTEMPTS = 3;
40
40
  /** How long to wait before trying a failed send again. */
41
41
  const RETRY_MS = 120_000;
42
42
 
43
+ /**
44
+ * The reason recorded on a queued item whose mailbox needs signing in again.
45
+ *
46
+ * @param {Error} err
47
+ * @returns {string}
48
+ */
49
+ export function reconnectReason(err) {
50
+ if (!err?.needsReconnect) return err?.message || 'The mailbox could not be used.';
51
+ return `The mailbox needs signing in again (Mail Settings > Mailboxes > Reconnect): ${err.message}`;
52
+ }
53
+
43
54
  /**
44
55
  * Create the scheduler.
45
56
  *
@@ -70,7 +81,16 @@ export function createScheduler({store, pool, config, logger, tickMs = TICK_MS})
70
81
  return;
71
82
  }
72
83
 
73
- const smtp = accounts.toSmtpConnection(account, item.identityId ?? null);
84
+ // Built INSIDE the try: a signed-in mailbox whose sign-in is dead throws
85
+ // here (OAuthReconnectError), and escaping would leave the claimed item
86
+ // `sending` with no finishSend - picked up again on every reclaim.
87
+ let smtp;
88
+ try {
89
+ smtp = accounts.toSmtpConnection(account, item.identityId ?? null);
90
+ } catch (err) {
91
+ await store.finishSend(item._id, {status: 'failed', error: reconnectReason(err)});
92
+ return;
93
+ }
74
94
  if (!smtp) {
75
95
  await store.finishSend(item._id, {
76
96
  status: 'failed',
@@ -94,9 +114,11 @@ export function createScheduler({store, pool, config, logger, tickMs = TICK_MS})
94
114
  });
95
115
  await store.finishSend(item._id, {status: 'sent', result, error: null});
96
116
  } catch (err) {
97
- const giveUp = (item.attempts ?? 1) >= MAX_ATTEMPTS;
117
+ // A dead sign-in will not fix itself between retries: fail now, so
118
+ // the sender is told (the send-failed notice) instead of waiting.
119
+ const giveUp = err?.needsReconnect || (item.attempts ?? 1) >= MAX_ATTEMPTS;
98
120
  await store.finishSend(item._id, giveUp
99
- ? {status: 'failed', error: err.message}
121
+ ? {status: 'failed', error: err?.needsReconnect ? reconnectReason(err) : err.message}
100
122
  // Back to pending, a little later. `finishedAt` is set either
101
123
  // way, which is harmless on a retry and is what tells a
102
124
  // screen when the last attempt was.
@@ -126,9 +148,11 @@ export function createScheduler({store, pool, config, logger, tickMs = TICK_MS})
126
148
  }
127
149
 
128
150
  const poolKey = `${item.userId}:${item.accountId}`;
129
- const connection = accounts.toConnection(account);
151
+ let connection;
130
152
 
131
153
  try {
154
+ // Inside the try for the same reason as runSend: a dead sign-in throws here.
155
+ connection = accounts.toConnection(account);
132
156
  // The UID the move reported, when the server has UIDPLUS. Without
133
157
  // it the message is found by its Message-ID instead - a snooze on
134
158
  // such a server is still a snooze, and refusing to bring it back
@@ -160,9 +184,9 @@ export function createScheduler({store, pool, config, logger, tickMs = TICK_MS})
160
184
  // does not read, absent from the list that was supposed to bring
161
185
  // it back.
162
186
  const attempts = (item.attempts ?? 0) + 1;
163
- const giveUp = attempts >= MAX_ATTEMPTS;
187
+ const giveUp = err?.needsReconnect || attempts >= MAX_ATTEMPTS;
164
188
  await store.finishSnooze(item._id, giveUp
165
- ? {status: 'failed', error: err.message, attempts}
189
+ ? {status: 'failed', error: err?.needsReconnect ? `${reconnectReason(err)} The message is still in ${item.folder}.` : err.message, attempts}
166
190
  : {status: 'pending', at: new Date(Date.now() + RETRY_MS), error: err.message, attempts});
167
191
  logger.warn(
168
192
  `[mail] could not wake snoozed message ${item._id} (attempt ${attempts}): ${err.message}`
@@ -20,7 +20,8 @@ Public-facing blog for the CMS. Supports posts with categories, tags, and option
20
20
  - `blocks/*.html|css` - the shipped layouts, copied into content/blocks/ once
21
21
  - `public/blog.js`, `public/blog.css` - the browser half (listing, right-click menu, post extras)
22
22
  - `public/samples/*.svg` - sample covers
23
- - `tests/access.test.js`, `tests/comment-notices.test.js`, `tests/layouts.test.js`, `tests/nav-link.test.js`, `tests/notices.test.js`, `tests/public.test.js`
23
+ - `tests/access.test.js`, `tests/comment-notices.test.js`, `tests/layouts.test.js`, `tests/nav-link.test.js`, `tests/notices.test.js`, `tests/public.test.js`, `tests/cache.test.js`
24
+ - `lib/cache.js` - PURE: the 30 s read cache (forgotten on every write route)
24
25
  - `admin/views/blog.js` - post list (KPIs + status/category/search filters)
25
26
  - `admin/views/post-editor.js` - create / edit a post
26
27
  - `admin/views/categories.js`, `admin/views/comments.js`, `admin/views/settings.js`
@@ -329,3 +330,12 @@ Declared in plugin.json `permissions` (granted once to `admin`), checked on EVER
329
330
  tested), needs a plausible email, and refuses a post that does not exist or is not visible (404) - each of those
330
331
  used to raise a staff notification.
331
332
  - `ASSET_V` / the preview `assetV` are 1.9.6 (they were 1.6.x while the plugin was 1.9.5 - keep them with the version).
333
+
334
+ ## 1.9.7 (2026-10-13 Tool sweep - caching)
335
+
336
+ - **Whole-collection reads are cached 30 s** (`lib/cache.js`, pure, `tests/cache.test.js`): `loadEntries()` in
337
+ lib/page.js (public pages, shortcodes, the sitemap source, the `/comments/pending-count` badge) and
338
+ `resolveAuthorName()` (one user lookup per author per window). The promise is kept, so concurrent misses share one
339
+ read. **Every non-GET route of this plugin forgets it in an `onSend` hook** - before the writer's response leaves -
340
+ so the admin's own next read, and the public POST /comments, are fresh. The TTL only covers writes made outside
341
+ the plugin (Collections tool, Blog Pro). Callers share the cached array: filter/sort copies, never mutate it.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A short memory for whole-collection reads.
3
+ *
4
+ * Every public page view and every sidebar badge poll (once a minute from
5
+ * every open admin tab) read ALL posts, categories and comments from the
6
+ * adapter. The data changes only when someone writes, so a read is kept for a
7
+ * little while and every write route of this plugin throws the memory away
8
+ * (`forget()`, from an onSend hook in plugin.js - before the writer's own
9
+ * response leaves, so their next read is fresh). The TTL is the safety net
10
+ * for writes this plugin never sees (the Collections tool, a Pro take-over).
11
+ *
12
+ * PURE - no I/O of its own; the loader is handed in. Tested in tests/cache.test.js.
13
+ *
14
+ * @module blog/lib/cache
15
+ */
16
+
17
+ export const CACHE_MS = 30_000;
18
+
19
+ /**
20
+ * @param {{ttl?: number, clock?: () => number}} [opts]
21
+ * @returns {{get: (key: string, load: () => Promise<any>) => Promise<any>, forget: () => void, size: number}}
22
+ */
23
+ export function createCache({ttl = CACHE_MS, clock = Date.now} = {}) {
24
+ // key -> {at, value: Promise}. The PROMISE is kept, not the result, so
25
+ // concurrent misses share one read, and a write landing while a read is in
26
+ // flight clears the map before that read can be stored - nothing stale
27
+ // survives a forget().
28
+ const kept = new Map();
29
+ return {
30
+ get(key, load) {
31
+ const hit = kept.get(key);
32
+ if (hit && clock() - hit.at < ttl) return hit.value;
33
+ const value = Promise.resolve().then(load);
34
+ const entry = {at: clock(), value};
35
+ kept.set(key, entry);
36
+ // A failed read is not remembered: the next caller tries again.
37
+ value.catch(() => { if (kept.get(key) === entry) kept.delete(key); });
38
+ return value;
39
+ },
40
+ forget() { kept.clear(); },
41
+ get size() { return kept.size; }
42
+ };
43
+ }
@@ -13,6 +13,13 @@ import defaultConfig from '../config.js';
13
13
  import {fmtDate} from '../../_lib/admin/ui/dates.js';
14
14
  import {buildToc, byNewest, cardVars, cleanBasePath, DEFAULT_POST_LAYOUT, DISPLAY_DEFAULTS, escapeHtml, interpolate, pickPostLayout, relatedPosts, safeImageUrl} from './layouts.js';
15
15
  import {catalogue, loadBlock, renderCards, styleTag} from './render.js';
16
+ import {createCache} from './cache.js';
17
+
18
+ // Whole-collection reads and author names, kept a short while (lib/cache.js):
19
+ // every public page view and every badge poll read all posts, categories and
20
+ // comments. plugin.js calls forgetEntries() on every write route of this plugin.
21
+ const remembered = createCache();
22
+ export const forgetEntries = () => remembered.forget();
16
23
 
17
24
  /**
18
25
  * Whether readers see a post. Blog Pro files posts under several blogs
@@ -69,12 +76,21 @@ export function liveBase(registered) {
69
76
  }
70
77
 
71
78
  /** Always returns the actual array of entries, regardless of adapter shape. */
72
- export async function loadEntries(slug) {
79
+ async function readEntries(slug) {
73
80
  const result = await listEntries(slug, {limit: 100000, sort: 'createdAt', order: 'desc'});
74
81
  const arr = Array.isArray(result) ? result : (Array.isArray(result?.entries) ? result.entries : []);
75
82
  return arr.map((entry) => ({id: entry.id, ...(entry.data ?? entry)}));
76
83
  }
77
84
 
85
+ /**
86
+ * A collection's entries, newest first - from the cache when a read is fresh.
87
+ * Callers get the SAME array, so none may change it in place (they filter and
88
+ * sort copies, as before).
89
+ */
90
+ export function loadEntries(slug) {
91
+ return remembered.get(`entries:${slug}`, () => readEntries(slug));
92
+ }
93
+
78
94
  /** Render a post body through the full Markdown + shortcode pipeline. */
79
95
  export async function renderPostBody(content) {
80
96
  if (!content) return {html: '', usedComponents: []};
@@ -87,14 +103,17 @@ export async function renderPostBody(content) {
87
103
  }
88
104
  }
89
105
 
90
- export async function resolveAuthorName(authorId) {
91
- if (!authorId) return 'Unknown';
92
- try {
93
- const user = await getUserById(authorId);
94
- return user?.name || user?.email || 'Unknown';
95
- } catch {
96
- return 'Unknown';
97
- }
106
+ /** A user's display name - one lookup per author per cache window, not per card. */
107
+ export function resolveAuthorName(authorId) {
108
+ if (!authorId) return Promise.resolve('Unknown');
109
+ return remembered.get(`author:${authorId}`, async () => {
110
+ try {
111
+ const user = await getUserById(authorId);
112
+ return user?.name || user?.email || 'Unknown';
113
+ } catch {
114
+ return 'Unknown';
115
+ }
116
+ });
98
117
  }
99
118
 
100
119
  /** One lookup per author, however many posts they wrote. */
@@ -5,7 +5,7 @@ import {getPluginStates, savePluginState} from '../../server/services/plugins.js
5
5
  import {renderBlogPage} from '../../server/services/renderer.js';
6
6
  import {cleanBasePath, DISPLAY_DEFAULTS, pickDisplaySettings, safeImageUrl} from './lib/layouts.js';
7
7
  import {catalogue, seedBlocks} from './lib/render.js';
8
- import {liveBase, liveSettings, loadEntries as loadAll, renderPostPage, slugHolder} from './lib/page.js';
8
+ import {forgetEntries, liveBase, liveSettings, loadEntries as loadAll, renderPostPage, slugHolder} from './lib/page.js';
9
9
  import {addNavLink, moveNavLink, removeNavLink} from './lib/nav-link.js';
10
10
  import {SAMPLE_CATEGORIES, samplePosts} from './lib/samples.js';
11
11
  import {commentKey, commentNotice} from './lib/notices.js';
@@ -167,17 +167,9 @@ function isVisible(post) {
167
167
  || (post.status === 'scheduled' && post.publishedAt && new Date(post.publishedAt) <= new Date());
168
168
  }
169
169
 
170
- async function loadPosts() {
171
- const result = await listEntries(POSTS_SLUG, { limit: 100000, sort: 'createdAt', order: 'desc' });
172
- const arr = Array.isArray(result) ? result : (result?.entries || []);
173
- return arr.map((e) => ({ id: e.id, ...(e.data ?? e) }));
174
- }
175
-
176
- async function loadCategories() {
177
- const result = await listEntries(CATEGORIES_SLUG, { limit: 10000 });
178
- const arr = Array.isArray(result) ? result : (result?.entries || []);
179
- return arr.map((e) => ({ id: e.id, ...(e.data ?? e) }));
180
- }
170
+ // The shortcodes run on public page views: the same cached reads as the pages.
171
+ const loadPosts = () => loadAll(POSTS_SLUG);
172
+ const loadCategories = () => loadAll(CATEGORIES_SLUG);
181
173
 
182
174
  function selectPosts(posts, { limit, categorySlug, categoryMap }) {
183
175
  let filtered = posts.filter(isVisible);
@@ -337,6 +329,15 @@ export default async function blogPlugin(fastify, options) {
337
329
  });
338
330
  }
339
331
 
332
+ // Every write route of this plugin (posts, categories, comments - the public
333
+ // POST /comments too - samples) retires the cached reads behind the public
334
+ // pages, the shortcodes and the badge. onSend, not onResponse: the memory is
335
+ // gone BEFORE the writer's response leaves, so their very next read is fresh.
336
+ // The hook is encapsulated - only this plugin's routes reach it.
337
+ fastify.addHook('onSend', async (request) => {
338
+ if (request.method !== 'GET' && request.method !== 'HEAD') forgetEntries();
339
+ });
340
+
340
341
  // The layouts are blocks; put the shipped ones where the Blocks tool can
341
342
  // edit them. Once each, never over a site's own copy (lib/render.js).
342
343
  try {
@@ -392,7 +393,7 @@ export default async function blogPlugin(fastify, options) {
392
393
  });
393
394
  const html = await renderBlogPage(
394
395
  fileURLToPath(new URL('./templates/post.html', import.meta.url)),
395
- { post: page.html, assetV: '1.9.6' },
396
+ { post: page.html, assetV: '1.9.7' },
396
397
  { title: post.title || 'Preview', description: post.excerpt || '', ogImage: safeImageUrl(post.featuredImage),
397
398
  usedComponents: page.usedComponents, urlPath: `${basePath}/${post.slug}`, noindex: true }
398
399
  );
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "blog",
3
3
  "displayName": "Blog",
4
- "version": "1.9.6",
4
+ "version": "1.9.7",
5
5
  "tier": "free",
6
6
  "description": "Public-facing blog with posts, categories and comments - layouts and listings built from editable blocks.",
7
7
  "author": "Domma CMS",
@@ -24,7 +24,7 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url));
24
24
  const TEMPLATES_DIR = path.join(__dirname, 'templates');
25
25
 
26
26
  /** Bumped with the plugin, so a release is never served a stale script. */
27
- const ASSET_V = '1.9.6';
27
+ const ASSET_V = '1.9.7';
28
28
 
29
29
  /**
30
30
  * Above this many posts a listing pages on the server instead: the browser