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 +8 -0
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/docs/screenshots/email-folder-move.png +0 -0
- package/docs/screenshots/email-menu-scroll.png +0 -0
- package/docs/screenshots/email-not-spam.png +0 -0
- package/package.json +2 -2
- package/plugins/_lib/admin/mail/reader-view.js +55 -24
- package/plugins/_lib/mail/folders.js +20 -0
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.
|
|
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-
|
|
6
|
-
* Commit:
|
|
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.
|
|
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-
|
|
6
|
-
* Commit:
|
|
5
|
+
* Built: 2026-10-27T10:51:32.116Z
|
|
6
|
+
* Commit: 721b19e
|
|
7
7
|
*
|
|
8
8
|
* Requires: domma.min.js
|
|
9
9
|
*/
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "domma-cms",
|
|
3
|
-
"version": "1.10.
|
|
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.
|
|
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 {
|
|
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 =
|
|
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
|
|
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
|
+
}
|