domma-cms 1.6.37 → 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.
Files changed (82) 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/mail.css +1 -1
  5. package/plugins/_lib/admin/mail/oauth-panel.js +303 -0
  6. package/plugins/_lib/admin/mail/reader-view.js +126 -13
  7. package/plugins/_lib/mail/accounts.js +548 -225
  8. package/plugins/_lib/mail/imapPool.js +63 -13
  9. package/plugins/_lib/mail/oauth.js +453 -0
  10. package/plugins/_lib/mail/oauthRoutes.js +294 -0
  11. package/plugins/_lib/mail/readRoutes.js +23 -2
  12. package/plugins/_lib/mail/scheduler.js +30 -6
  13. package/plugins/blog/CLAUDE.md +11 -1
  14. package/plugins/blog/lib/cache.js +43 -0
  15. package/plugins/blog/lib/page.js +28 -9
  16. package/plugins/blog/plugin.js +14 -13
  17. package/plugins/blog/plugin.json +1 -1
  18. package/plugins/blog/plugin.public.js +1 -1
  19. package/plugins/blog/tests/cache.test.js +65 -0
  20. package/plugins/booking/CLAUDE.md +14 -2
  21. package/plugins/booking/admin/lib/diary-layout.js +18 -0
  22. package/plugins/booking/admin/views/diary.js +153 -152
  23. package/plugins/booking/admin/views/people.js +64 -66
  24. package/plugins/booking/admin/views/settings.js +31 -32
  25. package/plugins/booking/plugin.json +1 -1
  26. package/plugins/booking/tests/admin-diary.test.js +9 -1
  27. package/plugins/feedback/CLAUDE.md +62 -0
  28. package/plugins/feedback/admin/css/index.css +16 -1
  29. package/plugins/feedback/admin/lib/kit.js +106 -0
  30. package/plugins/feedback/admin/lib/settings.js +107 -0
  31. package/plugins/feedback/admin/views/feedback.js +4 -1
  32. package/plugins/feedback/admin/views/inbox.js +11 -2
  33. package/plugins/feedback/admin/views/send.js +74 -7
  34. package/plugins/feedback/docs/guide.md +9 -9
  35. package/plugins/feedback/lib/attachments.js +133 -0
  36. package/plugins/feedback/lib/cache.js +43 -0
  37. package/plugins/feedback/lib/receiver.js +36 -6
  38. package/plugins/feedback/lib/sender.js +89 -6
  39. package/plugins/feedback/lib/shape.js +111 -6
  40. package/plugins/feedback/plugin.js +173 -17
  41. package/plugins/feedback/plugin.json +2 -2
  42. package/plugins/feedback/tests/api.test.js +125 -3
  43. package/plugins/feedback/tests/settings.test.js +59 -0
  44. package/plugins/feedback/tests/shape.test.js +87 -1
  45. package/plugins/free-tier.lock.json +74 -62
  46. package/plugins/mail-reader/CLAUDE.md +15 -4
  47. package/plugins/mail-reader/plugin.json +1 -1
  48. package/plugins/shopping-cart/CLAUDE.md +35 -2
  49. package/plugins/shopping-cart/admin/css/index.css +3 -0
  50. package/plugins/shopping-cart/admin/views/orders.js +75 -23
  51. package/plugins/shopping-cart/admin/views/shop.js +1 -1
  52. package/plugins/shopping-cart/docs/guide.md +7 -3
  53. package/plugins/shopping-cart/lib/payments.js +15 -2
  54. package/plugins/shopping-cart/lib/refunds.js +51 -0
  55. package/plugins/shopping-cart/lib/sales.js +15 -8
  56. package/plugins/shopping-cart/lib/shop.js +38 -3
  57. package/plugins/shopping-cart/plugin.js +47 -19
  58. package/plugins/shopping-cart/plugin.json +2 -2
  59. package/plugins/shopping-cart/tests/api.test.js +52 -0
  60. package/plugins/shopping-cart/tests/commission.test.js +1 -1
  61. package/plugins/shopping-cart/tests/pure.test.js +16 -0
  62. package/plugins/shopping-cart/tests/refunds.test.js +42 -0
  63. package/plugins/support/CLAUDE.md +58 -0
  64. package/plugins/support/admin/css/index.css +16 -1
  65. package/plugins/support/admin/lib/kit.js +192 -8
  66. package/plugins/support/admin/lib/settings.js +123 -0
  67. package/plugins/support/admin/views/desk.js +55 -12
  68. package/plugins/support/admin/views/inbox.js +17 -4
  69. package/plugins/support/admin/views/support.js +4 -1
  70. package/plugins/support/docs/guide.md +7 -9
  71. package/plugins/support/lib/attachments.js +133 -0
  72. package/plugins/support/lib/cache.js +43 -0
  73. package/plugins/support/lib/receiver.js +45 -10
  74. package/plugins/support/lib/sender.js +96 -10
  75. package/plugins/support/lib/shape.js +153 -12
  76. package/plugins/support/plugin.js +159 -29
  77. package/plugins/support/plugin.json +2 -2
  78. package/plugins/support/tests/api.test.js +125 -3
  79. package/plugins/support/tests/settings.test.js +68 -0
  80. package/plugins/support/tests/shape.test.js +76 -2
  81. package/server/routes/outreach-public.js +2 -2
  82. package/server/services/request-log.js +3 -1
@@ -47,9 +47,10 @@ export function createOutbox(dataDir) {
47
47
  seen: j.seen && typeof j.seen === 'object' ? j.seen : {},
48
48
  tickets: Array.isArray(j.tickets) ? j.tickets : [],
49
49
  contact: j.contact && typeof j.contact === 'object' ? j.contact : null,
50
- shelf: j.shelf && typeof j.shelf === 'object' ? j.shelf : {}
50
+ shelf: j.shelf && typeof j.shelf === 'object' ? j.shelf : {},
51
+ files: Array.isArray(j.files) ? j.files : []
51
52
  };
52
- } catch { return {items: [], seen: {}, tickets: [], contact: null, shelf: {}}; }
53
+ } catch { return {items: [], seen: {}, tickets: [], contact: null, shelf: {}, files: []}; }
53
54
  };
54
55
  const write = (state) => {
55
56
  fs.mkdirSync(dataDir, {recursive: true});
@@ -63,6 +64,26 @@ export function createOutbox(dataDir) {
63
64
  tickets: () => read().tickets,
64
65
  contact: () => read().contact,
65
66
  shelf: () => read().shelf,
67
+ /**
68
+ * Every file id this site still needs: named by an item not done with, or already sent (the
69
+ * thread shows them from this site's copy long after the item itself has gone).
70
+ */
71
+ fileIds: () => { const s = read(); return new Set([...s.files, ...s.items.flatMap(i => (i.payload?.attachments || []).map(a => a.id))]); },
72
+ /** A file reached the manager: keep it (the newest 2000), and note it on its item. */
73
+ fileSent(ref, id) {
74
+ update(s => {
75
+ const item = s.items.find(i => i.ref === ref);
76
+ if (item) item.filesSent = [...new Set([...(item.filesSent || []), id])];
77
+ s.files = [...s.files.filter(x => x !== id), id].slice(-2000);
78
+ });
79
+ },
80
+ /** A file the manager will never take (or this site lost): stop trying it. */
81
+ fileRefused(ref, id) {
82
+ update(s => {
83
+ const item = s.items.find(i => i.ref === ref);
84
+ if (item) item.filesRefused = [...new Set([...(item.filesRefused || []), id])];
85
+ });
86
+ },
66
87
  /**
67
88
  * Archive or remove a request from this site's list (`state` null puts it back). Remembers
68
89
  * Domma's latest reply at the time, and marks it read: what is put away is not "new".
@@ -118,11 +139,14 @@ export function createOutbox(dataDir) {
118
139
  const known = latest.has(ref);
119
140
  if ((!known && !local.has(ref)) || (known && (latest.get(ref) || '') !== (entry.dommaAt || ''))) delete s.shelf[ref];
120
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);
121
145
  const byRef = new Map(remote.map(t => [t.ref, t]));
122
146
  const have = new Set(remote.flatMap(t => [t.ref, ...(t.messages || []).map(m => m.id)]));
123
147
  s.items = s.items.filter((i) => {
124
148
  if (i.state !== 'sent') return true;
125
- if (have.has(i.ref)) return false; // arrived: done with it
149
+ if (have.has(i.ref)) return !filesDone(i); // arrived: done with it, once its files have gone
126
150
  const t = byRef.get(i.kind === 'ticket' ? i.ref : i.ticketRef);
127
151
  if (!t) return true; // not in the view yet
128
152
  if (i.kind === 'message' && !i.payload?.body) return false; // a close: no message to look for
@@ -136,6 +160,24 @@ export function createOutbox(dataDir) {
136
160
  };
137
161
  }
138
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
+
175
+ /** Has every file an item announced been sent, or given up on? */
176
+ const filesDone = (i) => {
177
+ const done = new Set([...(i.filesSent || []), ...(i.filesRefused || [])]);
178
+ return (i.payload?.attachments || []).every(a => done.has(a.id));
179
+ };
180
+
139
181
  /**
140
182
  * Talk to the manager. `fetchImpl` is swapped in tests; nothing here throws -
141
183
  * every call answers `{ok, status?, body?, error?}`.
@@ -157,10 +199,28 @@ export function managerApi(manager, fetchImpl = globalThis.fetch) {
157
199
  return {ok: false, error: `Could not reach the Domma server: ${err.message}`};
158
200
  }
159
201
  };
202
+ /** One attached file's bytes, after its message: raw, so the JSON routes' 32 KB limit never applies to them. */
203
+ const attach = async (ticketRef, attId, buffer) => {
204
+ try {
205
+ const res = await fetchImpl(`${manager.url}/api/plugins/support/fleet/attach/${encodeURIComponent(ticketRef)}/${encodeURIComponent(attId)}`, {
206
+ method: 'POST',
207
+ headers: {authorization: `Bearer ${manager.token}`, 'content-type': 'application/octet-stream'},
208
+ body: buffer,
209
+ signal: AbortSignal.timeout(30_000)
210
+ });
211
+ let json = null;
212
+ try { json = await res.json(); } catch { /* not JSON */ }
213
+ if (res.status >= 200 && res.status < 300) return {ok: true, status: res.status, body: json};
214
+ return {ok: false, status: res.status, error: json?.error || `The Domma server answered ${res.status}.`};
215
+ } catch (err) {
216
+ return {ok: false, error: `Could not reach the Domma server: ${err.message}`};
217
+ }
218
+ };
160
219
  return {
161
220
  open: (payload) => call('POST', 'tickets', payload),
162
221
  message: (payload) => call('POST', 'messages', payload),
163
- mine: () => call('GET', 'mine')
222
+ mine: () => call('GET', 'mine'),
223
+ attach
164
224
  };
165
225
  }
166
226
 
@@ -170,17 +230,40 @@ export function managerApi(manager, fetchImpl = globalThis.fetch) {
170
230
  *
171
231
  * @returns {Promise<{sent: number, waiting: number, error: string}>}
172
232
  */
173
- export function flush(outbox, api) {
233
+ export function flush(outbox, api, files = null) {
174
234
  // One flush at a time per outbox: the timer, the badge's nudge, a screen load and a send can
175
235
  // all ask at once, and two flushes would POST the same waiting item twice.
176
- if (!outbox.flushing) outbox.flushing = flushOnce(outbox, api).finally(() => { outbox.flushing = null; });
236
+ if (!outbox.flushing) outbox.flushing = flushOnce(outbox, api, files).finally(() => { outbox.flushing = null; });
177
237
  return outbox.flushing;
178
238
  }
179
239
 
180
240
  /** Statuses that mean "try again later"; any other 4xx means the manager will never take it. */
181
241
  const RETRY = new Set([401, 408, 429]);
182
242
 
183
- async function flushOnce(outbox, api) {
243
+ /**
244
+ * The files of messages that have arrived but whose files have not all followed.
245
+ * A file the manager refuses for good (400/404/413), or one this site no longer
246
+ * has, is not tried again - the words are there either way.
247
+ */
248
+ async function sendFiles(outbox, api, files) {
249
+ let error = '';
250
+ for (const item of outbox.all().filter(i => i.state === 'sent' && !filesDone(i))) {
251
+ const done = new Set([...(item.filesSent || []), ...(item.filesRefused || [])]);
252
+ for (const a of item.payload.attachments.filter(x => !done.has(x.id))) {
253
+ const buffer = files?.read(a);
254
+ if (!buffer) { outbox.fileRefused(item.ref, a.id); continue; }
255
+ const r = await api.attach(item.ticketRef, a.id, buffer);
256
+ if (r.ok) outbox.fileSent(item.ref, a.id);
257
+ else {
258
+ error = r.error || 'A file was not sent.';
259
+ if (r.status >= 400 && r.status < 500 && !RETRY.has(r.status)) outbox.fileRefused(item.ref, a.id);
260
+ }
261
+ }
262
+ }
263
+ return error;
264
+ }
265
+
266
+ async function flushOnce(outbox, api, files) {
184
267
  let sent = 0;
185
268
  let error = '';
186
269
  for (const queued of outbox.all().filter(i => i.state === 'waiting')) {
@@ -204,7 +287,9 @@ async function flushOnce(outbox, api) {
204
287
  outbox.mark(item.ref, {lastError: error, triedAt: new Date().toISOString(), ...(permanent && {state: 'refused'})});
205
288
  }
206
289
  }
207
- return {sent, waiting: outbox.all().filter(i => i.state === 'waiting').length, error};
290
+ // Then the files, each on its own request once its message is on the manager.
291
+ const fileError = api.attach ? await sendFiles(outbox, api, files) : '';
292
+ return {sent, waiting: outbox.all().filter(i => i.state === 'waiting').length, error: error || fileError};
208
293
  }
209
294
 
210
295
  /**
@@ -260,7 +345,8 @@ export function mergeTickets(items, remote, seen = {}, cached = []) {
260
345
  id: '', ref: i.ref, category: p.category, priority: p.priority, subject: p.subject, where: p.where || '',
261
346
  userName: p.user?.name || '', status: i.state === 'refused' ? 'refused' : 'waiting',
262
347
  statusLabel: i.state === 'refused' ? 'Not accepted' : 'Waiting to send',
263
- messages: [{id: i.ref, from: 'customer', name: p.user?.name || '', body: p.body, at: i.createdAt, pending: true}],
348
+ messages: [{id: i.ref, from: 'customer', name: p.user?.name || '', body: p.body, at: i.createdAt, pending: true,
349
+ ...(p.attachments?.length && {attachments: p.attachments})}],
264
350
  lastFrom: 'customer', lastAt: i.createdAt, createdAt: i.createdAt, updatedAt: i.createdAt,
265
351
  local: true, unread: false, lastError: i.lastError || ''
266
352
  });
@@ -269,7 +355,7 @@ export function mergeTickets(items, remote, seen = {}, cached = []) {
269
355
  const t = out.get(i.ticketRef);
270
356
  if (t && !(t.messages || []).some(m => m.id === i.ref)) {
271
357
  t.messages = [...(t.messages || []), {id: i.ref, from: 'customer', name: p.user?.name || '', body: p.body, at: i.createdAt,
272
- pending: i.state === 'waiting', refused: i.state === 'refused'}];
358
+ pending: i.state === 'waiting', refused: i.state === 'refused', ...(p.attachments?.length && {attachments: p.attachments})}];
273
359
  }
274
360
  }
275
361
  }
@@ -47,6 +47,68 @@ export const LIMITS = {subject: 140, body: 8000, where: 300, notes: 8000, messag
47
47
  // would pass here and then be refused by the manager's 32 KB body limit, forever.
48
48
  const text = (v, max) => String(v ?? '').replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, '').trim().slice(0, max);
49
49
  const line = (v, max) => text(v, max).replace(/\s*\n\s*/g, ' ');
50
+
51
+ // ---------------------------------------------------------------------------------
52
+ // Attachments: a screenshot is what "something is not working" needs most.
53
+ // The same rules as Feedback's (plugins/feedback/lib/shape.js) - each plugin
54
+ // carries its own copy, since either can be installed without the other.
55
+ // ---------------------------------------------------------------------------------
56
+
57
+ /** How many files one message may carry and how big each may be. The files travel one per request, after their message. */
58
+ export const ATTACHMENTS = {max: 3, maxBytes: 2 * 1024 * 1024};
59
+
60
+ /** What may be attached: mime -> the extension the file is kept under. No SVG or HTML - they can carry scripts. */
61
+ export const ATTACHMENT_TYPES = {
62
+ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'application/pdf': 'pdf', 'text/plain': 'txt'
63
+ };
64
+
65
+ /** Ids are made by the server (randomUUID) and become file names, so nothing else gets through. */
66
+ const ATTACHMENT_ID = /^[A-Za-z0-9-]{8,64}$/;
67
+
68
+ export const extOf = (type) => ATTACHMENT_TYPES[String(type || '').toLowerCase()] || 'bin';
69
+
70
+ /** A file name fit to show and to put in a Content-Disposition header: the last path segment, odd characters replaced, never empty. */
71
+ export function safeFileName(name, ext = 'bin') {
72
+ const base = String(name ?? '').split(/[\\/]/).pop().replace(/[^A-Za-z0-9._ -]/g, '_').replace(/^[. ]+/, '').trim().slice(0, 120);
73
+ return base || `file.${ext}`;
74
+ }
75
+
76
+ /**
77
+ * One attachment's description, cleaned - or null when it is not one we take.
78
+ * The bytes are checked against `size` when they arrive.
79
+ *
80
+ * @returns {{id: string, name: string, type: string, size: number}|null}
81
+ */
82
+ export function cleanAttachment(a = {}) {
83
+ if (!a || typeof a !== 'object') return null;
84
+ const type = String(a.type || '').toLowerCase().split(';')[0].trim();
85
+ const id = String(a.id || '');
86
+ const size = Number(a.size);
87
+ if (!ATTACHMENT_TYPES[type] || !ATTACHMENT_ID.test(id) || !Number.isInteger(size) || size < 1 || size > ATTACHMENTS.maxBytes) return null;
88
+ return {id, name: safeFileName(a.name, ATTACHMENT_TYPES[type]), type, size};
89
+ }
90
+
91
+ /** A list of them: the bad ones dropped, duplicates once, at most ATTACHMENTS.max. */
92
+ export function cleanAttachments(list) {
93
+ if (!Array.isArray(list)) return [];
94
+ const out = [];
95
+ const seen = new Set();
96
+ for (const a of list) {
97
+ const c = cleanAttachment(a);
98
+ if (!c || seen.has(c.id)) continue;
99
+ seen.add(c.id);
100
+ out.push(c);
101
+ if (out.length >= ATTACHMENTS.max) break;
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /** The sender-safe view of a stored attachment: what it is and whether the manager has the bytes yet. */
107
+ export const attachmentView = (a) => ({id: a.id, name: a.name, type: a.type, size: a.size, stored: Boolean(a.stored)});
108
+
109
+ /** Every attachment in a conversation, with the message it came in. */
110
+ export const attachmentsOf = (data = {}) => (data.messages || []).flatMap(m => (m.attachments || []).map(a => ({...a, messageId: m.id})));
111
+
50
112
  const EMAIL_RE = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
51
113
 
52
114
  /**
@@ -63,14 +125,15 @@ export function cleanRequest(body = {}) {
63
125
  const message = text(body.body, LIMITS.body);
64
126
  if (!subject) return {error: 'Give it a short subject.'};
65
127
  if (!message) return {error: 'Tell us what you need.'};
66
- return {request: {category, priority, subject, body: message, where: line(body.where, LIMITS.where)}};
128
+ // Descriptions of files, not the files: those follow one per request once the request is in.
129
+ return {request: {category, priority, subject, body: message, where: line(body.where, LIMITS.where), attachments: cleanAttachments(body.attachments)}};
67
130
  }
68
131
 
69
- /** One more message in a conversation. */
132
+ /** One more message in a conversation. Files go with words: a message is never files alone. */
70
133
  export function cleanMessage(body = {}) {
71
134
  const message = text(body.body, LIMITS.body);
72
135
  if (!message) return {error: 'Write a message first.'};
73
- return {message: {body: message}};
136
+ return {message: {body: message, attachments: cleanAttachments(body.attachments)}};
74
137
  }
75
138
 
76
139
  const who = (user = {}) => ({id: text(user.id, 64), name: line(user.name, 120), email: text(user.email, 200)});
@@ -84,8 +147,9 @@ export function buildTicketPayload({request, user = {}, cmsVersion = '', site =
84
147
  }
85
148
 
86
149
  /** 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)};
150
+ export function buildMessagePayload({ticketRef, ref, body = '', close = false, user = {}, attachments = []}) {
151
+ return {ticketRef: text(ticketRef, 64), ref: text(ref, 64), body: text(body, LIMITS.body), close: Boolean(close), user: who(user),
152
+ ...(body && {attachments: cleanAttachments(attachments)})};
89
153
  }
90
154
 
91
155
  /**
@@ -115,7 +179,9 @@ export function receivedTicket(payload = {}, slug, now) {
115
179
  userName: u.name || 'A site admin',
116
180
  userEmail: EMAIL_RE.test(u.email) ? u.email : '',
117
181
  status: 'open',
118
- messages: [{id: ref || 'm0', from: 'customer', name: u.name || 'A site admin', body: request.body, at: now}],
182
+ // Announced with the message; `stored` turns true as each file arrives (/fleet/attach).
183
+ messages: [{id: ref || 'm0', from: 'customer', name: u.name || 'A site admin', body: request.body, at: now,
184
+ ...(request.attachments.length && {attachments: request.attachments.map(a => ({...a, stored: false}))})}],
119
185
  lastFrom: 'customer',
120
186
  lastAt: now,
121
187
  notes: '',
@@ -132,14 +198,15 @@ export function receivedTicket(payload = {}, slug, now) {
132
198
  * @param {{from: 'customer'|'domma', name: string, body?: string, id: string, at: string, status?: string, close?: boolean}} turn
133
199
  * @returns {{data: object, added: boolean}}
134
200
  */
135
- export function addTurn(data, {from, name, body = '', id, at, status, close = false}) {
201
+ export function addTurn(data, {from, name, body = '', id, at, status, close = false, attachments = []}) {
136
202
  const messages = Array.isArray(data.messages) ? [...data.messages] : [];
137
203
  if (id && messages.some(m => m.id === id)) return {data, added: false};
138
204
  const history = Array.isArray(data.history) ? [...data.history] : [];
139
205
  const next = {...data};
140
206
  let added = false;
141
207
  if (body) {
142
- messages.push({id, from, name, body, at});
208
+ const files = cleanAttachments(attachments).map(a => ({...a, stored: false}));
209
+ messages.push({id, from, name, body, at, ...(files.length && {attachments: files})});
143
210
  next.lastFrom = from;
144
211
  next.lastAt = at;
145
212
  added = true;
@@ -181,7 +248,8 @@ export function senderView(entry) {
181
248
  userName: d.userName || '',
182
249
  status: d.status,
183
250
  statusLabel: statusLabel(d.status),
184
- messages: (d.messages || []).map(m => ({id: m.id, from: m.from, name: m.name, body: m.body, at: m.at})),
251
+ messages: (d.messages || []).map(m => ({id: m.id, from: m.from, name: m.name, body: m.body, at: m.at,
252
+ ...(m.attachments?.length && {attachments: m.attachments.map(attachmentView)})})),
185
253
  lastFrom: d.lastFrom || 'customer',
186
254
  lastAt: d.lastAt || '',
187
255
  createdAt: entry.meta?.createdAt || '',
@@ -231,6 +299,76 @@ export function contactCard(s = {}) {
231
299
  };
232
300
  }
233
301
 
302
+ /**
303
+ * The settings each side owns (1.1.5). The site keeps its sync interval; the
304
+ * manager keeps the contact card every site shows, where new requests are
305
+ * emailed, and the per-site brake.
306
+ */
307
+ export const SIDE_SETTINGS = {
308
+ sender: ['syncMinutes'],
309
+ receiver: ['email', 'phone', 'hours', 'responseTime', 'notifyEmail', 'perSiteMax', 'perSiteMinutes']
310
+ };
311
+ export const SETTING_RULES = {
312
+ syncMinutes: {min: 1, max: 1440, fallback: 5},
313
+ perSiteMax: {min: 1, max: 1000, fallback: 20},
314
+ perSiteMinutes: {min: 1, max: 1440, fallback: 10}
315
+ };
316
+ const PHONE_RE = /^[+\d][\d\s().-]{4,}$/;
317
+
318
+ /** What is wrong with a sent settings object, per field - empty when nothing. */
319
+ export function settingsErrors(raw, side = 'sender') {
320
+ const r = raw && typeof raw === 'object' ? raw : {};
321
+ const errors = {};
322
+ const keys = SIDE_SETTINGS[side] || [];
323
+ for (const k of keys) {
324
+ if (!(k in r)) continue;
325
+ const v = r[k];
326
+ if (SETTING_RULES[k]) {
327
+ const n = Number(v);
328
+ const {min, max} = SETTING_RULES[k];
329
+ if (v === '' || v == null || !Number.isInteger(n) || n < min || n > max) errors[k] = `A whole number from ${min} to ${max}.`;
330
+ } else if ((k === 'email' || k === 'notifyEmail') && String(v ?? '').trim() && !EMAIL_RE.test(String(v).trim())) {
331
+ errors[k] = 'One email address, or leave it empty.';
332
+ } else if (k === 'phone' && String(v ?? '').trim() && !PHONE_RE.test(String(v).trim())) {
333
+ errors[k] = 'Digits, spaces and + ( ) . - only.';
334
+ }
335
+ }
336
+ return errors;
337
+ }
338
+
339
+ /**
340
+ * One side's settings from whatever was saved or sent: only that side's keys,
341
+ * each checked; a value that does not pass keeps what is current, then the
342
+ * default. An empty email or phone is allowed - it hides that line of the card
343
+ * (notifyEmail: emails nobody).
344
+ *
345
+ * @param {object} raw
346
+ * @param {object} [current]
347
+ * @param {'sender'|'receiver'} [side]
348
+ */
349
+ export function cleanSettings(raw, current = {}, side = 'sender') {
350
+ const r = raw && typeof raw === 'object' ? raw : {};
351
+ const cur = current && typeof current === 'object' ? current : {};
352
+ const out = {};
353
+ const okFor = (k, v) => {
354
+ if (v === undefined) return undefined;
355
+ if (SETTING_RULES[k]) {
356
+ const n = Number(v);
357
+ const {min, max} = SETTING_RULES[k];
358
+ return v !== '' && v != null && Number.isInteger(n) && n >= min && n <= max ? n : undefined;
359
+ }
360
+ if (k === 'email' || k === 'notifyEmail') { const e = text(v, 200); return !e || EMAIL_RE.test(e) ? e : undefined; }
361
+ if (k === 'phone') { const p = line(v, 40); return !p || PHONE_RE.test(p) ? p : undefined; }
362
+ if (k === 'hours') return line(v, 200);
363
+ if (k === 'responseTime') return line(v, 300);
364
+ return undefined;
365
+ };
366
+ for (const k of SIDE_SETTINGS[side] || []) {
367
+ out[k] = okFor(k, r[k]) ?? okFor(k, cur[k]) ?? SETTING_RULES[k]?.fallback ?? '';
368
+ }
369
+ return out;
370
+ }
371
+
234
372
  /** The tel: form of a phone number. */
235
373
  export const telHref = (phone) => `tel:${String(phone || '').replace(/[^\d+]/g, '')}`;
236
374
 
@@ -241,9 +379,12 @@ export const telHref = (phone) => `tel:${String(phone || '').replace(/[^\d+]/g,
241
379
  */
242
380
  export function createThrottle({max = 20, windowMs = 10 * 60_000} = {}) {
243
381
  const seen = new Map();
244
- return (key, now = Date.now()) => {
245
- const recent = (seen.get(key) || []).filter(t => now - t < windowMs);
246
- if (recent.length >= max) { seen.set(key, recent); return false; }
382
+ // `limits` per call (1.1.5): the settings cog changes them without a restart.
383
+ return (key, now = Date.now(), limits = {}) => {
384
+ const cap = Number(limits.max) > 0 ? Number(limits.max) : max;
385
+ const window = Number(limits.windowMs) > 0 ? Number(limits.windowMs) : windowMs;
386
+ const recent = (seen.get(key) || []).filter(t => now - t < window);
387
+ if (recent.length >= cap) { seen.set(key, recent); return false; }
247
388
  recent.push(now);
248
389
  seen.set(key, recent);
249
390
  return true;