domma-cms 0.115.2 → 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 +1 -1
- package/plugins/free-tier.lock.json +10 -10
- package/plugins/support/admin/views/inbox.js +11 -6
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/config.js +2 -2
- package/plugins/support/lib/mail.js +3 -2
- package/plugins/support/plugin.js +25 -5
- package/plugins/support/plugin.json +27 -7
- package/plugins/support/tests/api.test.js +25 -3
- package/plugins/support/tests/shape.test.js +2 -2
package/package.json
CHANGED
|
@@ -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@
|
|
3
|
+
"source": "dcms-marketplace@33c799d",
|
|
4
4
|
"plugins": {
|
|
5
5
|
"blog": {
|
|
6
6
|
"version": "1.9.4",
|
|
@@ -224,25 +224,25 @@
|
|
|
224
224
|
}
|
|
225
225
|
},
|
|
226
226
|
"support": {
|
|
227
|
-
"version": "1.0.
|
|
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": "
|
|
235
|
-
"admin/views/support.js": "
|
|
234
|
+
"admin/views/inbox.js": "53321d9efba883b7fb2b26fcfe72e902f484b9cb1eb85eb0e043b228a7035252",
|
|
235
|
+
"admin/views/support.js": "f5621fd99782e40d9faeeddf187c5c8b0412ee621ab53f03fb96d60b3f144478",
|
|
236
236
|
"collections/support-tickets.json": "aa1e6b4814fdfbbcd46d097edd93dfb838cc49f5232a86ba76b54c9b29d82ac5",
|
|
237
|
-
"config.js": "
|
|
238
|
-
"lib/mail.js": "
|
|
237
|
+
"config.js": "eb7ccc1169fa8b2be52a213536825492fedaec5fa73fd749cba9f498dd54b503",
|
|
238
|
+
"lib/mail.js": "d14aae3a59aad6af61451863cdaaf94fc4a87810b16e1dcb9c655dda6c803b3b",
|
|
239
239
|
"lib/receiver.js": "ef958be2a8fadcec10169ff76f10404d395f9c6b401a51642ede9218547d6262",
|
|
240
240
|
"lib/sender.js": "71e9146729e443df401a1b84e9778e9243d057012462e48a05e32a73f7309aa2",
|
|
241
241
|
"lib/shape.js": "7bef95c823ee4c6d4041152a58fdb2279dbe6797710a89e1339f8aca60cd0f89",
|
|
242
|
-
"plugin.js": "
|
|
243
|
-
"plugin.json": "
|
|
244
|
-
"tests/api.test.js": "
|
|
245
|
-
"tests/shape.test.js": "
|
|
242
|
+
"plugin.js": "7ff5ec08e469cc14aabca09c560d53d57d8c406eff44937c5464f8b60ae3b88f",
|
|
243
|
+
"plugin.json": "f41c2b77ab2f8986a67912c219b3a137af7fa500b5017d89ba43324a970d9508",
|
|
244
|
+
"tests/api.test.js": "c9483f1583d4449c6cb28624455b727e781063033878a79bd569162110980d36",
|
|
245
|
+
"tests/shape.test.js": "6d7b7a23dcb1933e4ed6e2f4b9fce2bf0b50125073f6ec60d15d6f2e6d7f8aa1"
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
}
|
|
@@ -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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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(
|
|
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'})); }),
|
|
@@ -46,7 +46,7 @@ export const supportView = {
|
|
|
46
46
|
try {
|
|
47
47
|
[mode, meta] = await Promise.all([api.mode(), api.meta()]);
|
|
48
48
|
} catch (err) {
|
|
49
|
-
fail(`Support could not load: ${err.message || err}. You can always email support@
|
|
49
|
+
fail(`Support could not load: ${err.message || err}. You can always email support@domma-cms.com.`);
|
|
50
50
|
return;
|
|
51
51
|
}
|
|
52
52
|
if (!here()) return;
|
|
@@ -17,12 +17,12 @@
|
|
|
17
17
|
* perSiteMinutes the window for perSiteMax
|
|
18
18
|
*/
|
|
19
19
|
export default {
|
|
20
|
-
email: 'support@
|
|
20
|
+
email: 'support@domma-cms.com',
|
|
21
21
|
phone: '',
|
|
22
22
|
hours: 'Monday to Friday, 9am to 5pm (UK time)',
|
|
23
23
|
responseTime: 'We answer within one working day, and urgent requests first.',
|
|
24
24
|
syncMinutes: 5,
|
|
25
|
-
notifyEmail: 'support@
|
|
25
|
+
notifyEmail: 'support@domma-cms.com',
|
|
26
26
|
perSiteMax: 20,
|
|
27
27
|
perSiteMinutes: 10
|
|
28
28
|
};
|
|
@@ -31,7 +31,7 @@ export function siteUrl() {
|
|
|
31
31
|
* must never fail the request that caused it, and callers do not await it - an
|
|
32
32
|
* unhandled rejection stops the whole server.
|
|
33
33
|
*
|
|
34
|
-
* @param {{to: string, subject: string, html: string, text: string, replyTo?: string}} msg
|
|
34
|
+
* @param {{to: string, subject: string, html: string, text: string, replyTo?: string, from?: string}} msg
|
|
35
35
|
* @returns {Promise<boolean>}
|
|
36
36
|
*/
|
|
37
37
|
export async function send(msg, {log} = {}) {
|
|
@@ -44,7 +44,8 @@ export async function send(msg, {log} = {}) {
|
|
|
44
44
|
const key = JSON.stringify([smtp.host, smtp.port, smtp.user, smtp.secure]);
|
|
45
45
|
if (!transport || transportFor !== key) { transport = await createTransport(smtp); transportFor = key; }
|
|
46
46
|
await sendEmail(transport, {
|
|
47
|
-
|
|
47
|
+
// `msg.from`: the support mailbox, for mail TO a customer (a reply then reaches it).
|
|
48
|
+
from: msg.from || smtp.fromAddress || 'noreply@example.com',
|
|
48
49
|
fromName: 'Domma Support',
|
|
49
50
|
to: msg.to, subject: msg.subject, html: msg.html, text: msg.text,
|
|
50
51
|
...(msg.replyTo && {replyTo: msg.replyTo})
|
|
@@ -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
|
|
332
|
+
return detail(e);
|
|
316
333
|
});
|
|
317
334
|
|
|
318
335
|
fastify.post('/tickets/:id/reply', {...can('manage'), bodyLimit: 32 * 1024}, async (request, reply) => {
|
|
@@ -326,11 +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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
346
|
+
const target = await replyTarget(d);
|
|
347
|
+
if (target.email && (body || (d.status === 'closed' && e.data.status !== 'closed'))) {
|
|
348
|
+
// From the support mailbox and replying to it: a customer who answers the email reaches Domma Support.
|
|
349
|
+
const support = contactCard(settings).email;
|
|
350
|
+
mail.send({to: target.email, ...(support && {from: support, replyTo: support}),
|
|
351
|
+
...toCustomerEmail({ticket: d, siteName: siteOf(d.site).name, link: siteLink(d.site, d.ref), body, resolved: d.status === 'closed'})}, {log});
|
|
332
352
|
}
|
|
333
|
-
return
|
|
353
|
+
return detail(next);
|
|
334
354
|
});
|
|
335
355
|
|
|
336
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.
|
|
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",
|
|
@@ -15,14 +15,26 @@
|
|
|
15
15
|
"icon": "headphones",
|
|
16
16
|
"description": "Ask Domma for help and follow the conversation (send). On the Domma server itself: the inbox of every site's requests (manage).",
|
|
17
17
|
"actions": [
|
|
18
|
-
{
|
|
19
|
-
|
|
18
|
+
{
|
|
19
|
+
"key": "send",
|
|
20
|
+
"label": "Send",
|
|
21
|
+
"description": "Ask Domma for help and read this site's requests and replies"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"key": "manage",
|
|
25
|
+
"label": "Manage",
|
|
26
|
+
"description": "On the Domma server: read and answer every site's requests"
|
|
27
|
+
}
|
|
20
28
|
],
|
|
21
|
-
"grant": [
|
|
29
|
+
"grant": [
|
|
30
|
+
"admin"
|
|
31
|
+
]
|
|
22
32
|
}
|
|
23
33
|
],
|
|
24
34
|
"admin": {
|
|
25
|
-
"css": [
|
|
35
|
+
"css": [
|
|
36
|
+
"admin/css/index.css"
|
|
37
|
+
],
|
|
26
38
|
"sidebar": [
|
|
27
39
|
{
|
|
28
40
|
"id": "support",
|
|
@@ -35,8 +47,16 @@
|
|
|
35
47
|
}
|
|
36
48
|
],
|
|
37
49
|
"routes": [
|
|
38
|
-
{
|
|
39
|
-
|
|
50
|
+
{
|
|
51
|
+
"path": "/plugins/support",
|
|
52
|
+
"view": "plugin-support",
|
|
53
|
+
"title": "Support - Domma CMS"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"path": "/plugins/support/:id",
|
|
57
|
+
"view": "plugin-support",
|
|
58
|
+
"title": "Support - Domma CMS"
|
|
59
|
+
}
|
|
40
60
|
],
|
|
41
61
|
"views": {
|
|
42
62
|
"plugin-support": {
|
|
@@ -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,
|
|
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,8 +108,11 @@ 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
|
-
|
|
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?');
|
|
114
|
+
assert.equal(toAnn.from, 'support@domma-cms.com', 'from the support mailbox');
|
|
115
|
+
assert.equal(toAnn.replyTo, 'support@domma-cms.com', 'so answering the email reaches support');
|
|
111
116
|
assert.ok(toAnn.text.includes(`https://acme.example.test/admin/#/plugins/support/${s.ref}`));
|
|
112
117
|
|
|
113
118
|
const list = await call(acme, 'GET', '/tickets');
|
|
@@ -179,7 +184,7 @@ test('the contact card: the manager\'s reaches every site; a standalone site sti
|
|
|
179
184
|
const alone = await site({env: {}});
|
|
180
185
|
const mode = (await call(alone, 'GET', '/mode')).body;
|
|
181
186
|
assert.equal(mode.connected, false);
|
|
182
|
-
assert.equal(mode.contact.email, 'support@
|
|
187
|
+
assert.equal(mode.contact.email, 'support@domma-cms.com');
|
|
183
188
|
assert.equal((await call(alone, 'POST', '/tickets', {subject: 's', body: 'b'})).status, 409);
|
|
184
189
|
});
|
|
185
190
|
|
|
@@ -263,3 +268,20 @@ test('review: a manager that never answers does not hang the screen', async () =
|
|
|
263
268
|
assert.equal(s.body.state, 'waiting');
|
|
264
269
|
} finally { hang = false; }
|
|
265
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
|
+
});
|
|
@@ -74,8 +74,8 @@ test('inbox updates are checked', () => {
|
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
test('the contact card lets through only what is well-formed', () => {
|
|
77
|
-
assert.deepEqual(contactCard({email: 'support@
|
|
78
|
-
{email: 'support@
|
|
77
|
+
assert.deepEqual(contactCard({email: 'support@domma-cms.com', phone: '+44 (0)1234 567890', hours: 'Mon\nFri', responseTime: 'Soon'}),
|
|
78
|
+
{email: 'support@domma-cms.com', phone: '+44 (0)1234 567890', hours: 'Mon Fri', responseTime: 'Soon'});
|
|
79
79
|
assert.deepEqual(contactCard({email: 'javascript:alert(1)', phone: 'call me'}), {email: '', phone: '', hours: '', responseTime: ''});
|
|
80
80
|
assert.equal(telHref('+44 (0)1234 567890'), 'tel:+4401234567890');
|
|
81
81
|
});
|