domma-cms 1.6.36 → 1.6.38

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 (84) 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/compose-window.js +4 -0
  5. package/plugins/_lib/admin/mail/mail.css +1 -1
  6. package/plugins/_lib/admin/mail/reader-view.js +15 -6
  7. package/plugins/_lib/mail/compose.js +7 -2
  8. package/plugins/_lib/mail/message-action.js +56 -0
  9. package/plugins/_lib/mail/scheduler.js +2 -0
  10. package/plugins/_lib/mail/send.js +12 -3
  11. package/plugins/_lib/mail/store.js +16 -6
  12. package/plugins/_lib/outreach/index.js +1 -1
  13. package/plugins/_lib/outreach/ledger.js +10 -3
  14. package/plugins/_lib/outreach/sending.js +9 -3
  15. package/plugins/_lib/outreach/suppression.js +14 -3
  16. package/plugins/_lib/outreach/unsubscribe.js +13 -0
  17. package/plugins/blog/CLAUDE.md +11 -1
  18. package/plugins/blog/lib/cache.js +43 -0
  19. package/plugins/blog/lib/page.js +28 -9
  20. package/plugins/blog/plugin.js +14 -13
  21. package/plugins/blog/plugin.json +1 -1
  22. package/plugins/blog/plugin.public.js +1 -1
  23. package/plugins/blog/tests/cache.test.js +65 -0
  24. package/plugins/booking/CLAUDE.md +14 -2
  25. package/plugins/booking/admin/lib/diary-layout.js +18 -0
  26. package/plugins/booking/admin/views/diary.js +153 -152
  27. package/plugins/booking/admin/views/people.js +64 -66
  28. package/plugins/booking/admin/views/settings.js +31 -32
  29. package/plugins/booking/plugin.json +1 -1
  30. package/plugins/booking/tests/admin-diary.test.js +9 -1
  31. package/plugins/feedback/CLAUDE.md +55 -0
  32. package/plugins/feedback/admin/css/index.css +16 -1
  33. package/plugins/feedback/admin/lib/kit.js +106 -0
  34. package/plugins/feedback/admin/lib/settings.js +107 -0
  35. package/plugins/feedback/admin/views/feedback.js +4 -1
  36. package/plugins/feedback/admin/views/inbox.js +11 -2
  37. package/plugins/feedback/admin/views/send.js +74 -7
  38. package/plugins/feedback/docs/guide.md +9 -9
  39. package/plugins/feedback/lib/attachments.js +133 -0
  40. package/plugins/feedback/lib/cache.js +43 -0
  41. package/plugins/feedback/lib/receiver.js +36 -6
  42. package/plugins/feedback/lib/sender.js +70 -6
  43. package/plugins/feedback/lib/shape.js +111 -6
  44. package/plugins/feedback/plugin.js +171 -17
  45. package/plugins/feedback/plugin.json +2 -2
  46. package/plugins/feedback/tests/api.test.js +125 -3
  47. package/plugins/feedback/tests/settings.test.js +59 -0
  48. package/plugins/feedback/tests/shape.test.js +64 -0
  49. package/plugins/free-tier.lock.json +73 -61
  50. package/plugins/mail-reader/CLAUDE.md +15 -4
  51. package/plugins/mail-reader/plugin.json +1 -1
  52. package/plugins/shopping-cart/CLAUDE.md +28 -2
  53. package/plugins/shopping-cart/admin/css/index.css +3 -0
  54. package/plugins/shopping-cart/admin/views/orders.js +42 -10
  55. package/plugins/shopping-cart/admin/views/shop.js +1 -1
  56. package/plugins/shopping-cart/docs/guide.md +7 -3
  57. package/plugins/shopping-cart/lib/payments.js +15 -2
  58. package/plugins/shopping-cart/lib/refunds.js +51 -0
  59. package/plugins/shopping-cart/lib/sales.js +15 -8
  60. package/plugins/shopping-cart/lib/shop.js +38 -3
  61. package/plugins/shopping-cart/plugin.js +47 -19
  62. package/plugins/shopping-cart/plugin.json +2 -2
  63. package/plugins/shopping-cart/tests/api.test.js +52 -0
  64. package/plugins/shopping-cart/tests/commission.test.js +1 -1
  65. package/plugins/shopping-cart/tests/refunds.test.js +42 -0
  66. package/plugins/support/CLAUDE.md +51 -0
  67. package/plugins/support/admin/css/index.css +16 -1
  68. package/plugins/support/admin/lib/kit.js +192 -8
  69. package/plugins/support/admin/lib/settings.js +123 -0
  70. package/plugins/support/admin/views/desk.js +55 -12
  71. package/plugins/support/admin/views/inbox.js +17 -4
  72. package/plugins/support/admin/views/support.js +4 -1
  73. package/plugins/support/docs/guide.md +7 -9
  74. package/plugins/support/lib/attachments.js +133 -0
  75. package/plugins/support/lib/cache.js +43 -0
  76. package/plugins/support/lib/receiver.js +45 -10
  77. package/plugins/support/lib/sender.js +81 -10
  78. package/plugins/support/lib/shape.js +153 -12
  79. package/plugins/support/plugin.js +159 -29
  80. package/plugins/support/plugin.json +2 -2
  81. package/plugins/support/tests/api.test.js +125 -3
  82. package/plugins/support/tests/settings.test.js +68 -0
  83. package/plugins/support/tests/shape.test.js +51 -1
  84. package/server/routes/outreach-public.js +54 -10
@@ -18,7 +18,7 @@ test('a request needs a subject and a message; unknown choices fall back', () =>
18
18
  assert.equal(cleanRequest({body: 'x'}).error, 'Give it a short subject.');
19
19
  assert.equal(cleanRequest({subject: 'x'}).error, 'Tell us what you need.');
20
20
  const {request} = cleanRequest({subject: ' Site\n down ', body: ' help\r\nplease ', category: 'nope', priority: 'panic', where: '/shop'});
21
- assert.deepEqual(request, {category: 'help', priority: 'normal', subject: 'Site down', body: 'help\nplease', where: '/shop'});
21
+ assert.deepEqual(request, {category: 'help', priority: 'normal', subject: 'Site down', body: 'help\nplease', where: '/shop', attachments: []});
22
22
  assert.equal(cleanRequest({subject: 's', body: 'b', priority: 'urgent'}).request.priority, 'urgent');
23
23
  });
24
24
 
@@ -187,3 +187,53 @@ test('review: a request removed while a send is in flight is not half-deleted, a
187
187
  assert.ok(!posted.includes('T3') && !posted.includes('M3'));
188
188
  } finally { fs.rmSync(dir, {recursive: true, force: true}); }
189
189
  });
190
+
191
+ // ---- Attachments (1.1.4) ----
192
+
193
+ import {ATTACHMENTS, attachmentsOf, cleanAttachment, cleanAttachments, cleanMessage} from '../lib/shape.js';
194
+
195
+ const FID = '0f8fad5b-d9cb-469f-a165-70867728950e';
196
+ const file = (n = 1, extra = {}) => ({id: `${FID.slice(0, -1)}${n}`, name: `shot${n}.png`, type: 'image/png', size: 10, ...extra});
197
+
198
+ test('an attachment: a known type, a server-made id, a size within the cap', () => {
199
+ assert.deepEqual(cleanAttachment(file(1, {junk: 1})), {id: file(1).id, name: 'shot1.png', type: 'image/png', size: 10});
200
+ assert.equal(cleanAttachment(file(1, {type: 'image/svg+xml'})), null, 'SVG can carry script');
201
+ assert.equal(cleanAttachment(file(1, {type: 'text/html'})), null);
202
+ assert.equal(cleanAttachment(file(1, {id: '../../x'})), null);
203
+ assert.equal(cleanAttachment(file(1, {size: ATTACHMENTS.maxBytes + 1})), null);
204
+ assert.equal(cleanAttachment(file(1, {name: '/etc/"passwd'})).name, '_passwd');
205
+ assert.equal(cleanAttachments([file(1), file(2), file(3), file(4), file(1)]).length, 3, 'three at most, each once');
206
+ });
207
+
208
+ test('files ride on a message: announced not stored, carried by a follow-up, shown to the site', () => {
209
+ const {request} = cleanRequest({subject: 's', body: 'b', attachments: [file(1), {id: 'nope'}]});
210
+ assert.equal(request.attachments.length, 1);
211
+ const {data} = receivedTicket(buildTicketPayload({request, user: {name: 'Ann'}, ref: 'r1'}), 'acme', T0);
212
+ assert.equal(data.messages[0].attachments[0].stored, false);
213
+ assert.equal(cleanMessage({body: '', attachments: [file(2)]}).error, 'Write a message first.', 'files go with words');
214
+ const msg = buildMessagePayload({ticketRef: 'r1', ref: 'm1', body: 'and this', attachments: [file(2)]});
215
+ assert.equal(msg.attachments.length, 1);
216
+ assert.equal(buildMessagePayload({ticketRef: 'r1', ref: 'm2', close: true, attachments: [file(3)]}).attachments, undefined, 'a close carries none');
217
+ const next = addTurn(data, {from: 'customer', name: 'Ann', body: 'and this', id: 'm1', at: T1, attachments: msg.attachments}).data;
218
+ assert.deepEqual(attachmentsOf(next).map(a => [a.name, a.messageId, a.stored]), [['shot1.png', 'r1', false], ['shot2.png', 'm1', false]]);
219
+ const view = senderView({id: 'e1', data: next});
220
+ assert.deepEqual(view.messages[1].attachments, [{id: file(2).id, name: 'shot2.png', type: 'image/png', size: 10, stored: false}]);
221
+ assert.equal(view.messages[0].attachments[0].messageId, undefined);
222
+ });
223
+
224
+ test('a sent message stays in the outbox until its files have gone', () => {
225
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-files-'));
226
+ try {
227
+ const ob = createOutbox(dir);
228
+ ob.add('ticket', {ref: 'r1', subject: 's', body: 'b', attachments: [file(1)]});
229
+ ob.mark('r1', {state: 'sent'});
230
+ const remote = [{ref: 'r1', subject: 's', status: 'open', messages: [{id: 'r1', from: 'customer', body: 'b', at: T0}]}];
231
+ ob.remember(remote);
232
+ assert.equal(ob.all().length, 1, 'kept: its file has not gone');
233
+ assert.ok(ob.fileIds().has(file(1).id));
234
+ ob.fileSent('r1', file(1).id);
235
+ ob.remember(remote);
236
+ assert.equal(ob.all().length, 0, 'now done with');
237
+ assert.ok(ob.fileIds().has(file(1).id), 'the file itself is still wanted: the thread shows it');
238
+ } finally { fs.rmSync(dir, {recursive: true, force: true}); }
239
+ });
@@ -1,13 +1,22 @@
1
1
  /**
2
- * The one-click unsubscribe page every outreach email links to:
2
+ * The unsubscribe page every outreach email links to:
3
3
  *
4
- * GET /campaign-unsubscribe?e=<base64url address>&k=<HMAC>
4
+ * GET /campaign-unsubscribe?e=<base64url address>&k=<HMAC> a confirm page
5
+ * POST /campaign-unsubscribe (e, k as form fields or query) the opt-out
5
6
  *
6
7
  * Core's, not the sending Tool's, so the link in an email already sent keeps
7
8
  * working when that Tool lapses or is removed (PECR). The address goes on the
8
9
  * shared suppression list (`outreach-unsubscribes`), which every outreach
9
10
  * Tool checks before it sends.
10
11
  *
12
+ * Only the POST changes anything. Mail security scanners (Safe Links,
13
+ * Mimecast, Proofpoint) and mailbox previews GET every link in a message, so a
14
+ * GET that opted out unsubscribed people who never clicked - and silenced
15
+ * Surveys and Onboard for them too, the list being shared. The GET shows one
16
+ * button; the POST is also what a mail client's own Unsubscribe button sends
17
+ * (RFC 8058 one-click: the List-Unsubscribe URL with the body
18
+ * `List-Unsubscribe=One-Click`), so both land on the same handler.
19
+ *
11
20
  * A Campaigns Pro older than this engine registers the same path itself, and
12
21
  * plugins register before core: then core stands aside rather than collide.
13
22
  *
@@ -19,6 +28,7 @@ import {UNSUBSCRIBE_PATH, verifyUnsubscribe} from '../../plugins/_lib/outreach/u
19
28
  import {getUnsubscribeSecret} from '../services/outreachUnsubscribe.js';
20
29
 
21
30
  const SLUG = 'outreach-unsubscribes';
31
+ const FORM_TYPE = 'application/x-www-form-urlencoded';
22
32
 
23
33
  const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
24
34
 
@@ -26,6 +36,15 @@ function page(title, bodyHtml) {
26
36
  return `<!DOCTYPE html><html lang="en-GB"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><meta name="robots" content="noindex"><title>${esc(title)}</title><link rel="stylesheet" href="/dist/domma/domma.css"><style>main{max-width:36rem;margin:4rem auto;padding:0 1.25rem;line-height:1.6}</style></head><body><main>${bodyHtml}</main></body></html>`;
27
37
  }
28
38
 
39
+ const badLink = () => page('Unsubscribe', '<h1>That link is not right.</h1><p>Use the unsubscribe link from one of our emails.</p>');
40
+
41
+ /** The confirm page: the same e and k, posted back by one button. */
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>`
44
+ + `<form method="post" action="${UNSUBSCRIBE_PATH}"><input type="hidden" name="e" value="${esc(e)}"><input type="hidden" name="k" value="${esc(k)}">`
45
+ + `<button type="submit" class="btn btn-primary">Unsubscribe</button></form>`);
46
+ }
47
+
29
48
  /** The suppression list's collection - made if no outreach Tool ever made it. */
30
49
  async function ensureList() {
31
50
  try {
@@ -43,6 +62,14 @@ async function ensureList() {
43
62
  }
44
63
  }
45
64
 
65
+ /** The e and k of a request: the posted form first, else the URL (a one-click POST keeps them there). */
66
+ export function linkParams(req) {
67
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
68
+ const query = req.query || {};
69
+ const pick = (v) => (typeof v === 'string' ? v : '');
70
+ return {e: pick(body.e) || pick(query.e), k: pick(body.k) || pick(query.k)};
71
+ }
72
+
46
73
  /**
47
74
  * @param {import('fastify').FastifyInstance} fastify
48
75
  * @param {{col?: object, secret?: string}} [opts] - tests inject a collections service and a secret
@@ -52,17 +79,34 @@ export async function outreachPublicRoutes(fastify, opts = {}) {
52
79
  const col = opts.col || {createEntry, deleteEntry, listEntries};
53
80
  const suppression = createSuppression(col);
54
81
 
55
- fastify.get(UNSUBSCRIBE_PATH, async (req, reply) => {
56
- reply.header('Cache-Control', 'no-store').header('Referrer-Policy', 'no-referrer');
82
+ // The engine parses JSON only; the confirm form and one-click POSTs are
83
+ // form-encoded. Scoped to these routes, and tiny: two short fields.
84
+ if (!fastify.hasContentTypeParser(FORM_TYPE)) {
85
+ fastify.addContentTypeParser(FORM_TYPE, {parseAs: 'string', bodyLimit: 4096}, (req, body, done) => {
86
+ try { done(null, Object.fromEntries(new URLSearchParams(body))); } catch (err) { done(err); }
87
+ });
88
+ }
89
+
90
+ const verified = (req) => {
91
+ const {e, k} = linkParams(req);
57
92
  let email = null;
58
- try { email = verifyUnsubscribe(opts.secret || getUnsubscribeSecret(), req.query?.e, req.query?.k); } catch { email = null; }
59
- if (!email) {
60
- return reply.code(400).type('text/html; charset=utf-8')
61
- .send(page('Unsubscribe', '<h1>That link is not right.</h1><p>Use the unsubscribe link from one of our emails.</p>'));
62
- }
93
+ try { email = verifyUnsubscribe(opts.secret || getUnsubscribeSecret(), e, k); } catch { email = null; }
94
+ return {email, e, k};
95
+ };
96
+ const html = (reply) => reply.header('Cache-Control', 'no-store').header('Referrer-Policy', 'no-referrer').type('text/html; charset=utf-8');
97
+
98
+ fastify.get(UNSUBSCRIBE_PATH, async (req, reply) => {
99
+ const {email, e, k} = verified(req);
100
+ if (!email) return html(reply).code(400).send(badLink());
101
+ return html(reply).send(confirmPage(email, e, k));
102
+ });
103
+
104
+ fastify.post(UNSUBSCRIBE_PATH, async (req, reply) => {
105
+ const {email} = verified(req);
106
+ if (!email) return html(reply).code(400).send(badLink());
63
107
  if (!opts.col) await ensureList();
64
108
  await suppression.add(email, 'unsubscribe-link');
65
- return reply.type('text/html; charset=utf-8').send(page('Unsubscribed',
109
+ return html(reply).send(page('Unsubscribed',
66
110
  `<h1>You are unsubscribed.</h1><p>${esc(email)} will get no more campaign or survey emails from this site.</p>`));
67
111
  });
68
112
  }