domma-cms 0.113.0 → 0.114.1

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,213 @@
1
+ /**
2
+ * Support - the SITE side, on every site the manager started.
3
+ *
4
+ * Everything the customer writes goes into this site's outbox FIRST and is then
5
+ * sent. The manager's token for this site rotates whenever either end restarts,
6
+ * and for those seconds a send answers 401 or does not connect at all - so a
7
+ * request is never "sent or lost", it is "sent, or waiting". Waiting ones go
8
+ * again on the next sync (every few minutes) and whenever the screen is opened.
9
+ *
10
+ * Requests are known here by `ref` (made on this site) because a waiting one
11
+ * has no manager id yet. A follow-up message carries its request's ref, and
12
+ * waits behind it if the request itself has not gone.
13
+ *
14
+ * The outbox also keeps the last view of each request from the manager (for the
15
+ * sidebar badge, which must never call the manager), which Domma replies this
16
+ * site has seen, and the contact card the manager last sent.
17
+ *
18
+ * @module support/lib/sender
19
+ */
20
+ import {randomUUID} from 'node:crypto';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import {lastDommaAt} from './shape.js';
24
+
25
+ /**
26
+ * Where this site's manager is, from the environment its manager set.
27
+ * @returns {{url: string, token: string, slug: string}|null}
28
+ */
29
+ export function managerOf(env = process.env) {
30
+ if (!env.MANAGER_URL || !env.FLEET_TOKEN) return null;
31
+ return {url: String(env.MANAGER_URL).replace(/\/+$/, ''), token: String(env.FLEET_TOKEN), slug: String(env.SITE_SLUG || '')};
32
+ }
33
+
34
+ export const newRef = () => randomUUID();
35
+
36
+ /** The outbox, in data/outbox.json. Written whole, through a temp file. */
37
+ export function createOutbox(dataDir) {
38
+ const file = path.join(dataDir, 'outbox.json');
39
+ const read = () => {
40
+ try {
41
+ const j = JSON.parse(fs.readFileSync(file, 'utf8'));
42
+ return {
43
+ items: Array.isArray(j.items) ? j.items : [],
44
+ seen: j.seen && typeof j.seen === 'object' ? j.seen : {},
45
+ tickets: Array.isArray(j.tickets) ? j.tickets : [],
46
+ contact: j.contact && typeof j.contact === 'object' ? j.contact : null
47
+ };
48
+ } catch { return {items: [], seen: {}, tickets: [], contact: null}; }
49
+ };
50
+ const write = (state) => {
51
+ fs.mkdirSync(dataDir, {recursive: true});
52
+ fs.writeFileSync(`${file}.tmp`, JSON.stringify(state, null, 2));
53
+ fs.renameSync(`${file}.tmp`, file);
54
+ };
55
+ const update = (fn) => { const s = read(); const r = fn(s); write(s); return r; };
56
+ return {
57
+ all: () => read().items,
58
+ seen: () => read().seen,
59
+ tickets: () => read().tickets,
60
+ contact: () => read().contact,
61
+ add(kind, payload, ticketRef) {
62
+ return update(s => {
63
+ const item = {ref: payload.ref, kind, ticketRef: ticketRef || payload.ref, state: 'waiting', payload,
64
+ createdAt: new Date().toISOString(), lastError: ''};
65
+ s.items.push(item);
66
+ return item;
67
+ });
68
+ },
69
+ mark(ref, patch) {
70
+ return update(s => {
71
+ const item = s.items.find(i => i.ref === ref);
72
+ if (item) Object.assign(item, patch);
73
+ return item || null;
74
+ });
75
+ },
76
+ markSeen(ticketRef, at) { update(s => { s.seen[ticketRef] = at; }); },
77
+ /** The manager's latest view: what the badge reads. Sent items it already has are dropped from the outbox. */
78
+ remember(remote, contact) {
79
+ update(s => {
80
+ s.tickets = remote.map(t => ({ref: t.ref, subject: t.subject, status: t.status, dommaAt: lastDommaAt(t)}));
81
+ if (contact) s.contact = contact;
82
+ const byRef = new Map(remote.map(t => [t.ref, t]));
83
+ const have = new Set(remote.flatMap(t => [t.ref, ...(t.messages || []).map(m => m.id)]));
84
+ s.items = s.items.filter((i) => {
85
+ if (i.state !== 'sent') return true;
86
+ if (have.has(i.ref)) return false; // arrived: done with it
87
+ const t = byRef.get(i.kind === 'ticket' ? i.ref : i.ticketRef);
88
+ if (!t) return true; // not in the view yet
89
+ if (i.kind === 'message' && !i.payload?.body) return false; // a close: no message to look for
90
+ // The manager answered 2xx yet its request lacks this message (lost to a concurrent
91
+ // write, say): send it again. Not once the conversation is long enough to have trimmed it.
92
+ if (i.kind === 'message' && (t.messages || []).length < 500) { i.state = 'waiting'; i.lastError = 'Sent again: it did not arrive.'; }
93
+ return true;
94
+ });
95
+ });
96
+ }
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Talk to the manager. `fetchImpl` is swapped in tests; nothing here throws -
102
+ * every call answers `{ok, status?, body?, error?}`.
103
+ */
104
+ export function managerApi(manager, fetchImpl = globalThis.fetch) {
105
+ const call = async (method, route, body) => {
106
+ try {
107
+ const res = await fetchImpl(`${manager.url}/api/plugins/support/fleet/${route}`, {
108
+ method,
109
+ headers: {authorization: `Bearer ${manager.token}`, ...(body && {'content-type': 'application/json'})},
110
+ ...(body && {body: JSON.stringify(body)}),
111
+ signal: AbortSignal.timeout(10_000)
112
+ });
113
+ let json = null;
114
+ try { json = await res.json(); } catch { /* not JSON */ }
115
+ if (res.status >= 200 && res.status < 300) return {ok: true, status: res.status, body: json};
116
+ return {ok: false, status: res.status, error: json?.error || `The Domma server answered ${res.status}.`};
117
+ } catch (err) {
118
+ return {ok: false, error: `Could not reach the Domma server: ${err.message}`};
119
+ }
120
+ };
121
+ return {
122
+ open: (payload) => call('POST', 'tickets', payload),
123
+ message: (payload) => call('POST', 'messages', payload),
124
+ mine: () => call('GET', 'mine')
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Send whatever is waiting, oldest first. Safe to repeat: the manager answers a
130
+ * request or message it already has with what it already made.
131
+ *
132
+ * @returns {Promise<{sent: number, waiting: number, error: string}>}
133
+ */
134
+ export function flush(outbox, api) {
135
+ // One flush at a time per outbox: the timer, the badge's nudge, a screen load and a send can
136
+ // all ask at once, and two flushes would POST the same waiting item twice.
137
+ if (!outbox.flushing) outbox.flushing = flushOnce(outbox, api).finally(() => { outbox.flushing = null; });
138
+ return outbox.flushing;
139
+ }
140
+
141
+ /** Statuses that mean "try again later"; any other 4xx means the manager will never take it. */
142
+ const RETRY = new Set([401, 408, 429]);
143
+
144
+ async function flushOnce(outbox, api) {
145
+ let sent = 0;
146
+ let error = '';
147
+ for (const item of outbox.all().filter(i => i.state === 'waiting')) {
148
+ if (item.kind === 'message') {
149
+ const parent = outbox.all().find(i => i.kind === 'ticket' && i.ref === item.ticketRef);
150
+ if (parent?.state === 'waiting') continue; // goes after its request
151
+ if (parent?.state === 'refused') { outbox.mark(item.ref, {state: 'refused', lastError: 'Its request was not accepted.'}); continue; }
152
+ }
153
+ const r = item.kind === 'ticket' ? await api.open(item.payload) : await api.message(item.payload);
154
+ if (r.ok) {
155
+ outbox.mark(item.ref, {state: 'sent', lastError: '', sentAt: new Date().toISOString()});
156
+ sent++;
157
+ } else {
158
+ error = r.error || 'Not sent.';
159
+ // A 4xx other than 401/408/429 is permanent (400 malformed, 404 its request is gone, 413 too big):
160
+ // stop retrying it. 401 is a token the manager does not know yet; 5xx and no answer are transient.
161
+ const permanent = r.status >= 400 && r.status < 500 && !RETRY.has(r.status);
162
+ outbox.mark(item.ref, {lastError: error, triedAt: new Date().toISOString(), ...(permanent && {state: 'refused'})});
163
+ }
164
+ }
165
+ return {sent, waiting: outbox.all().filter(i => i.state === 'waiting').length, error};
166
+ }
167
+
168
+ /**
169
+ * The requests the screen shows: the manager's view of each, plus anything
170
+ * still waiting here (a request the manager has never seen, or a message it
171
+ * has not got yet, shown at the end of its conversation as "sending").
172
+ *
173
+ * @param {object[]} items - the outbox
174
+ * @param {object[]|null} remote - the manager's /mine, or null when it could not be reached
175
+ * @param {Object<string, string>} seen - request ref -> the Domma message time this site has read
176
+ * @param {object[]} [cached] - the last remote view, used while the manager cannot be reached
177
+ * @returns {object[]}
178
+ */
179
+ export function mergeTickets(items, remote, seen = {}, cached = []) {
180
+ const out = new Map();
181
+ for (const t of remote || []) {
182
+ const dommaAt = lastDommaAt(t);
183
+ out.set(t.ref, {...t, local: false, unread: Boolean(dommaAt && seen[t.ref] !== dommaAt)});
184
+ }
185
+ if (!remote) {
186
+ // Offline: what we last heard, so the list does not empty while the manager restarts.
187
+ for (const c of cached) {
188
+ out.set(c.ref, {ref: c.ref, id: '', subject: c.subject, status: c.status, statusLabel: '', messages: [], local: false, stale: true,
189
+ unread: Boolean(c.dommaAt && seen[c.ref] !== c.dommaAt)});
190
+ }
191
+ }
192
+ for (const i of items) {
193
+ const p = i.payload || {};
194
+ if (i.kind === 'ticket' && !out.has(i.ref) && i.state !== 'sent') {
195
+ out.set(i.ref, {
196
+ id: '', ref: i.ref, category: p.category, priority: p.priority, subject: p.subject, where: p.where || '',
197
+ userName: p.user?.name || '', status: i.state === 'refused' ? 'refused' : 'waiting',
198
+ statusLabel: i.state === 'refused' ? 'Not accepted' : 'Waiting to send',
199
+ messages: [{id: i.ref, from: 'customer', name: p.user?.name || '', body: p.body, at: i.createdAt, pending: true}],
200
+ lastFrom: 'customer', lastAt: i.createdAt, createdAt: i.createdAt, updatedAt: i.createdAt,
201
+ local: true, unread: false, lastError: i.lastError || ''
202
+ });
203
+ }
204
+ if (i.kind === 'message' && i.state !== 'sent' && p.body) {
205
+ const t = out.get(i.ticketRef);
206
+ if (t && !(t.messages || []).some(m => m.id === i.ref)) {
207
+ t.messages = [...(t.messages || []), {id: i.ref, from: 'customer', name: p.user?.name || '', body: p.body, at: i.createdAt,
208
+ pending: i.state === 'waiting', refused: i.state === 'refused'}];
209
+ }
210
+ }
211
+ }
212
+ return [...out.values()].sort((a, b) => String(b.lastAt || b.createdAt).localeCompare(String(a.lastAt || a.createdAt)));
213
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Support - the pure half: what a request is, what a conversation holds, what
3
+ * each side may say, and the emails both sides get.
4
+ *
5
+ * Nothing here touches the disk, the network or the clock (callers pass `now`),
6
+ * so all of it is tested on its own (tests/shape.test.js).
7
+ *
8
+ * A request is a CONVERSATION: the customer's opening message, then turns from
9
+ * either side. Its status follows whose turn it is:
10
+ *
11
+ * open waiting on Domma - a new request, or the customer wrote again
12
+ * answered waiting on you - Domma replied
13
+ * closed resolved - either side said so; writing again reopens it
14
+ *
15
+ * @module support/lib/shape
16
+ */
17
+
18
+ /** What a request is about. The icon is a Domma icon name. */
19
+ export const CATEGORIES = [
20
+ {key: 'help', label: 'I need help with something', short: 'Help', icon: 'help-circle'},
21
+ {key: 'problem', label: 'Something is not working', short: 'Problem', icon: 'alert-triangle'},
22
+ {key: 'billing', label: 'Billing or my account', short: 'Billing', icon: 'credit-card'},
23
+ {key: 'other', label: 'Something else', short: 'Other', icon: 'message-square'}
24
+ ];
25
+
26
+ export const PRIORITIES = [
27
+ {key: 'normal', label: 'Normal', hint: 'We answer in order, usually within a working day.'},
28
+ {key: 'urgent', label: 'Urgent', hint: 'My site is down, or I cannot take orders, bookings or payments.'}
29
+ ];
30
+
31
+ export const STATUSES = [
32
+ {key: 'open', label: 'Waiting on Domma', inboxLabel: 'Open', tone: 'warning'},
33
+ {key: 'answered', label: 'Domma replied', inboxLabel: 'Answered', tone: 'info'},
34
+ {key: 'closed', label: 'Resolved', inboxLabel: 'Resolved', tone: 'success'}
35
+ ];
36
+
37
+ export const CATEGORY_KEYS = CATEGORIES.map(c => c.key);
38
+ export const PRIORITY_KEYS = PRIORITIES.map(p => p.key);
39
+ export const STATUS_KEYS = STATUSES.map(s => s.key);
40
+ export const statusLabel = (key) => STATUSES.find(s => s.key === key)?.label ?? key;
41
+ export const categoryLabel = (key) => CATEGORIES.find(c => c.key === key)?.short ?? key;
42
+
43
+ /** Longest a field may be. The routes' bodyLimit (32 KB) is the outer wall. */
44
+ export const LIMITS = {subject: 140, body: 8000, where: 300, notes: 8000, messages: 500};
45
+
46
+ // Control characters go (all but tab and newline): each is 6 bytes once JSON-escaped, so 8000 of them
47
+ // would pass here and then be refused by the manager's 32 KB body limit, forever.
48
+ const text = (v, max) => String(v ?? '').replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, '').trim().slice(0, max);
49
+ const line = (v, max) => text(v, max).replace(/\s*\n\s*/g, ' ');
50
+ const EMAIL_RE = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
51
+
52
+ /**
53
+ * A new request as the customer typed it, cleaned. Never carries who sent it -
54
+ * that comes from the signed-in user on the server, not from the form.
55
+ *
56
+ * @param {object} body
57
+ * @returns {{request?: object, error?: string}}
58
+ */
59
+ export function cleanRequest(body = {}) {
60
+ const category = CATEGORY_KEYS.includes(body.category) ? body.category : 'help';
61
+ const priority = PRIORITY_KEYS.includes(body.priority) ? body.priority : 'normal';
62
+ const subject = line(body.subject, LIMITS.subject);
63
+ const message = text(body.body, LIMITS.body);
64
+ if (!subject) return {error: 'Give it a short subject.'};
65
+ if (!message) return {error: 'Tell us what you need.'};
66
+ return {request: {category, priority, subject, body: message, where: line(body.where, LIMITS.where)}};
67
+ }
68
+
69
+ /** One more message in a conversation. */
70
+ export function cleanMessage(body = {}) {
71
+ const message = text(body.body, LIMITS.body);
72
+ if (!message) return {error: 'Write a message first.'};
73
+ return {message: {body: message}};
74
+ }
75
+
76
+ const who = (user = {}) => ({id: text(user.id, 64), name: line(user.name, 120), email: text(user.email, 200)});
77
+
78
+ /**
79
+ * What a site sends the manager for a new request. `site` is informational
80
+ * only - the manager files it under the site its token names.
81
+ */
82
+ export function buildTicketPayload({request, user = {}, cmsVersion = '', site = '', ref}) {
83
+ return {ref: text(ref, 64), ...request, cmsVersion: text(cmsVersion, 20), site: text(site, 64), user: who(user)};
84
+ }
85
+
86
+ /** What a site sends for another message, or to close a request (`body` empty, `close: true`). */
87
+ export function buildMessagePayload({ticketRef, ref, body = '', close = false, user = {}}) {
88
+ return {ticketRef: text(ticketRef, 64), ref: text(ref, 64), body: text(body, LIMITS.body), close: Boolean(close), user: who(user)};
89
+ }
90
+
91
+ /**
92
+ * A request as the MANAGER stores it. The slug is the one the token resolved
93
+ * to; anything the body claims about the site is ignored.
94
+ *
95
+ * @param {object} payload
96
+ * @param {string} slug
97
+ * @param {string} now - ISO time
98
+ * @returns {{data?: object, error?: string}}
99
+ */
100
+ export function receivedTicket(payload = {}, slug, now) {
101
+ const {request, error} = cleanRequest(payload);
102
+ if (error) return {error};
103
+ const u = who(payload.user && typeof payload.user === 'object' ? payload.user : {});
104
+ const ref = text(payload.ref, 64);
105
+ return {
106
+ data: {
107
+ category: request.category,
108
+ priority: request.priority,
109
+ subject: request.subject,
110
+ where: request.where,
111
+ site: slug,
112
+ ref,
113
+ cmsVersion: text(payload.cmsVersion, 20),
114
+ userId: u.id,
115
+ userName: u.name || 'A site admin',
116
+ userEmail: EMAIL_RE.test(u.email) ? u.email : '',
117
+ status: 'open',
118
+ messages: [{id: ref || 'm0', from: 'customer', name: u.name || 'A site admin', body: request.body, at: now}],
119
+ lastFrom: 'customer',
120
+ lastAt: now,
121
+ notes: '',
122
+ history: [{at: now, by: u.name || 'A site admin', what: 'Opened'}]
123
+ }
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Add a turn to a conversation, PURE: returns the new data. A message whose id
129
+ * is already there (a retry that did arrive) changes nothing.
130
+ *
131
+ * @param {object} data - the stored request
132
+ * @param {{from: 'customer'|'domma', name: string, body?: string, id: string, at: string, status?: string, close?: boolean}} turn
133
+ * @returns {{data: object, added: boolean}}
134
+ */
135
+ export function addTurn(data, {from, name, body = '', id, at, status, close = false}) {
136
+ const messages = Array.isArray(data.messages) ? [...data.messages] : [];
137
+ if (id && messages.some(m => m.id === id)) return {data, added: false};
138
+ const history = Array.isArray(data.history) ? [...data.history] : [];
139
+ const next = {...data};
140
+ let added = false;
141
+ if (body) {
142
+ messages.push({id, from, name, body, at});
143
+ next.lastFrom = from;
144
+ next.lastAt = at;
145
+ added = true;
146
+ history.push({at, by: name, what: from === 'domma' ? 'Replied' : 'Wrote'});
147
+ }
148
+ // Whose turn it is now. Domma may choose (answered, or closed); the customer reopens by writing.
149
+ let to = data.status;
150
+ if (close) to = 'closed';
151
+ else if (from === 'customer' && body) to = 'open';
152
+ else if (from === 'domma') to = STATUS_KEYS.includes(status) ? status : (body ? 'answered' : data.status);
153
+ if (to !== data.status) {
154
+ history.push({at, by: name, what: to === 'closed' ? 'Resolved' : to === 'open' && data.status === 'closed' ? 'Reopened' : `Status: ${statusLabel(to)}`});
155
+ added = true;
156
+ }
157
+ next.status = to;
158
+ next.messages = messages.slice(-LIMITS.messages);
159
+ next.history = history.slice(-200);
160
+ return {data: next, added};
161
+ }
162
+
163
+ /**
164
+ * What a site may read of one of its own requests. A whitelist: internal notes,
165
+ * history and other sites stay on the manager.
166
+ */
167
+ export function senderView(entry) {
168
+ const d = entry?.data || {};
169
+ return {
170
+ id: entry.id,
171
+ ref: d.ref || '',
172
+ category: d.category,
173
+ priority: d.priority,
174
+ subject: d.subject,
175
+ where: d.where || '',
176
+ userName: d.userName || '',
177
+ status: d.status,
178
+ statusLabel: statusLabel(d.status),
179
+ messages: (d.messages || []).map(m => ({id: m.id, from: m.from, name: m.name, body: m.body, at: m.at})),
180
+ lastFrom: d.lastFrom || 'customer',
181
+ lastAt: d.lastAt || '',
182
+ createdAt: entry.meta?.createdAt || '',
183
+ updatedAt: entry.meta?.updatedAt || ''
184
+ };
185
+ }
186
+
187
+ /** The newest message from Domma, for "unread" - its time, or ''. */
188
+ export function lastDommaAt(ticket) {
189
+ const ms = (ticket?.messages || []).filter(m => m.from === 'domma');
190
+ return ms.length ? String(ms[ms.length - 1].at || '') : '';
191
+ }
192
+
193
+ /** What the inbox may change without writing: status and internal notes. */
194
+ export function cleanUpdate(body = {}) {
195
+ const patch = {};
196
+ if (body.status !== undefined) {
197
+ if (!STATUS_KEYS.includes(body.status)) return {error: 'That is not a status.'};
198
+ patch.status = body.status;
199
+ }
200
+ if (body.notes !== undefined) patch.notes = text(body.notes, LIMITS.notes);
201
+ if (body.priority !== undefined) {
202
+ if (!PRIORITY_KEYS.includes(body.priority)) return {error: 'That is not a priority.'};
203
+ patch.priority = body.priority;
204
+ }
205
+ if (!Object.keys(patch).length) return {error: 'Nothing to change.'};
206
+ return {patch};
207
+ }
208
+
209
+ /**
210
+ * The contact card every site shows, from settings. Only what is safe and
211
+ * well-formed gets through: an email that looks like one, a phone of phone
212
+ * characters, short plain text otherwise.
213
+ */
214
+ export function contactCard(s = {}) {
215
+ const email = text(s.email, 200);
216
+ const phone = line(s.phone, 40);
217
+ return {
218
+ email: EMAIL_RE.test(email) ? email : '',
219
+ phone: /^[+\d][\d\s().-]{4,}$/.test(phone) ? phone : '',
220
+ hours: line(s.hours, 200),
221
+ responseTime: line(s.responseTime, 300)
222
+ };
223
+ }
224
+
225
+ /** The tel: form of a phone number. */
226
+ export const telHref = (phone) => `tel:${String(phone || '').replace(/[^\d+]/g, '')}`;
227
+
228
+ /**
229
+ * How many a site may send: `max` in any `windowMs`. The manager's global
230
+ * limiter exempts loopback, which is where every site calls from, so this is
231
+ * the only brake.
232
+ */
233
+ export function createThrottle({max = 20, windowMs = 10 * 60_000} = {}) {
234
+ const seen = new Map();
235
+ return (key, now = Date.now()) => {
236
+ const recent = (seen.get(key) || []).filter(t => now - t < windowMs);
237
+ if (recent.length >= max) { seen.set(key, recent); return false; }
238
+ recent.push(now);
239
+ seen.set(key, recent);
240
+ return true;
241
+ };
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------------
245
+ // Emails
246
+ // ---------------------------------------------------------------------------------
247
+
248
+ export const escapeHtml = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
249
+
250
+ /** A plain message: paragraphs, a quoted block and a button. Everything escaped. */
251
+ function compose({intro = [], quote = '', facts = [], link = '', linkText = 'Open'}) {
252
+ const text = [
253
+ ...intro, '',
254
+ ...(quote ? [quote.split('\n').map(l => `> ${l}`).join('\n'), ''] : []),
255
+ ...facts.map(([k, v]) => `${k}: ${v}`),
256
+ ...(link ? ['', `${linkText}: ${link}`] : [])
257
+ ].join('\n');
258
+ const html = `<div style="font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:15px;line-height:1.5;color:#1f2937;max-width:600px">
259
+ ${intro.map(p => `<p>${escapeHtml(p)}</p>`).join('\n')}
260
+ ${quote ? `<blockquote style="margin:16px 0;padding:12px 16px;border-left:3px solid #6366f1;background:#f5f5ff;white-space:pre-wrap">${escapeHtml(quote)}</blockquote>` : ''}
261
+ ${facts.length ? `<table style="font-size:13px;color:#4b5563">${facts.map(([k, v]) => `<tr><td style="padding:2px 12px 2px 0">${escapeHtml(k)}</td><td>${escapeHtml(v)}</td></tr>`).join('')}</table>` : ''}
262
+ ${link ? `<p style="margin-top:20px"><a href="${escapeHtml(link)}" style="background:#4f46e5;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none">${escapeHtml(linkText)}</a></p>` : ''}
263
+ </div>`;
264
+ return {text, html};
265
+ }
266
+
267
+ /**
268
+ * To Domma: a site opened a request, or wrote again. Reply-To is the customer,
269
+ * so answering from the mailbox reaches them - but the inbox link is the way to
270
+ * keep the conversation in one place.
271
+ */
272
+ export function toDommaEmail({ticket, siteName, link, body, isNew}) {
273
+ const urgent = ticket.priority === 'urgent';
274
+ const subject = `${urgent ? '[URGENT] ' : ''}${isNew ? 'Support request' : 'Reply'} from ${siteName}: ${ticket.subject}`;
275
+ return {
276
+ subject,
277
+ replyTo: ticket.userEmail || '',
278
+ ...compose({
279
+ intro: [isNew ? `${ticket.userName} at ${siteName} needs help.` : `${ticket.userName} at ${siteName} wrote again.`],
280
+ quote: body,
281
+ facts: [['Site', siteName], ['Category', categoryLabel(ticket.category)], ['Priority', urgent ? 'URGENT' : 'Normal'],
282
+ ...(ticket.where ? [['Where', ticket.where]] : []), ...(ticket.cmsVersion ? [['CMS', ticket.cmsVersion]] : []),
283
+ ...(ticket.userEmail ? [['Email', ticket.userEmail]] : [])],
284
+ link, linkText: 'Open in the Support inbox'
285
+ })
286
+ };
287
+ }
288
+
289
+ /** To the customer: Domma replied (or resolved the request). */
290
+ export function toCustomerEmail({ticket, siteName, link, body, resolved}) {
291
+ return {
292
+ subject: `Re: ${ticket.subject}`,
293
+ ...compose({
294
+ intro: [`Hello ${ticket.userName || ''}`.trim() + ',',
295
+ body ? 'Domma Support has replied to your request.' : 'Domma Support has marked your request as resolved.',
296
+ ...(resolved && body ? ['We have marked it as resolved. If it is not, just reply in your admin and it reopens.'] : [])],
297
+ quote: body,
298
+ facts: [['Request', ticket.subject], ['Site', siteName]],
299
+ link, linkText: 'Open it in your admin'
300
+ })
301
+ };
302
+ }