domma-cms 0.115.3 → 0.115.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.115.3",
3
+ "version": "0.115.4",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -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@9b9543d",
3
+ "source": "dcms-marketplace@33c799d",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.9.4",
@@ -224,14 +224,14 @@
224
224
  }
225
225
  },
226
226
  "support": {
227
- "version": "1.0.1",
227
+ "version": "1.0.2",
228
228
  "files": {
229
229
  "CLAUDE.md": "a54f3af90bc273622e67d0da0502da39a9e0962633d43d82eebf1228cf996f79",
230
230
  "admin/css/index.css": "43a792dbc08acafc9f79757ac96c3675a49abb39aab05a553c6871f6f2050a1e",
231
231
  "admin/lib/kit.js": "bc52d8dfe031b470b49c51e7170d46689a6b2892871e53ec0d6a44f37a91afc1",
232
232
  "admin/templates/support.html": "2bb69d0443a367a402e6160fe68f4af3e8983b578cfff9575b4df9a534fcb480",
233
233
  "admin/views/desk.js": "f0770c750dd340b83438c8f41b222b814c0937081201cb06b851a3fdea2952fd",
234
- "admin/views/inbox.js": "fac961363a827ce5139689464a6b9a65c685752b0771f4bf956fb1dcd8d95ccb",
234
+ "admin/views/inbox.js": "53321d9efba883b7fb2b26fcfe72e902f484b9cb1eb85eb0e043b228a7035252",
235
235
  "admin/views/support.js": "f5621fd99782e40d9faeeddf187c5c8b0412ee621ab53f03fb96d60b3f144478",
236
236
  "collections/support-tickets.json": "aa1e6b4814fdfbbcd46d097edd93dfb838cc49f5232a86ba76b54c9b29d82ac5",
237
237
  "config.js": "eb7ccc1169fa8b2be52a213536825492fedaec5fa73fd749cba9f498dd54b503",
@@ -239,9 +239,9 @@
239
239
  "lib/receiver.js": "ef958be2a8fadcec10169ff76f10404d395f9c6b401a51642ede9218547d6262",
240
240
  "lib/sender.js": "71e9146729e443df401a1b84e9778e9243d057012462e48a05e32a73f7309aa2",
241
241
  "lib/shape.js": "7bef95c823ee4c6d4041152a58fdb2279dbe6797710a89e1339f8aca60cd0f89",
242
- "plugin.js": "240cd2e0f0cd26037e7bcc76ce376caa29a54323a3bab4a96abc5e0558a9be06",
243
- "plugin.json": "239630f28577b687cdafb53b6f4be3e0dc0c48fb24a20092d094bfab68cb6f7b",
244
- "tests/api.test.js": "f2d3632fce50e926e1d0d6f965e1d10358f13a71cfcece0bdee8150d7dc70c07",
242
+ "plugin.js": "7ff5ec08e469cc14aabca09c560d53d57d8c406eff44937c5464f8b60ae3b88f",
243
+ "plugin.json": "f41c2b77ab2f8986a67912c219b3a137af7fa500b5017d89ba43324a970d9508",
244
+ "tests/api.test.js": "c9483f1583d4449c6cb28624455b727e781063033878a79bd569162110980d36",
245
245
  "tests/shape.test.js": "6d7b7a23dcb1933e4ed6e2f4b9fce2bf0b50125073f6ec60d15d6f2e6d7f8aa1"
246
246
  }
247
247
  }
@@ -101,10 +101,14 @@ export async function mount(root, ctx) {
101
101
  const replace = (t) => { tickets.value = tickets.peek().map(x => (x.id === t.id ? {...x, ...t} : x)); refreshBadge(); };
102
102
 
103
103
  async function open(tid) {
104
- let t = byId(tid);
105
- if (!t) {
106
- try { t = await api.ticket(tid); } catch { E.toast('That request is no longer here.', {type: 'warning'}); return; }
107
- }
104
+ // Read fresh: the panel shows where a reply will be emailed, which only the detail route works out.
105
+ let t;
106
+ try { t = await api.ticket(tid); } catch { t = byId(tid); }
107
+ if (!t) { E.toast('That request is no longer here.', {type: 'warning'}); return; }
108
+ const rt = t.replyTo || {email: t.userEmail || '', source: t.userEmail ? 'profile' : 'none'};
109
+ const replyNote = rt.source === 'registration' ? `Replies are emailed to ${rt.email} - the address the site was bought with.`
110
+ : rt.source === 'profile' ? `Replies are emailed to ${rt.email} - from the requester's profile on their site (this site was not bought through Customers).`
111
+ : 'This customer has no email on record - replies show only in their admin.';
108
112
  const draft = M.observable('');
109
113
  const after = M.observable('answered');
110
114
  const notes = M.observable(t.notes || '');
@@ -145,7 +149,8 @@ export async function mount(root, ctx) {
145
149
  </dl>
146
150
  <div class="sp-thread-host"></div>
147
151
  <div class="sp-reply">
148
- <label class="form-label" for="sp-answer">Reply <span data-help="The customer sees it in their admin and gets it by email${t.userEmail ? '' : ' (this one has no email on record, so only in their admin)'}."></span></label>
152
+ <label class="form-label" for="sp-answer">Reply <span data-help="The customer sees it in their admin and gets it by email."></span></label>
153
+ <p class="sp-note sp-small">${esc(replyNote)}</p>
149
154
  <textarea id="sp-answer" class="form-textarea" rows="5" maxlength="8000" data-model="draft.value" placeholder="Write to ${esc(t.userName || 'the customer')}"></textarea>
150
155
  <div class="sp-actions">
151
156
  <label class="sp-inline">Then <select class="form-select" data-model="after.value"><option value="answered">wait for them</option><option value="closed">mark resolved</option></select></label>
@@ -171,7 +176,7 @@ export async function mount(root, ctx) {
171
176
  const next = await api.reply(tid, {body: draft.peek(), status: after.peek()});
172
177
  replace(next);
173
178
  draft.value = '';
174
- E.toast(t.userEmail ? `Sent - ${t.userName || 'the customer'} has been emailed.` : 'Sent - it shows in their admin.', {type: 'success'});
179
+ E.toast(rt.email ? `Sent - emailed to ${rt.email}.` : 'Sent - it shows in their admin.', {type: 'success'});
175
180
  }),
176
181
  resolve: () => run(async () => { replace(await api.reply(tid, {status: 'closed'})); E.toast('Resolved.', {type: 'success'}); }),
177
182
  reopen: () => run(async () => { replace(await api.update(tid, {status: 'open'})); }),
@@ -221,6 +221,21 @@ async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings
221
221
  try { await rx.ensureCollection(); } catch (err) { log.error(`[support] collection: ${err.message}`); }
222
222
 
223
223
  const siteOf = (slug) => directory()[slug] || {name: slug, host: ''};
224
+ /**
225
+ * Where Domma's replies to a site's request are emailed. A site that was BOUGHT has an owner on
226
+ * the manager (Customer Registration's ownerEmail service): that address, which only Domma and the
227
+ * owner's Manage-my-site link can change. Otherwise - a site made by hand - the email on the
228
+ * requester's profile, which that site's own admin controls.
229
+ */
230
+ const OWNER_KEY = Symbol.for('domma.customer-registration');
231
+ const ownerLookup = options.ownerEmail
232
+ || (async (slug) => (globalThis[OWNER_KEY]?.version >= 1 ? globalThis[OWNER_KEY].services.ownerEmail(slug) : null));
233
+ async function replyTarget(d) {
234
+ let owner = null;
235
+ try { owner = await ownerLookup(d.site); } catch { /* no registration service */ }
236
+ if (owner && /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/.test(owner)) return {email: owner, source: 'registration'};
237
+ return {email: d.userEmail || '', source: d.userEmail ? 'profile' : 'none'};
238
+ }
224
239
  const inboxLink = (id) => `${mail.siteUrl()}/admin/#/plugins/support/${encodeURIComponent(id)}`;
225
240
  const siteLink = (slug, ref) => {
226
241
  const host = siteOf(slug).host;
@@ -280,6 +295,8 @@ async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings
280
295
 
281
296
  const row = (e, dir = directory()) => ({id: e.id, ...e.data, siteName: dir[e.data.site]?.name || e.data.site,
282
297
  siteHost: dir[e.data.site]?.host || '', createdAt: e.meta?.createdAt, updatedAt: e.meta?.updatedAt});
298
+ /** One request for the panel: plus where a reply will be emailed, and why. */
299
+ const detail = async (e) => ({...row(e), replyTo: await replyTarget(e.data)});
283
300
 
284
301
  fastify.get('/mode', can('manage'), async () => ({mode: 'receiver', connected: true, contact: contactCard(settings),
285
302
  notifyEmail: settings.notifyEmail || ''}));
@@ -312,7 +329,7 @@ async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings
312
329
  fastify.get('/tickets/:id', can('manage'), async (request, reply) => {
313
330
  const e = await rx.getTicket(request.params.id);
314
331
  if (!e) return reply.code(404).send({error: 'No such request.'});
315
- return row(e);
332
+ return detail(e);
316
333
  });
317
334
 
318
335
  fastify.post('/tickets/:id/reply', {...can('manage'), bodyLimit: 32 * 1024}, async (request, reply) => {
@@ -326,13 +343,14 @@ async function receiverRoutes(fastify, {can, notify, mail, receiverMod, settings
326
343
  const next = await rx.dommaTurn(e.id, {body, status, by, msgId: randomUUID()});
327
344
  const d = next.data;
328
345
  // The customer hears of a reply, and of their request being resolved - not of a quiet status change back to open.
329
- if (d.userEmail && (body || (d.status === 'closed' && e.data.status !== 'closed'))) {
346
+ const target = await replyTarget(d);
347
+ if (target.email && (body || (d.status === 'closed' && e.data.status !== 'closed'))) {
330
348
  // From the support mailbox and replying to it: a customer who answers the email reaches Domma Support.
331
349
  const support = contactCard(settings).email;
332
- mail.send({to: d.userEmail, ...(support && {from: support, replyTo: support}),
350
+ mail.send({to: target.email, ...(support && {from: support, replyTo: support}),
333
351
  ...toCustomerEmail({ticket: d, siteName: siteOf(d.site).name, link: siteLink(d.site, d.ref), body, resolved: d.status === 'closed'})}, {log});
334
352
  }
335
- return row(next);
353
+ return detail(next);
336
354
  });
337
355
 
338
356
  fastify.put('/tickets/:id', can('manage'), async (request, reply) => {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "support",
3
3
  "displayName": "Support",
4
- "version": "1.0.1",
4
+ "version": "1.0.2",
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.",
7
7
  "author": "Domma CMS",
@@ -60,7 +60,9 @@ before(async () => {
60
60
  manager = Fastify();
61
61
  await manager.register(support, {
62
62
  auth: {authenticate: async (req) => { req.user = {id: 'v1', name: 'Darryl'}; }, requirePermission: () => async () => {}},
63
- prefix: '/api/plugins/support', mode: 'receiver', mail, dataDir: await fs.mkdtemp(path.join(dataDir, 'manager-')),
63
+ prefix: '/api/plugins/support', mode: 'receiver', mail,
64
+ // acme was bought (an owner on the manager); beta was made by hand.
65
+ ownerEmail: async (slug) => (slug === 'acme' ? 'owner@acme.test' : null), dataDir: await fs.mkdtemp(path.join(dataDir, 'manager-')),
64
66
  settings: {perSiteMax: 30, perSiteMinutes: 10, phone: '+44 1234 567890', hours: 'Always', notifyEmail: 'support@dommajs.test'},
65
67
  slugFromToken: async (h) => TOKENS[String(h || '').replace(/^Bearer\s+/i, '')] || null,
66
68
  siteDirectory: () => SITES,
@@ -106,7 +108,8 @@ test('Domma replies: the customer is emailed a link to their own admin, and the
106
108
  mails.length = 0;
107
109
  const r = await call(manager, 'POST', `/api/plugins/support/tickets/${t.id}/reply`, {body: 'Pages > New page.'});
108
110
  assert.equal(r.body.status, 'answered');
109
- const toAnn = mails.find(x => x.to === 'ann@acme.test');
111
+ assert.ok(!mails.some(x => x.to === 'ann@acme.test'), 'not the profile email: acme was bought');
112
+ const toAnn = mails.find(x => x.to === 'owner@acme.test');
110
113
  assert.equal(toAnn.subject, 'Re: How do I add a page?');
111
114
  assert.equal(toAnn.from, 'support@domma-cms.com', 'from the support mailbox');
112
115
  assert.equal(toAnn.replyTo, 'support@domma-cms.com', 'so answering the email reaches support');
@@ -265,3 +268,20 @@ test('review: a manager that never answers does not hang the screen', async () =
265
268
  assert.equal(s.body.state, 'waiting');
266
269
  } finally { hang = false; }
267
270
  });
271
+
272
+ test('replies go to the address a site was bought with; a hand-made site falls back to the profile', async () => {
273
+ const acme = await site({token: 'tok-acme'});
274
+ const beta = await site({token: 'tok-beta', user: {id: 'b1', name: 'Bob', email: 'bob@beta.test'}});
275
+ const a = (await call(acme, 'POST', '/tickets', {subject: 'Owner routing', body: 'x'})).body;
276
+ const b = (await call(beta, 'POST', '/tickets', {subject: 'Profile routing', body: 'y'})).body;
277
+ const list = (await call(manager, 'GET', '/api/plugins/support/tickets')).body.tickets;
278
+ const ta = list.find(t => t.ref === a.ref);
279
+ const tb = list.find(t => t.ref === b.ref);
280
+ const da = (await call(manager, 'GET', `/api/plugins/support/tickets/${ta.id}`)).body;
281
+ assert.deepEqual(da.replyTo, {email: 'owner@acme.test', source: 'registration'});
282
+ const db = (await call(manager, 'GET', `/api/plugins/support/tickets/${tb.id}`)).body;
283
+ assert.deepEqual(db.replyTo, {email: 'bob@beta.test', source: 'profile'});
284
+ mails.length = 0;
285
+ await call(manager, 'POST', `/api/plugins/support/tickets/${tb.id}/reply`, {body: 'hi Bob'});
286
+ assert.ok(mails.some(m => m.to === 'bob@beta.test'));
287
+ });