domma-cms 1.6.38 → 1.6.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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}`
@@ -173,3 +173,10 @@ lib/shape.js; no SVG or HTML - they can carry script). Chosen with "Attach a fil
173
173
  - `admin/lib/settings.js`: the banner cog, added by `feedback.js` once the mode is known, only when the silent
174
174
  `GET /settings` answers; a slideover through kit's `openPanel` with M bindings, data-help per field, discard
175
175
  guard, Ctrl+Enter. Tests: tests/settings.test.js, and "the per-site throttle" in tests/api.test.js (live limit).
176
+
177
+ ## 1.1.8 (2026-10-13) - a report deleted on the manager leaves the site too
178
+ Deleting a report in the inbox hid it from the site, but the site's outbox kept the sent item - its text and, through
179
+ `pruneFiles`' keep list, its screenshots - for good. `sync()` now calls `outbox.forgetDropped(remote)` whenever the
180
+ manager answered `/fleet/mine` (its list for the site is complete): a SENT item the manager no longer has is dropped
181
+ (`droppedByManager`, PURE, tested); waiting/refused items and an unreachable manager keep everything. The files go
182
+ at the next prune (older than a day, no longer named). Found on the dcm click-through.
@@ -17,7 +17,7 @@ import {api, loadKit, store} from '../lib/kit.js';
17
17
  import {addSettingsCog} from '../lib/settings.js';
18
18
 
19
19
  const ROUTE = /^#\/plugins\/feedback(\/|$)/;
20
- const V = '1.1.7';
20
+ const V = '1.1.8';
21
21
  const TABS = [
22
22
  {key: 'inbox', label: 'Inbox', icon: 'inbox'},
23
23
  {key: 'mine', label: 'My feedback', icon: 'send'}
@@ -69,10 +69,29 @@ export function createOutbox(dataDir, name = 'outbox.json') {
69
69
  const s = read();
70
70
  s.seen[id] = replyAt;
71
71
  write(s);
72
+ },
73
+ /**
74
+ * The manager's full list of this site's reports: a SENT report it no longer has was deleted there,
75
+ * so this site forgets it too - its copy of the text, and (on the next prune) its files.
76
+ * @returns {number} how many were forgotten
77
+ */
78
+ forgetDropped(remote) {
79
+ if (!Array.isArray(remote)) return 0;
80
+ const s = read();
81
+ const keep = s.items.filter(i => !droppedByManager(i, remote));
82
+ const gone = s.items.length - keep.length;
83
+ if (gone) { s.items = keep; write(s); }
84
+ return gone;
72
85
  }
73
86
  };
74
87
  }
75
88
 
89
+ /** A sent report the manager's list (complete, for this site) no longer holds - deleted there. PURE. */
90
+ export function droppedByManager(item, remote) {
91
+ if (item?.state !== 'sent' || !Array.isArray(remote)) return false;
92
+ return !remote.some(r => r.ref === item.ref);
93
+ }
94
+
76
95
  export const newRef = () => randomUUID();
77
96
 
78
97
  /**
@@ -136,6 +136,8 @@ async function senderRoutes(fastify, {can, hooks, notifyOk, liveSettings, settin
136
136
  const pushed = await flush(outbox, api, files);
137
137
  const mine = await api.mine();
138
138
  const remote = mine.ok ? (mine.body?.reports || []) : null;
139
+ // Deleted on the manager: gone here too, and its screenshots with the next prune (1.1.8).
140
+ if (remote) outbox.forgetDropped(remote);
139
141
  const reports = mergeReports(outbox.all(), remote, outbox.seen());
140
142
  // Keep the last-known reply on the outbox copy: the badge reads that, never the manager.
141
143
  if (remote) for (const r of reports) if (!r.local && r.ref) outbox.mark(r.ref, {reply: r.reply ? '1' : '', replyAt: r.replyAt, remoteId: r.id});
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "feedback",
3
3
  "displayName": "Feedback",
4
- "version": "1.1.7",
4
+ "version": "1.1.8",
5
5
  "tier": "free",
6
6
  "description": "Tell Domma what is broken, missing or good - straight from your admin - and see what became of it: seen, planned, in progress, done, with Domma's reply.",
7
7
  "author": "Domma CMS",
@@ -40,7 +40,7 @@
40
40
  ],
41
41
  "views": {
42
42
  "plugin-feedback": {
43
- "entry": "feedback/admin/views/feedback.js?v=1.1.7",
43
+ "entry": "feedback/admin/views/feedback.js?v=1.1.8",
44
44
  "exportName": "feedbackView"
45
45
  }
46
46
  }
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
3
3
  import {
4
4
  buildPayload, cleanSubmission, cleanUpdate, createThrottle, issueDescription, receivedReport, senderView, statusFromIssue
5
5
  } from '../lib/shape.js';
6
- import {mergeReports} from '../lib/sender.js';
6
+ import {createOutbox as createFbOutbox, droppedByManager, mergeReports} from '../lib/sender.js';
7
7
 
8
8
  test('a submission is cleaned, and says nothing about who sent it', () => {
9
9
  assert.equal(cleanSubmission({title: ' ', description: 'x'}).error, 'Give it a short title.');
@@ -149,3 +149,25 @@ test('the file store: private files, read back by id, unclaimed ones tidied', ()
149
149
  assert.equal(files.read(a), null);
150
150
  } finally { fs.rmSync(root, {recursive: true, force: true}); }
151
151
  });
152
+
153
+ test('a report deleted on the manager is forgotten by the site (its text, then its files)', async () => {
154
+ const {mkdtempSync, rmSync} = await import('node:fs');
155
+ const {tmpdir} = await import('node:os');
156
+ const {join} = await import('node:path');
157
+ assert.equal(droppedByManager({ref: 'a', state: 'sent'}, [{ref: 'b'}]), true);
158
+ assert.equal(droppedByManager({ref: 'a', state: 'sent'}, [{ref: 'a'}]), false);
159
+ assert.equal(droppedByManager({ref: 'a', state: 'waiting'}, []), false, 'never sent: the manager cannot have it yet');
160
+ assert.equal(droppedByManager({ref: 'a', state: 'sent'}, null), false, 'manager unreachable: keep everything');
161
+ const dir = mkdtempSync(join(tmpdir(), 'fb-outbox-'));
162
+ try {
163
+ const box = createFbOutbox(dir);
164
+ for (const ref of ['kept', 'gone', 'wait']) box.add({ref, attachments: [{id: `${ref}-file`}]});
165
+ box.mark('kept', {state: 'sent'});
166
+ box.mark('gone', {state: 'sent'});
167
+ assert.equal(box.forgetDropped(null), 0);
168
+ assert.equal(box.forgetDropped([{ref: 'kept'}]), 1);
169
+ assert.deepEqual(box.all().map(i => i.ref), ['kept', 'wait']);
170
+ } finally {
171
+ rmSync(dir, {recursive: true, force: true});
172
+ }
173
+ });