domma-cms 1.10.34 → 1.10.35

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.10.35] - 2026-10-27
4
+
5
+ - 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.
6
+ - 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.
7
+ - Open messages in Spam have a visible Not spam action that returns them to Inbox.
8
+ - Update Domma JS for context menus that remain open while their contents scroll on smaller screens.
9
+ - **Built against domma-js 1.0.1** - see [its release notes](https://github.com/pinpointzero73/domma/releases/tag/v1.0.1).
10
+
3
11
  ## [1.10.34] - 2026-10-27
4
12
 
5
13
  - 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.35",
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;
@@ -5122,6 +5120,12 @@ return {
5122
5120
  disabled: () => locked,
5123
5121
  action: () => renameFolder(folder)
5124
5122
  },
5123
+ {
5124
+ label: 'Move to...',
5125
+ icon: 'folder',
5126
+ disabled: () => locked || !folder.delimiter,
5127
+ action: () => moveFolder(folder)
5128
+ },
5125
5129
  {
5126
5130
  label: 'Delete',
5127
5131
  icon: 'trash',
@@ -5187,11 +5191,12 @@ return {
5187
5191
  * inputs at all.
5188
5192
  *
5189
5193
  * @param {string|null} preselect
5194
+ * @param {object|null} moving - the folder to relocate, or null to create
5190
5195
  * @returns {Promise<{parent: string, name: string}|null>}
5191
5196
  */
5192
- function askForFolder(preselect = null) {
5197
+ function askForFolder(preselect = null, moving = null) {
5193
5198
  return new Promise(resolve => {
5194
- const modal = trackModal(E.modal({title: 'New folder', size: 'sm'}));
5199
+ const modal = trackModal(E.modal({title: moving ? `Move ${moving.name}` : 'New folder', size: 'sm', buttons: []}));
5195
5200
 
5196
5201
  const wrap = document.createElement('div');
5197
5202
  wrap.className = 'mail-form-stack';
@@ -5230,7 +5235,8 @@ return {
5230
5235
  // A level the server never listed cannot hold mail. Shown
5231
5236
  // anyway, because leaving a hole makes its children look
5232
5237
  // top-level, but not choosable.
5233
- if (node.selectable === false) option.disabled = true;
5238
+ if (node.selectable === false || (moving && (node.path === moving.path
5239
+ || node.path.startsWith(`${moving.path}${moving.delimiter}`)))) option.disabled = true;
5234
5240
  // The full path where the indentation is ambiguous.
5235
5241
  option.title = node.path;
5236
5242
  if (node.path === preselect) option.selected = true;
@@ -5244,7 +5250,7 @@ return {
5244
5250
  cancel.textContent = 'Cancel';
5245
5251
  const create = document.createElement('button');
5246
5252
  create.className = 'btn btn-sm btn-primary';
5247
- create.textContent = 'Create';
5253
+ create.textContent = moving ? 'Move' : 'Create';
5248
5254
 
5249
5255
  let settled = false;
5250
5256
  const finish = (value) => {
@@ -5256,7 +5262,7 @@ return {
5256
5262
 
5257
5263
  cancel.addEventListener('click', () => finish(null));
5258
5264
  create.addEventListener('click', () => {
5259
- const name = nameInput.value.trim();
5265
+ const name = moving ? moving.name : nameInput.value.trim();
5260
5266
  if (!name) { nameInput.focus(); return; }
5261
5267
  finish({parent: parentSelect.value, name});
5262
5268
  });
@@ -5268,12 +5274,12 @@ return {
5268
5274
 
5269
5275
  actions.appendChild(cancel);
5270
5276
  actions.appendChild(create);
5271
- for (const el of [nameLabel, nameInput, parentLabel, parentSelect, actions]) {
5277
+ for (const el of [...(moving ? [] : [nameLabel, nameInput]), parentLabel, parentSelect, actions]) {
5272
5278
  wrap.appendChild(el);
5273
5279
  }
5274
5280
  modal.element.appendChild(wrap);
5275
5281
  modal.open?.();
5276
- setTimeout(() => nameInput.focus(), 60);
5282
+ setTimeout(() => (moving ? parentSelect : nameInput).focus(), 60);
5277
5283
  });
5278
5284
  }
5279
5285
 
@@ -5303,6 +5309,31 @@ return {
5303
5309
  }
5304
5310
  }
5305
5311
 
5312
+ /** Move a folder and its children within the current mailbox. */
5313
+ async function moveFolder(folder) {
5314
+ if (!dialogOnce.claim()) return;
5315
+ const accountId = currentAccountId;
5316
+ const cut = folder.path.lastIndexOf(folder.delimiter);
5317
+ const parent = cut < 0 ? '' : folder.path.slice(0, cut);
5318
+ let chosen;
5319
+ try {
5320
+ chosen = await askForFolder(parent, folder);
5321
+ } finally {
5322
+ dialogOnce.release();
5323
+ }
5324
+ if (!chosen || !here() || accountId !== currentAccountId || chosen.parent === parent) return;
5325
+ try {
5326
+ const params = new URLSearchParams({account: accountId ?? ''});
5327
+ const result = await api(`${BASE}/folders?${params}`, 'PUT',
5328
+ {path: folder.path, parent: chosen.parent});
5329
+ if (!here() || accountId !== currentAccountId) return;
5330
+ E.toast(`Moved to ${result.path}.`, {type: 'success'});
5331
+ await loadFolders({quiet: true});
5332
+ } catch (err) {
5333
+ E.toast(err.message || 'That folder could not be moved.', {type: 'error'});
5334
+ }
5335
+ }
5336
+
5306
5337
  /**
5307
5338
  * Rename a folder, leaving it where it is.
5308
5339
  *
@@ -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
+ }