domma-cms 1.10.34 → 1.10.36

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.10.36] - 2026-10-27
4
+
5
+ - Trash folder menus offer Empty Trash with an explicit permanent-deletion confirmation. Clear every message in the server-designated Trash folder, including more than one list page, while retaining the folder and its subfolders. Requires Email Pro 0.10.1.
6
+
7
+ ## [1.10.35] - 2026-10-27
8
+
9
+ - Email lists always show the local date and time. Ctrl-click adds or removes individual messages, starting with the open message; Shift-click selects a range from it.
10
+ - Email folder menus offer a destination picker to move a folder and its subfolders within the mailbox. System folders, cycles and duplicate destinations are protected.
11
+ - Open messages in Spam have a visible Not spam action that returns them to Inbox.
12
+ - Update Domma JS for context menus that remain open while their contents scroll on smaller screens.
13
+ - **Built against domma-js 1.0.1** - see [its release notes](https://github.com/pinpointzero73/domma/releases/tag/v1.0.1).
14
+
3
15
  ## [1.10.34] - 2026-10-27
4
16
 
5
17
  - Scan rendered page previews for external image domains, show blocked hosts in the editor, and let users with site settings permission allow individual domains. Image policy updates apply without restarting; an open editor can reload its preview policy while retaining unsaved drafts.
@@ -1,9 +1,9 @@
1
1
  /*!
2
- * Domma Tools CSS v1.0.0
2
+ * Domma Tools CSS v1.0.1
3
3
  * Dynamic Object Manipulation & Modeling API
4
4
  * (c) 2026 Darryl Waterhouse & DCBW-IT
5
- * Built: 2026-10-26T07:45:05.989Z
6
- * Commit: f81c097
5
+ * Built: 2026-10-27T10:51:45.431Z
6
+ * Commit: 721b19e
7
7
  */
8
8
 
9
9
  /*!
@@ -1,9 +1,9 @@
1
1
  /*!
2
- * Domma Tools v1.0.0
2
+ * Domma Tools v1.0.1
3
3
  * Developer tools: Theme Roller & Page Roller
4
4
  * (c) 2026 Darryl Waterhouse & DCBW-IT
5
- * Built: 2026-10-26T07:44:49.638Z
6
- * Commit: f81c097
5
+ * Built: 2026-10-27T10:51:32.116Z
6
+ * Commit: 721b19e
7
7
  *
8
8
  * Requires: domma.min.js
9
9
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.10.34",
3
+ "version": "1.10.36",
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",
@@ -86,7 +86,7 @@
86
86
  "bcryptjs": "^3.0.3",
87
87
  "css-select": "^7.0.0",
88
88
  "dom-serializer": "^3.1.1",
89
- "domma-js": "1.0.0",
89
+ "domma-js": "1.0.1",
90
90
  "dotenv": "^17.2.3",
91
91
  "fastify": "5.12.7",
92
92
  "gray-matter": "^4.0.3",
@@ -40,7 +40,7 @@ const {
40
40
  } = await import(versioned('settings-section.js'));
41
41
  const {createSplitters, fitToViewport} = await import(versioned('panes.js'));
42
42
  const {createDiagnosticsSection} = await import(versioned('diagnostics-section.js'));
43
- const {fmtDate, fmtDateTime} = await import(new URL(`../ui/dates.js?v=${ASSET_VERSION}`, import.meta.url).href);
43
+ const {fmtDateTime} = await import(new URL(`../ui/dates.js?v=${ASSET_VERSION}`, import.meta.url).href);
44
44
  // Renamed: onMount wraps it in its own `openCompose`, which tracks each window it opens.
45
45
  const {openCompose: openComposeWindow} = await import(versioned('compose-window.js'));
46
46
  const {makeResizable} = await import(versioned('resizable.js'));
@@ -99,20 +99,6 @@ async function api(url, method = 'GET', body) {
99
99
  return payload;
100
100
  }
101
101
 
102
- /**
103
- * Format a message date: time for today, date for anything older.
104
- *
105
- * @param {string|null} value
106
- * @returns {string}
107
- */
108
- function formatDate(value) {
109
- if (!value) return '';
110
- const d = D(value);
111
- return d.format('YYYY-MM-DD') === D().format('YYYY-MM-DD')
112
- ? d.format('HH:mm')
113
- : fmtDate(value);
114
- }
115
-
116
102
  /**
117
103
  * Render an address list as a readable string.
118
104
  *
@@ -2565,7 +2551,7 @@ return {
2565
2551
 
2566
2552
  const date = document.createElement('span');
2567
2553
  date.className = 'mail-item-date';
2568
- date.textContent = formatDate(message.date);
2554
+ date.textContent = fmtDateTime(message.date);
2569
2555
  top.appendChild(date);
2570
2556
 
2571
2557
  item.appendChild(top);
@@ -2687,6 +2673,9 @@ return {
2687
2673
  // which is the gesture people use ninety times in a
2688
2674
  // hundred and must stay the simplest one.
2689
2675
  if (event.ctrlKey || event.metaKey) {
2676
+ event.preventDefault();
2677
+ const open = selectedUid$.value;
2678
+ if (!selected.size && open != null && messages.some(m => m.uid === open)) selected.add(open);
2690
2679
  toggleSelected(message.uid);
2691
2680
  return;
2692
2681
  }
@@ -2695,6 +2684,7 @@ return {
2695
2684
  return;
2696
2685
  }
2697
2686
  if (selected.size) clearSelection();
2687
+ selectionAnchor = message.uid;
2698
2688
  openMessage(message.uid, message.folder);
2699
2689
  });
2700
2690
  listEl.appendChild(item);
@@ -3159,6 +3149,14 @@ return {
3159
3149
  `Mark this message ${isSeen ? 'unread' : 'read'} (u)`
3160
3150
  ));
3161
3151
 
3152
+ const spam = messageIsSpam(current ?? message);
3153
+ if (spam || junkFolder()) {
3154
+ actions.appendChild(actionButton(spam ? 'Not spam' : 'Mark as spam',
3155
+ spam ? 'shield-check' : 'shield-alert',
3156
+ () => markSpam(message, !spam),
3157
+ spam ? 'Mark as not spam and return to Inbox' : 'Move this message to Spam'));
3158
+ }
3159
+
3162
3160
  if (features.send) {
3163
3161
  actions.appendChild(actionButton('Reply', 'corner-up-left',
3164
3162
  () => compose(message, 'reply'), 'Reply to the sender (r)'));
@@ -5083,7 +5081,7 @@ return {
5083
5081
  // what to grey out. The server refuses regardless - this is the
5084
5082
  // courtesy, not the control.
5085
5083
  const protectedUse = folder.specialUse
5086
- && ['\\Inbox', '\\Sent', '\\Drafts', '\\Trash', '\\Junk', '\\Archive']
5084
+ && ['\\Inbox', '\\Sent', '\\Drafts', '\\Trash', '\\Junk', '\\Archive', '\\All', '\\Flagged']
5087
5085
  .includes(folder.specialUse);
5088
5086
  const isInbox = String(folder.path).toUpperCase() === 'INBOX';
5089
5087
  const locked = protectedUse || isInbox;
@@ -5116,12 +5114,24 @@ return {
5116
5114
  action: () => setFolderVisible(folder, folder.subscribed === false)
5117
5115
  },
5118
5116
  {type: 'divider'},
5117
+ ...(folder.specialUse === '\\Trash' && features.emptyTrash ? [{
5118
+ label: 'Empty Trash...',
5119
+ icon: 'trash',
5120
+ danger: true,
5121
+ action: () => emptyTrashFolder(folder)
5122
+ }, {type: 'divider'}] : []),
5119
5123
  {
5120
5124
  label: 'Rename',
5121
5125
  icon: 'edit-3',
5122
5126
  disabled: () => locked,
5123
5127
  action: () => renameFolder(folder)
5124
5128
  },
5129
+ {
5130
+ label: 'Move to...',
5131
+ icon: 'folder',
5132
+ disabled: () => locked || !folder.delimiter,
5133
+ action: () => moveFolder(folder)
5134
+ },
5125
5135
  {
5126
5136
  label: 'Delete',
5127
5137
  icon: 'trash',
@@ -5187,11 +5197,12 @@ return {
5187
5197
  * inputs at all.
5188
5198
  *
5189
5199
  * @param {string|null} preselect
5200
+ * @param {object|null} moving - the folder to relocate, or null to create
5190
5201
  * @returns {Promise<{parent: string, name: string}|null>}
5191
5202
  */
5192
- function askForFolder(preselect = null) {
5203
+ function askForFolder(preselect = null, moving = null) {
5193
5204
  return new Promise(resolve => {
5194
- const modal = trackModal(E.modal({title: 'New folder', size: 'sm'}));
5205
+ const modal = trackModal(E.modal({title: moving ? `Move ${moving.name}` : 'New folder', size: 'sm', buttons: []}));
5195
5206
 
5196
5207
  const wrap = document.createElement('div');
5197
5208
  wrap.className = 'mail-form-stack';
@@ -5230,7 +5241,8 @@ return {
5230
5241
  // A level the server never listed cannot hold mail. Shown
5231
5242
  // anyway, because leaving a hole makes its children look
5232
5243
  // top-level, but not choosable.
5233
- if (node.selectable === false) option.disabled = true;
5244
+ if (node.selectable === false || (moving && (node.path === moving.path
5245
+ || node.path.startsWith(`${moving.path}${moving.delimiter}`)))) option.disabled = true;
5234
5246
  // The full path where the indentation is ambiguous.
5235
5247
  option.title = node.path;
5236
5248
  if (node.path === preselect) option.selected = true;
@@ -5244,7 +5256,7 @@ return {
5244
5256
  cancel.textContent = 'Cancel';
5245
5257
  const create = document.createElement('button');
5246
5258
  create.className = 'btn btn-sm btn-primary';
5247
- create.textContent = 'Create';
5259
+ create.textContent = moving ? 'Move' : 'Create';
5248
5260
 
5249
5261
  let settled = false;
5250
5262
  const finish = (value) => {
@@ -5256,7 +5268,7 @@ return {
5256
5268
 
5257
5269
  cancel.addEventListener('click', () => finish(null));
5258
5270
  create.addEventListener('click', () => {
5259
- const name = nameInput.value.trim();
5271
+ const name = moving ? moving.name : nameInput.value.trim();
5260
5272
  if (!name) { nameInput.focus(); return; }
5261
5273
  finish({parent: parentSelect.value, name});
5262
5274
  });
@@ -5268,12 +5280,12 @@ return {
5268
5280
 
5269
5281
  actions.appendChild(cancel);
5270
5282
  actions.appendChild(create);
5271
- for (const el of [nameLabel, nameInput, parentLabel, parentSelect, actions]) {
5283
+ for (const el of [...(moving ? [] : [nameLabel, nameInput]), parentLabel, parentSelect, actions]) {
5272
5284
  wrap.appendChild(el);
5273
5285
  }
5274
5286
  modal.element.appendChild(wrap);
5275
5287
  modal.open?.();
5276
- setTimeout(() => nameInput.focus(), 60);
5288
+ setTimeout(() => (moving ? parentSelect : nameInput).focus(), 60);
5277
5289
  });
5278
5290
  }
5279
5291
 
@@ -5303,6 +5315,31 @@ return {
5303
5315
  }
5304
5316
  }
5305
5317
 
5318
+ /** Move a folder and its children within the current mailbox. */
5319
+ async function moveFolder(folder) {
5320
+ if (!dialogOnce.claim()) return;
5321
+ const accountId = currentAccountId;
5322
+ const cut = folder.path.lastIndexOf(folder.delimiter);
5323
+ const parent = cut < 0 ? '' : folder.path.slice(0, cut);
5324
+ let chosen;
5325
+ try {
5326
+ chosen = await askForFolder(parent, folder);
5327
+ } finally {
5328
+ dialogOnce.release();
5329
+ }
5330
+ if (!chosen || !here() || accountId !== currentAccountId || chosen.parent === parent) return;
5331
+ try {
5332
+ const params = new URLSearchParams({account: accountId ?? ''});
5333
+ const result = await api(`${BASE}/folders?${params}`, 'PUT',
5334
+ {path: folder.path, parent: chosen.parent});
5335
+ if (!here() || accountId !== currentAccountId) return;
5336
+ E.toast(`Moved to ${result.path}.`, {type: 'success'});
5337
+ await loadFolders({quiet: true});
5338
+ } catch (err) {
5339
+ E.toast(err.message || 'That folder could not be moved.', {type: 'error'});
5340
+ }
5341
+ }
5342
+
5306
5343
  /**
5307
5344
  * Rename a folder, leaving it where it is.
5308
5345
  *
@@ -5331,6 +5368,33 @@ return {
5331
5368
  }
5332
5369
  }
5333
5370
 
5371
+ /** Empty Trash after explicitly confirming permanent deletion. */
5372
+ async function emptyTrashFolder(folder) {
5373
+ const accountId = currentAccountId;
5374
+ const count = Number.isFinite(folder.messages) ? `${folder.messages} message${folder.messages === 1 ? '' : 's'}` : 'all messages';
5375
+ const question = `Are you sure you want to permanently delete ${count} from "${folder.path}"? This cannot be undone. The folder and its subfolders will be kept.`;
5376
+ if (!dialogOnce.claim()) return;
5377
+ let confirmed;
5378
+ try {
5379
+ confirmed = await ask(esc(question), {title: 'Empty Trash?', confirmText: 'Permanently delete', cancelText: 'Cancel'});
5380
+ } finally {
5381
+ dialogOnce.release();
5382
+ }
5383
+ if (!confirmed || !here() || accountId !== currentAccountId) return;
5384
+ try {
5385
+ const params = new URLSearchParams({account: accountId ?? ''});
5386
+ const result = await api(`${BASE}/folders/empty?${params}`, 'POST', {path: folder.path, confirmed: true});
5387
+ if (!here() || accountId !== currentAccountId) return;
5388
+ E.toast(`Permanently deleted ${result.deleted} message${result.deleted === 1 ? '' : 's'} from Trash.`, {type: 'success'});
5389
+ const open = currentFolder;
5390
+ await loadFolders({quiet: true});
5391
+ const shown = folders.find(f => f.path === open);
5392
+ if (here() && accountId === currentAccountId && shown && currentFolder !== open) await selectFolder(shown);
5393
+ } catch (err) {
5394
+ E.toast(err.message || 'Trash could not be emptied.', {type: 'error'});
5395
+ }
5396
+ }
5397
+
5334
5398
  /**
5335
5399
  * Delete a folder and everything inside it.
5336
5400
  *
@@ -190,3 +190,23 @@ export function resolveSubscriptions(boxes) {
190
190
  }
191
191
  return out;
192
192
  }
193
+
194
+ /** Validate a folder move before sending an IMAP rename. */
195
+ export function moveTarget(folder, parent, folders, delimiter) {
196
+ const allowed = canModifyFolder(folder, {action: 'move'});
197
+ if (!allowed.ok) return {...allowed, status: 409};
198
+ if (typeof parent !== 'string') return {ok: false, status: 400, error: 'Choose a destination folder.'};
199
+ if (!delimiter) return {ok: false, status: 400, error: 'This mailbox does not support nested folders.'};
200
+ if (parent === folder.path || parent.startsWith(`${folder.path}${delimiter}`)) {
201
+ return {ok: false, status: 400, error: 'A folder cannot be moved inside itself or its subfolders.'};
202
+ }
203
+ if (parent && !folders.some(f => f.path === parent)) {
204
+ return {ok: false, status: 404, error: 'That destination folder does not exist.'};
205
+ }
206
+ const name = folder.path.slice(folder.path.lastIndexOf(delimiter) + delimiter.length);
207
+ const path = buildFolderPath(parent, name, delimiter);
208
+ if (path !== folder.path && folders.some(f => f.path === path)) {
209
+ return {ok: false, status: 409, error: `"${path}" already exists.`};
210
+ }
211
+ return {ok: true, path};
212
+ }
@@ -471,3 +471,26 @@ export async function setSpamBulk({
471
471
  uidMap: moved.uidMap, keywords, mirrored: moved.mirrored
472
472
  };
473
473
  }
474
+
475
+ /** Permanently empty a server-designated Trash folder, retaining the folder and its children. */
476
+ export async function emptyTrash({pool, store, account, poolKey, connection, folder}) {
477
+ const boxes = await pool.withClient(poolKey, connection, client => client.list());
478
+ if (!boxes.some(box => box.path === folder && box.specialUse === '\\Trash')) {
479
+ const error = new Error('Only the server-designated Trash folder can be emptied.');
480
+ error.statusCode = 409;
481
+ throw error;
482
+ }
483
+ const deleted = await pool.withMailbox(poolKey, connection, folder, async (client, mailbox) => {
484
+ const count = Number(mailbox.exists);
485
+ if (count === 0) return 0;
486
+ const lastUid = Number(mailbox.uidNext) - 1;
487
+ if (!Number.isSafeInteger(count) || count < 0 || !Number.isSafeInteger(lastUid) || lastUid < 1) {
488
+ throw new Error('The Trash message count could not be verified. Refresh and try again.');
489
+ }
490
+ const result = await client.messageDelete({uid: `1:${lastUid}`}, {uid: true});
491
+ if (result === false) throw new Error('The server refused to empty Trash.');
492
+ return count;
493
+ }, {writable: true});
494
+ if (store) await store.clearFolder(account.id, folder);
495
+ return {deleted, path: folder, expunged: true};
496
+ }