domma-cms 1.10.35 → 1.10.37
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.37] - 2026-10-27
|
|
4
|
+
|
|
5
|
+
- The updater's backup no longer copies the runtime data of plugins it does not update. A marketplace or site plugin's `data/`, `backups/` and `node_modules/` stay out of `.domma-backups` (its code is still copied); the engine's own plugins are still backed up whole. A File Store's members' uploads were otherwise copied into every one of up to sixteen kept backups.
|
|
6
|
+
|
|
7
|
+
## [1.10.36] - 2026-10-27
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
|
|
3
11
|
## [1.10.35] - 2026-10-27
|
|
4
12
|
|
|
5
13
|
- 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.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the update backup takes of plugins/, for the engine updater.
|
|
3
|
+
*
|
|
4
|
+
* The backup is there to undo the update, and the update only ever rewrites
|
|
5
|
+
* the plugins the engine ships (Blog, Mail Reader, ...). Every other plugin -
|
|
6
|
+
* installed from the marketplace or written by the site - is left exactly as
|
|
7
|
+
* it is, so copying its runtime state would back up something the update never
|
|
8
|
+
* touches. And that state can be large: a File Store keeps members' uploads in
|
|
9
|
+
* its data/, and the update backups are kept up to sixteen deep. So:
|
|
10
|
+
*
|
|
11
|
+
* - a plugin the engine ships: copied whole, data/ included (the update
|
|
12
|
+
* replaces its folder, carrying data/ across through memory - the backup
|
|
13
|
+
* is the safety net if that goes wrong);
|
|
14
|
+
* - any other plugin: its code only - no data/, backups/ or node_modules/.
|
|
15
|
+
*
|
|
16
|
+
* @module bin/lib/plugin-backup
|
|
17
|
+
*/
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
/** Runtime folders inside a plugin that a plugin the update does not touch never needs backed up. */
|
|
21
|
+
export const STATE_DIRS = ['data', 'backups', 'node_modules'];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A `cpSync` filter for copying `pluginsDir` into the backup.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} pluginsDir the site's plugins/ folder
|
|
27
|
+
* @param {Iterable<string>} shipped names of the plugins the engine package ships
|
|
28
|
+
* @returns {(src: string) => boolean}
|
|
29
|
+
*/
|
|
30
|
+
export function pluginBackupFilter(pluginsDir, shipped) {
|
|
31
|
+
const engine = new Set(shipped);
|
|
32
|
+
const root = path.resolve(pluginsDir);
|
|
33
|
+
return (src) => {
|
|
34
|
+
const rel = path.relative(root, path.resolve(src));
|
|
35
|
+
if (!rel || rel.startsWith('..')) return true;
|
|
36
|
+
const [plugin, second] = rel.split(path.sep);
|
|
37
|
+
if (second === undefined || engine.has(plugin)) return true;
|
|
38
|
+
return !STATE_DIRS.includes(second);
|
|
39
|
+
};
|
|
40
|
+
}
|
package/bin/update.js
CHANGED
|
@@ -16,6 +16,7 @@ import {fileURLToPath} from 'node:url';
|
|
|
16
16
|
import {deepMergeNewKeys} from './lib/config-merge.js';
|
|
17
17
|
import {shouldReplacePlugin} from './lib/plugin-version.js';
|
|
18
18
|
import {KEEP_UPDATE_BACKUPS, pruneUpdateBackups} from './lib/backup-retention.js';
|
|
19
|
+
import {pluginBackupFilter} from './lib/plugin-backup.js';
|
|
19
20
|
import {removeLeftFreeTier} from './lib/left-free-tier.js';
|
|
20
21
|
|
|
21
22
|
// ---------------------------------------------------------------------------
|
|
@@ -306,10 +307,13 @@ export default async function update(_positional, flags) {
|
|
|
306
307
|
}
|
|
307
308
|
}
|
|
308
309
|
|
|
309
|
-
// Back up plugins
|
|
310
|
+
// Back up plugins/: the engine's own plugins whole (the update replaces them), every other
|
|
311
|
+
// plugin's code only - the update never touches it, and its data/ can be large (bin/lib/plugin-backup.js).
|
|
310
312
|
const pluginsDir = path.join(cwd, 'plugins');
|
|
311
313
|
if (existsSync(pluginsDir)) {
|
|
312
|
-
|
|
314
|
+
const shippedDir = path.join(PACKAGE_ROOT, 'plugins');
|
|
315
|
+
const shipped = existsSync(shippedDir) ? readdirSync(shippedDir, {withFileTypes: true}).filter(e => e.isDirectory()).map(e => e.name) : [];
|
|
316
|
+
cpSync(pluginsDir, path.join(backupPath, 'plugins'), {recursive: true, filter: pluginBackupFilter(pluginsDir, shipped)});
|
|
313
317
|
}
|
|
314
318
|
|
|
315
319
|
done();
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -5114,6 +5114,12 @@ return {
|
|
|
5114
5114
|
action: () => setFolderVisible(folder, folder.subscribed === false)
|
|
5115
5115
|
},
|
|
5116
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'}] : []),
|
|
5117
5123
|
{
|
|
5118
5124
|
label: 'Rename',
|
|
5119
5125
|
icon: 'edit-3',
|
|
@@ -5362,6 +5368,33 @@ return {
|
|
|
5362
5368
|
}
|
|
5363
5369
|
}
|
|
5364
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
|
+
|
|
5365
5398
|
/**
|
|
5366
5399
|
* Delete a folder and everything inside it.
|
|
5367
5400
|
*
|
|
@@ -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
|
+
}
|