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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
3
- "source": "dcms-marketplace@e4f2659",
3
+ "source": "dcms-marketplace@0525295",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.9.7",
@@ -118,14 +118,14 @@
118
118
  }
119
119
  },
120
120
  "feedback": {
121
- "version": "1.1.7",
121
+ "version": "1.1.8",
122
122
  "files": {
123
- "CLAUDE.md": "f06ec835ba056943a495eaaacd27d3bbe331e6c0fb5e46246a25f5918709d76e",
123
+ "CLAUDE.md": "8666d4ef8d8620f0fc20f544a4c1915cddc21370cbc27a7f854ebfe41f4cfe1d",
124
124
  "admin/css/index.css": "c8f827ad62386134e87bc05bd2c8e24f41184402b0a5a17e7250d749240c34c0",
125
125
  "admin/lib/kit.js": "502d2d090e8aaeda03034864b4e3cc0d9bbf7e8fba29b6e4916a8a758c0cd54d",
126
126
  "admin/lib/settings.js": "6e3fe9103fcbf7acb52211698ca408fac067e061100e580a9a3011cd76bdfba3",
127
127
  "admin/templates/feedback.html": "a366dce78f6c3e84a7cb0ac63eba53e1980a4eb44154496a0536f5214c5bb36f",
128
- "admin/views/feedback.js": "2186a648b237b435f806d12fa963af9519e6f90d5691012d4fc2fb1c3bf10689",
128
+ "admin/views/feedback.js": "5aed85f09ef8af762d425912ca7fb4299ec129a21822863edf431af4f1a12e64",
129
129
  "admin/views/inbox.js": "20cc1bd16df59c775f0e2d398f4179d0e30683a3a76a942e250f59f7cbdce98a",
130
130
  "admin/views/send.js": "104290b8f58f7e8d75d86de0b3f5496bd38546b6bfb20ddff45e153e61f7d061",
131
131
  "collections/feedback-reports.json": "a072291b52463f7785a77f5f77d36b8238ba8f89cfc2672c6642eccdb3144486",
@@ -134,13 +134,13 @@
134
134
  "lib/attachments.js": "b23bda973c2e7723bc485c5f2840868363a3ac4e65c153cb5abd5a3c41dba3ad",
135
135
  "lib/cache.js": "6db2859c21a3dddcc2ec34e8380dd672fc99a6cb0d76a2b05b68086503f4ccce",
136
136
  "lib/receiver.js": "98aeea6712ba6aa576919520a86451f7f223e5154416a225a848e0c875923c8d",
137
- "lib/sender.js": "5e1e1b0a052ac0488a68eb263d05b1bbc39120994a17d3aa67937f01c6704228",
137
+ "lib/sender.js": "58194d1ab562e55e7ac6a29aed6b5ec8316ec6932b40b44034bb347e8f799f79",
138
138
  "lib/shape.js": "47ccbbb95598023f8de03dd0f557205159375fd2bb61ee799a19c186c45bc4d9",
139
- "plugin.js": "f261d429b9e3c1bcccb9e74af47c639ff380a718c55e1a56f861403e29a71bae",
140
- "plugin.json": "1db9a277fcd079e226af5c75498dfd05e88ff57bab66257009360bdca09f41b0",
139
+ "plugin.js": "dd9cf5066e8491184a782112ed9463a211b37c2dbd93caf832edd65c2bb0e2a4",
140
+ "plugin.json": "9712d3eba3d81a0a8f5f85dfa168ea158d14b6ef9c0de6416314938c1b41f6ed",
141
141
  "tests/api.test.js": "67336ed4a034e21ead5876becefa1de2fa2229b06689385471516df92e198d33",
142
142
  "tests/settings.test.js": "3a50ac89a9d114c8466fc043a66f0f1bf373e7a7ad6dba45a995468c3332b374",
143
- "tests/shape.test.js": "5d85759e51256a26ad0fea32fd4676b53ca45d023b93cb9cb295ad40df2bae43"
143
+ "tests/shape.test.js": "2e2e5e17ce059fbaa4dad8170ae45382d6c7e74bacf3b301814bb81a60ba98ba"
144
144
  }
145
145
  },
146
146
  "mail-reader": {
@@ -181,14 +181,14 @@
181
181
  }
182
182
  },
183
183
  "shopping-cart": {
184
- "version": "1.6.2",
184
+ "version": "1.6.3",
185
185
  "files": {
186
- "CLAUDE.md": "4783c7003984b7f43a1ac216d44f450d1459e800a00d68f816cac25808cc5ac2",
186
+ "CLAUDE.md": "1c881307d422beaeaef405b9d90ec4c71679fdebe0a919353fbd9c597e26b22b",
187
187
  "admin/css/index.css": "dcb2d30126c2e049b03a2e2b615082a067ab4dc911c87bf128029c900e6691b4",
188
188
  "admin/lib/chart.js": "b01ce5cfac773c8df8efccab50b67d3dfa99fbead0037d817553db4a6688d4d6",
189
189
  "admin/lib/kit.js": "d9aa90469b3f589beaff0c3fb6ad3d983ab3fd6d94c06965be639a665bfe052f",
190
190
  "admin/templates/shop.html": "c5bb03f1f7cbabfbd8202f3533783eb1848103755b1ba25fdcf1fd8e9478cb8e",
191
- "admin/views/orders.js": "1ef4df77c392c8940e189db8bc40e25b3f7fcca202d815417f750d1fc8da34f2",
191
+ "admin/views/orders.js": "1c5b571027c78223f98dec2a39c1af8e33e0d810a9b34e0b176ebf85c5c9f486",
192
192
  "admin/views/overview.js": "97045c069c6560b3f67e1b2230bdc9c4133ed92ec6f732886470f886f11c755a",
193
193
  "admin/views/products.js": "e109660355aaec8db07eb65fa1169e7d2ec20bc2ec45293cc20e34f7c1bd62bf",
194
194
  "admin/views/sales.js": "da71b9fbb7d39e39ecd8be35b1187e88bc4b5b28f18baaeddc6ea4cd202bb330",
@@ -211,7 +211,7 @@
211
211
  "lib/shop.js": "baa5dfc4e410210e8ba13678997ee5752eb2ebe72fc4d3e449465d29b60b458d",
212
212
  "lib/summary.js": "cdb13ce3d1799d429059fa03f179281711dc00427ccf2eb6b8f8aa52fef26295",
213
213
  "plugin.js": "824dba8336846d443b2427ed9a5adeba11740570566fc6e89257f9847a488632",
214
- "plugin.json": "116c9b7964398caf788ae0d1cd0ebb88517a35e404ad834ed3afb869676fc1c8",
214
+ "plugin.json": "475c64d3e5f07d0dd4f1480b65630d0e9ef362418653477de9be617aac13dc10",
215
215
  "plugin.public.js": "2f92e8340c9da653401c11dd99dab033dc6772d63fa2114489192c48e9e0be00",
216
216
  "public/account.js": "5f644016c098c7879c59948d662f5997cb61680996204ecb7897baab8945d434",
217
217
  "public/cart.js": "7338141392ec653ff616eb75688d2a2ad08cff8427d7020d381f540be0037b73",
@@ -237,22 +237,22 @@
237
237
  "tests/accounts.test.js": "e64adc2a855d4ca017682bba7f75b052534f826556d8d35d825fb79cbc66596a",
238
238
  "tests/api.test.js": "15c71751a3d31171707cbb3119d21b85c1c59fb7a67272b318dd4655809ac8d2",
239
239
  "tests/commission.test.js": "fbc4cf83e0ed6446a09e57e4ff37a9608c81947c633c78ac6049eb0aedf6cb58",
240
- "tests/pure.test.js": "13620f708f21e2590979e8579e2e7788d6109e1682bf73d7b1eb64838da35196",
240
+ "tests/pure.test.js": "59a351c813784e8131b96a118c79de2e3858a2fba0fc5c0a6baed79e28cafda8",
241
241
  "tests/refunds.test.js": "0ae06afca3cf9fd1fe89f1fbc63b3676216c79e174dbcb83788180c01d078366",
242
242
  "tests/sales.test.js": "9025bc608b04bcc14f5bb2c98b4f7c35de99f3ddab4ffa59772d045795bf2cb7"
243
243
  }
244
244
  },
245
245
  "support": {
246
- "version": "1.1.5",
246
+ "version": "1.1.6",
247
247
  "files": {
248
- "CLAUDE.md": "1e7f80df50b81b472f353a935be547c773efefcf1f03e71c4adead8130134f77",
248
+ "CLAUDE.md": "65845bbf6a909cd523466896874e84a2a29d239fdc5da0840df5155913cdcf23",
249
249
  "admin/css/index.css": "6695029b1da2a2e4ff25db4abecd360895ed39e4a9bc7f0996857672faf2c426",
250
250
  "admin/lib/kit.js": "3d5a5fb5c53f7cc9ff98664803ff58701a4d8598068e9f1a16e9db0a72109c40",
251
251
  "admin/lib/settings.js": "3f0b7c4c5ab927b2785af2abb3872ffd9269d12b402468f79795c4198eb49ea0",
252
252
  "admin/templates/support.html": "2bb69d0443a367a402e6160fe68f4af3e8983b578cfff9575b4df9a534fcb480",
253
253
  "admin/views/desk.js": "4f3cffe3603592d715c6a9b553e02dc7331a2675cabaeddff709c91436531734",
254
254
  "admin/views/inbox.js": "8134d91e20a19eb2ca7b25edb604fa241e65b764e0d058ee5d8751f903a16c32",
255
- "admin/views/support.js": "6f3f303cd498a2f396bbb995e24894bd710567b224d1bbbdc6eca04f389dfef8",
255
+ "admin/views/support.js": "d1c72cc2e35609e54be296791cdcceb43be9be016b6c307000ddb8a226ba6d92",
256
256
  "collections/support-tickets.json": "55a0856bacd66c2e8f1e3c85bdf3638e5ff826560943cda854a8f2d336a88905",
257
257
  "config.js": "eb7ccc1169fa8b2be52a213536825492fedaec5fa73fd749cba9f498dd54b503",
258
258
  "docs/guide.md": "bfe570f4f485f58ae3aab4f71039e5e426f85f6f348c96794dfcb2dc494fced1",
@@ -260,13 +260,13 @@
260
260
  "lib/cache.js": "70cc826b24cc5359597a5c83a990f6da43cadebf2ac34094aee42058240fb72d",
261
261
  "lib/mail.js": "d14aae3a59aad6af61451863cdaaf94fc4a87810b16e1dcb9c655dda6c803b3b",
262
262
  "lib/receiver.js": "c9401cc799e5eafd419c136c95826ca28a8455e32e99cea32f4821acc1fb6c26",
263
- "lib/sender.js": "46abee3e2bd7ac8d763d1f56b385c2b9367012a223d61831694292a35bdaf142",
263
+ "lib/sender.js": "2b308422ac60a8a2801570e06319d5972797a601640c67beee95ccab9ac64092",
264
264
  "lib/shape.js": "d60b48410aa5582ea80451c3d831686b0e29525a958733a8bc9dfaae8eca94d2",
265
265
  "plugin.js": "91fff8d66bf5fa0fa4502bb177460ae234f0ed0494a4fbe6dd94d63e2f88b571",
266
- "plugin.json": "a2e0d141b1548f95147f6d113a35bd46f9add485f4cb65d710ae5c8a12ffe5a7",
266
+ "plugin.json": "70bf4f117c4b2f9132365f73a1b1a38c3dfd5618d5273a8c899a2393323ebf43",
267
267
  "tests/api.test.js": "1af65f0cba8a83c080a6e3e65544abc86fc43c8e25ca05c7f3e1ab4e3f7ac5a2",
268
268
  "tests/settings.test.js": "434acbfd940eef800b1166a9aaf36363351af5652fcfb06527b8498866b7b964",
269
- "tests/shape.test.js": "d68ad350fbf1335c436f0cb311adaa3268f25db61cb65b266ab3330c217047bf"
269
+ "tests/shape.test.js": "7866c6c1d140716bf0817449937d6d711d7c3b97be1db1e1eb33bde341e36296"
270
270
  }
271
271
  }
272
272
  }
@@ -243,3 +243,10 @@ Cart only READS it (`lib/commission.js currentCommission`, re-read when the file
243
243
  `saveSettings` move it too. `GET /count` (every admin tab, every minute, all orders + products) keeps one answer for
244
244
  60 s unless the generation moved; an answer worked out across a write is not kept. `services.writeGeneration` lets
245
245
  extensions (Takeaway 0.4.2) cache their own order reads the same way - feature-detect it, older Carts lack it.
246
+
247
+ ## 1.6.3 (2026-10-13): the Refund form was empty
248
+ `E.confirm` sanitises its message (preset basic), which strips `<input>` - so 1.6.2's Refund dialog showed only the words,
249
+ no amount, reason or restock box, and OK refunded the WHOLE order. `refund()` in `admin/views/orders.js` is now a small
250
+ slideover (`openPanel`, like Fulfil): amount (prefilled with what is left), reason, restock box (greyed out unless the
251
+ amount is all that is left), a Refund button. Never put form fields in `E.confirm` / `E.alert`.
252
+ `tests/pure.test.js` "admin: no form fields inside E.confirm" guards it across the admin views.
@@ -172,54 +172,74 @@ export async function mount(root, ctx) {
172
172
  /** Money already given back on an order (legacy whole-order refunds included). */
173
173
  const refundedOf = (d) => Array.isArray(d.refunds) ? d.refunds.reduce((n, r) => n + (Number(r.amount) || 0), 0) : d.status === 'refunded' ? d.total || 0 : 0;
174
174
 
175
- async function refund(o) {
176
- const d = o.data;
177
- const via = {stripe: 'through Stripe', paypal: 'through PayPal'}[d.provider];
178
- const left = Math.max(0, (d.total || 0) - refundedOf(d));
179
- const before = refundedOf(d);
180
- const html = `<p>Refund ${esc(orderLabel(d))}${before ? ` - <strong>${esc(money(left))}</strong> of ${esc(money(d.total))} is left to refund (${esc(money(before))} already refunded).` : `, <strong>${esc(money(d.total))}</strong>.`}</p>
181
- <label class="form-label" for="shp-refund-amount">Amount <span data-help="Leave the whole amount for a full refund. A smaller amount is a part refund: the order stays as it is, with what was given back on it, and you can refund the rest later."></span></label>
182
- <input class="form-input" id="shp-refund-amount" inputmode="decimal" value="${esc(moneyIn(left))}" aria-describedby="shp-refund-hint">
183
- <p class="shp-muted" id="shp-refund-hint">${via && d.paymentRef ? `The money goes back ${via} straight away.` : 'This only records the refund - give the money back the way it came.'}</p>
184
- <label class="form-label" for="shp-refund-note">Reason <span class="shp-muted">(optional, kept on the order)</span></label>
185
- <input class="form-input" id="shp-refund-note" maxlength="200" placeholder="Damaged in the post, one item missing…">
186
- <label class="shp-check"><input type="checkbox" id="shp-restock" ${d.stockTaken ? 'checked' : 'disabled'}> Put the items back in stock <span class="shp-muted">(full refund only)</span></label>`;
187
- let restock = Boolean(d.stockTaken);
188
- let amountText = moneyIn(left);
189
- let note = '';
190
- const onChange = (e) => {
191
- if (e.target.id === 'shp-restock') restock = e.target.checked;
192
- if (e.target.id === 'shp-refund-amount') {
193
- amountText = e.target.value;
194
- // Stock only comes back when the whole order is refunded, so the box greys out on a part refund.
195
- const box = document.getElementById('shp-restock');
196
- const full = moneyOut(amountText) === left;
197
- if (box && d.stockTaken) { box.disabled = !full; if (!full) box.checked = false; else box.checked = restock; }
198
- }
199
- if (e.target.id === 'shp-refund-note') note = e.target.value;
200
- };
201
- document.addEventListener('input', onChange);
202
- document.addEventListener('change', onChange);
203
- const ok = await E.confirm(html);
204
- document.removeEventListener('input', onChange);
205
- document.removeEventListener('change', onChange);
206
- if (!ok) return null;
207
- const amount = moneyOut(amountText);
208
- if (!Number.isInteger(amount) || amount <= 0 || amount > left) {
209
- E.toast(`Enter an amount between ${money(1)} and ${money(left)}.`, {type: 'error'});
210
- return null;
211
- }
212
- const full = amount === left;
213
- try {
214
- const r = await api.orders.refund(o.id, {amount: full ? undefined : amount, restock: full && restock, note});
215
- replace(r.order);
216
- refreshBadge();
217
- E.toast(`${orderLabel(d)} ${r.how}.`, {type: 'success'});
218
- return r.order;
219
- } catch (err) {
220
- E.toast(err.message || String(err), {type: 'error'});
221
- return null;
222
- }
175
+ /**
176
+ * Refund - all that is left, or part of it - in a small form of its own. (Not E.confirm: its message is
177
+ * sanitised, which strips inputs, so the amount, reason and restock box never showed and OK refunded it all.)
178
+ */
179
+ function refund(o) {
180
+ return new Promise((resolve) => {
181
+ const d = o.data;
182
+ const via = {stripe: 'through Stripe', paypal: 'through PayPal'}[d.provider];
183
+ const before = refundedOf(d);
184
+ const left = Math.max(0, (d.total || 0) - before);
185
+ const f = {amount: M.observable(moneyIn(left)), note: M.observable(''), saving: M.observable(false)};
186
+ let restock = Boolean(d.stockTaken);
187
+ const full = () => moneyOut(f.amount.peek()) === left;
188
+ let panel = null;
189
+ let box = null;
190
+ // Stock only comes back when the whole order is refunded, so the box greys out on a part refund.
191
+ const syncBox = () => { if (box && d.stockTaken) { box.disabled = !full(); box.checked = full() && restock; } };
192
+ const stop = M.effect(() => { f.amount.value; syncBox(); });
193
+ openPanel({
194
+ title: `${before ? 'Refund more of' : 'Refund'} ${orderLabel(d)}`,
195
+ size: 'sm',
196
+ model: f,
197
+ html: `<div class="shp-form">
198
+ <p class="shp-note">${before ? `<strong>${esc(money(left))}</strong> of ${esc(money(d.total))} is left to refund (${esc(money(before))} already refunded).` : `The order came to <strong>${esc(money(d.total))}</strong>.`}</p>
199
+ <label class="form-label" for="shp-refund-amount">Amount <span data-help="Leave the whole amount for a full refund. A smaller amount is a part refund: the order stays as it is, with what was given back on it, and you can refund the rest later."></span></label>
200
+ <input class="form-input" id="shp-refund-amount" inputmode="decimal" data-model="amount.value" aria-describedby="shp-refund-hint">
201
+ <p class="shp-muted" id="shp-refund-hint">${via && d.paymentRef ? `The money goes back ${via} straight away.` : 'This only records the refund - give the money back the way it came.'}</p>
202
+ <label class="form-label" for="shp-refund-note">Reason <span class="shp-muted">(optional, kept on the order)</span></label>
203
+ <input class="form-input" id="shp-refund-note" maxlength="200" data-model="note.value" placeholder="Damaged in the post, one item missing…">
204
+ <label class="shp-check"><input type="checkbox" id="shp-restock"> Put the items back in stock <span class="shp-muted">(full refund only)</span></label>
205
+ <div class="shp-actions"><span class="shp-spacer"></span>
206
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
207
+ <button type="button" class="btn btn-sm btn-danger" data-on-click="save" data-bind-disabled="saving.value"><span data-icon="credit-card" data-icon-size="13"></span> Refund</button></div>
208
+ </div>`,
209
+ before: (el) => {
210
+ box = el.querySelector('#shp-restock');
211
+ if (!d.stockTaken) box.disabled = true;
212
+ box.addEventListener('change', () => { restock = box.checked; });
213
+ syncBox();
214
+ },
215
+ cleanup: () => { stop?.(); resolve(null); },
216
+ methods: {
217
+ cancel: () => panel?.close(true),
218
+ async save() {
219
+ if (f.saving.peek()) return;
220
+ const amount = moneyOut(f.amount.peek());
221
+ if (!Number.isInteger(amount) || amount <= 0 || amount > left) {
222
+ E.toast(`Enter an amount between ${money(1)} and ${money(left)}.`, {type: 'error'});
223
+ return;
224
+ }
225
+ const all = amount === left;
226
+ f.saving.value = true;
227
+ try {
228
+ const r = await api.orders.refund(o.id, {amount: all ? undefined : amount, restock: all && restock, note: f.note.peek()});
229
+ replace(r.order);
230
+ refreshBadge();
231
+ E.toast(`${orderLabel(d)} ${r.how}.`, {type: 'success'});
232
+ resolve(r.order);
233
+ panel?.close(true);
234
+ } catch (err) {
235
+ E.toast(err.message || String(err), {type: 'error'});
236
+ } finally {
237
+ f.saving.value = false;
238
+ }
239
+ }
240
+ }
241
+ }).then(p => { panel = p; p.panel.querySelector('#shp-refund-amount')?.focus(); });
242
+ });
223
243
  }
224
244
 
225
245
  async function remove(o) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "shopping-cart",
3
3
  "displayName": "Shopping Cart",
4
- "version": "1.6.2",
4
+ "version": "1.6.3",
5
5
  "tier": "free",
6
6
  "description": "Sell from your site: products with options, stock and pictures, a storefront and a cart that follows shoppers from page to page, and checkout with Stripe, PayPal or payment offline. Orders to fulfil in the sidebar, refunds from the order.",
7
7
  "author": "Domma CMS",
@@ -313,3 +313,19 @@ test('payments: Stripe refunds and sessions carry an Idempotency-Key, so a retry
313
313
  assert.equal(seen[2].init.headers['Idempotency-Key'], 'shop-session-o1');
314
314
  } finally { globalThis.fetch = realFetch; }
315
315
  });
316
+
317
+ test('admin: no form fields inside E.confirm (its message is sanitised; inputs vanish)', async () => {
318
+ const {readFileSync, readdirSync} = await import('node:fs');
319
+ const dir = new URL('../admin/views/', import.meta.url);
320
+ for (const f of readdirSync(dir).filter(n => n.endsWith('.js'))) {
321
+ const src = readFileSync(new URL(f, dir), 'utf8');
322
+ for (const m of src.matchAll(/E\.confirm\(\s*([A-Za-z_$][\w$]*)\s*[,)]/g)) {
323
+ // A variable passed to E.confirm: find its last assignment before the call and refuse form fields in it.
324
+ const before = src.slice(0, m.index);
325
+ const at = before.lastIndexOf(`${m[1]} =`);
326
+ const assigned = at < 0 ? '' : before.slice(at, at + 4000);
327
+ assert.ok(!/<(input|select|textarea)\b/.test(assigned), `${f}: E.confirm(${m[1]}) carries form fields`);
328
+ }
329
+ for (const m of src.matchAll(/E\.confirm\(`([^`]*)`/g)) assert.ok(!/<(input|select|textarea)\b/.test(m[1]), `${f}: E.confirm with form fields`);
330
+ }
331
+ });
@@ -133,3 +133,10 @@ copy of the pure rules (lib/shape.js) and the store (lib/attachments.js) since e
133
133
  - `admin/lib/settings.js`: the cog, added by `support.js` once the mode is known, only when the silent
134
134
  `GET /settings` answers; slideover through kit's `openPanel`, M bindings, data-help per field, discard guard,
135
135
  Ctrl+Enter. Test: tests/settings.test.js.
136
+
137
+ ## 1.1.6 (2026-10-13) - a request deleted on the manager takes the site's files with it
138
+ The site keeps a copy of each file it sent (`outbox.files`, newest 2000) to draw its own thread, and the prune spared
139
+ every id on that list - so a request Domma deleted left its screenshots on the site for good. `remember(remote)` now
140
+ narrows `files` with `keepFiles(files, remote, items)` (PURE, tested): only ids in a message of a request the manager
141
+ still holds (its `/fleet/mine` is complete for the site) or named by an outbox item. The next prune (older than a day)
142
+ removes the rest. Found on the dcm click-through.
@@ -15,7 +15,7 @@ import {api, loadKit} from '../lib/kit.js';
15
15
  import {addSettingsCog} from '../lib/settings.js';
16
16
 
17
17
  const ROUTE = /^#\/plugins\/support(\/|$)/;
18
- const V = '1.1.5';
18
+ const V = '1.1.6';
19
19
  let lifetime = null;
20
20
 
21
21
  export const supportView = {
@@ -139,6 +139,9 @@ export function createOutbox(dataDir) {
139
139
  const known = latest.has(ref);
140
140
  if ((!known && !local.has(ref)) || (known && (latest.get(ref) || '') !== (entry.dommaAt || ''))) delete s.shelf[ref];
141
141
  }
142
+ // Files this site keeps to show its own thread: only those the manager still has (a request
143
+ // deleted there takes its files with it) or an item here still names. The next prune removes the rest.
144
+ s.files = keepFiles(s.files, remote, s.items);
142
145
  const byRef = new Map(remote.map(t => [t.ref, t]));
143
146
  const have = new Set(remote.flatMap(t => [t.ref, ...(t.messages || []).map(m => m.id)]));
144
147
  s.items = s.items.filter((i) => {
@@ -157,6 +160,18 @@ export function createOutbox(dataDir) {
157
160
  };
158
161
  }
159
162
 
163
+ /**
164
+ * Of the file ids this site kept after sending them, those still wanted: in a message of a request the
165
+ * manager still holds (its list for this site is complete), or named by an outbox item. PURE.
166
+ */
167
+ export function keepFiles(files, remote, items = []) {
168
+ const live = new Set([
169
+ ...(remote || []).flatMap(t => (t.messages || []).flatMap(m => (m.attachments || []).map(a => a.id))),
170
+ ...(items || []).flatMap(i => (i.payload?.attachments || []).map(a => a.id))
171
+ ]);
172
+ return (files || []).filter(id => live.has(id));
173
+ }
174
+
160
175
  /** Has every file an item announced been sent, or given up on? */
161
176
  const filesDone = (i) => {
162
177
  const done = new Set([...(i.filesSent || []), ...(i.filesRefused || [])]);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "support",
3
3
  "displayName": "Support",
4
- "version": "1.1.5",
4
+ "version": "1.1.6",
5
5
  "tier": "free",
6
6
  "description": "Get help from Domma straight from your admin: our email, phone and hours always to hand, and a conversation with the Domma team for anything you need - urgent requests first. Right-click a request to archive, resolve or remove it.",
7
7
  "author": "Domma CMS",
@@ -61,7 +61,7 @@
61
61
  ],
62
62
  "views": {
63
63
  "plugin-support": {
64
- "entry": "support/admin/views/support.js?v=1.1.5",
64
+ "entry": "support/admin/views/support.js?v=1.1.6",
65
65
  "exportName": "supportView"
66
66
  }
67
67
  }
@@ -5,7 +5,7 @@ import {
5
5
  addTurn, buildMessagePayload, buildTicketPayload, cleanRequest, cleanUpdate, contactCard, createThrottle, lastDommaAt,
6
6
  receivedTicket, senderView, telHref, toCustomerEmail, toDommaEmail
7
7
  } from '../lib/shape.js';
8
- import {applyShelf, createOutbox, flush, mergeTickets} from '../lib/sender.js';
8
+ import {applyShelf, createOutbox, flush, keepFiles, mergeTickets} from '../lib/sender.js';
9
9
  import fs from 'node:fs';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
@@ -237,3 +237,27 @@ test('a sent message stays in the outbox until its files have gone', () => {
237
237
  assert.ok(ob.fileIds().has(file(1).id), 'the file itself is still wanted: the thread shows it');
238
238
  } finally { fs.rmSync(dir, {recursive: true, force: true}); }
239
239
  });
240
+
241
+ test('files kept for the thread go when the manager no longer has their request', () => {
242
+ const remote = [{ref: 't1', messages: [{id: 'm1', attachments: [{id: 'f1'}]}, {id: 'm2'}]}];
243
+ const items = [{ref: 'm9', payload: {attachments: [{id: 'f3'}]}}];
244
+ assert.deepEqual(keepFiles(['f1', 'f2', 'f3'], remote, items), ['f1', 'f3'], 'f2 was on a request deleted at the manager');
245
+ assert.deepEqual(keepFiles(['f1'], [], []), []);
246
+ assert.deepEqual(keepFiles(undefined, remote), []);
247
+ });
248
+
249
+ test('remember(): a deleted request takes its files out of the keep list', () => {
250
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sup-outbox-'));
251
+ try {
252
+ const box = createOutbox(dir);
253
+ box.add('ticket', {ref: 't1', attachments: [{id: 'f1'}]});
254
+ box.mark('t1', {state: 'sent'});
255
+ box.fileSent('t1', 'f1');
256
+ box.remember([{ref: 't1', messages: [{id: 't1', attachments: [{id: 'f1'}]}]}], null);
257
+ assert.ok(box.fileIds().has('f1'), 'still on the manager: kept');
258
+ box.remember([], null);
259
+ assert.equal(box.fileIds().has('f1'), false, 'deleted on the manager: no longer kept');
260
+ } finally {
261
+ fs.rmSync(dir, {recursive: true, force: true});
262
+ }
263
+ });
@@ -40,7 +40,7 @@ const badLink = () => page('Unsubscribe', '<h1>That link is not right.</h1><p>Us
40
40
 
41
41
  /** The confirm page: the same e and k, posted back by one button. */
42
42
  function confirmPage(email, e, k) {
43
- return page('Unsubscribe', `<h1>Unsubscribe?</h1><p>${esc(email)} will get no more campaign or survey emails from this site.</p>`
43
+ return page('Unsubscribe', `<h1>Unsubscribe?</h1><p>${esc(email)} will get no more campaign, survey or onboarding emails from this site.</p>`
44
44
  + `<form method="post" action="${UNSUBSCRIBE_PATH}"><input type="hidden" name="e" value="${esc(e)}"><input type="hidden" name="k" value="${esc(k)}">`
45
45
  + `<button type="submit" class="btn btn-primary">Unsubscribe</button></form>`);
46
46
  }
@@ -107,6 +107,6 @@ export async function outreachPublicRoutes(fastify, opts = {}) {
107
107
  if (!opts.col) await ensureList();
108
108
  await suppression.add(email, 'unsubscribe-link');
109
109
  return html(reply).send(page('Unsubscribed',
110
- `<h1>You are unsubscribed.</h1><p>${esc(email)} will get no more campaign or survey emails from this site.</p>`));
110
+ `<h1>You are unsubscribed.</h1><p>${esc(email)} will get no more campaign, survey or onboarding emails from this site.</p>`));
111
111
  });
112
112
  }
@@ -12,7 +12,9 @@
12
12
  */
13
13
 
14
14
  /** Parameter names redacted whatever their case. */
15
- const SENSITIVE = new Set(['token', 't', 'key', 'hostkey', 'invitekey']);
15
+ // `code` and `state`: an OAuth2 callback (the mail sign-in) carries a live
16
+ // authorisation code and its one-time ticket in the query string.
17
+ const SENSITIVE = new Set(['token', 't', 'key', 'hostkey', 'invitekey', 'code', 'state']);
16
18
 
17
19
  /** Anything with secret or password in the name is redacted too. */
18
20
  const SENSITIVE_PATTERN = /secret|password|passwd/i;