domma-cms 1.10.38 → 1.10.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.
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.10.39] - 2026-10-27
|
|
4
|
+
|
|
5
|
+
- Email rules can be sorted into named groups, each switched on or off as a whole; rules run in the order shown (ungrouped first, then each group). The message menu offers "Create a rule from this message…", which opens Rules with a new rule matching the sender (or the mailing list). Requires Email Pro 0.12.0 for the screens.
|
|
6
|
+
|
|
3
7
|
## [1.10.38] - 2026-10-27
|
|
4
8
|
|
|
5
9
|
- Email gains Thunderbird's compose settings. Per mailbox: default read and delivery receipts (SMTP DSN), replies filed in the folder of the message they answer, and require secure delivery (REQUIRETLS - refused, with the reason, when the outgoing server cannot promise it). In this browser: default editor (rich or plain text), automatic draft saving (an automatic-only draft goes on Discard), spell checking, and registering Email as the mailto: link handler. Compose shows Read receipt, Delivery receipt, Sign and Encrypt ticks, and a reopened draft keeps them.
|
package/package.json
CHANGED
|
@@ -50,6 +50,7 @@ const {fillMessageAction, loadMessageActions, messageFacts} = await import(versi
|
|
|
50
50
|
const {isPlainKey, keyBelongsElsewhere, resolveKey, SHORTCUTS} = await import(versioned('keyboard.js'));
|
|
51
51
|
const {menuPoint, quoteSelection, readBridgeMessage, selectionItems} = await import(versioned('body-menu.js'));
|
|
52
52
|
const {describeWhen, quickTimes} = await import(versioned('scheduling.js'));
|
|
53
|
+
const {ruleFromMessage} = await import(versioned('rules.js'));
|
|
53
54
|
// Optional for the same reason as oauth-panel.js below.
|
|
54
55
|
const {securityNote} = await import(versioned('security-note.js')).catch(() => ({securityNote: () => null}));
|
|
55
56
|
// Sign in with Microsoft / Google (Email Pro). Optional: an older copy of this
|
|
@@ -194,6 +195,8 @@ function formatSize(bytes) {
|
|
|
194
195
|
*/
|
|
195
196
|
export function createReaderView({pluginName, assetVersion = ASSET_VERSION, settingsSections = [], features = {},
|
|
196
197
|
extensions = {}}) {
|
|
198
|
+
// Filing rules come with an edition's Rules tab (Email Pro).
|
|
199
|
+
const hasRules = settingsSections.some(section => section?.id === 'rules');
|
|
197
200
|
const BASE = `/api/plugins/${pluginName}`;
|
|
198
201
|
|
|
199
202
|
// Deliberately NOT keyed by plugin. The two editions never run together - the
|
|
@@ -910,13 +913,16 @@ return {
|
|
|
910
913
|
*
|
|
911
914
|
* @returns {void}
|
|
912
915
|
*/
|
|
913
|
-
function openSettings() {
|
|
916
|
+
function openSettings(target = null) {
|
|
914
917
|
// One at a time: a second click, or a needs-password error while
|
|
915
918
|
// it is open, must not stack a second panel on the first.
|
|
916
|
-
if (!settingsOnce.claim())
|
|
919
|
+
if (!settingsOnce.claim()) {
|
|
920
|
+
if (target) E.toast('Mail Settings is already open. Close it first.', {type: 'info'});
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
917
923
|
let panel = null;
|
|
918
924
|
try {
|
|
919
|
-
buildSettings((so) => { panel = so; });
|
|
925
|
+
buildSettings((so) => { panel = so; }, target);
|
|
920
926
|
} catch (err) {
|
|
921
927
|
// Failed before it opened: let go of everything it took.
|
|
922
928
|
panel?.__mailAbandon?.();
|
|
@@ -926,7 +932,13 @@ return {
|
|
|
926
932
|
}
|
|
927
933
|
|
|
928
934
|
/** @returns {void} */
|
|
929
|
-
|
|
935
|
+
/**
|
|
936
|
+
* @param {Function} [made] - handed the slideover once it exists
|
|
937
|
+
* @param {{section: string, request?: object}|null} [target] - open on
|
|
938
|
+
* this tab, and hand its section `ctx.request` (once) - e.g. the
|
|
939
|
+
* rule a message's "Create a rule…" starts
|
|
940
|
+
*/
|
|
941
|
+
function buildSettings(made = () => {}, target = null) {
|
|
930
942
|
// Help popovers per drawn section, let go when the panel closes.
|
|
931
943
|
const helpDisposers = [];
|
|
932
944
|
// What sections registered through ctx.guard / ctx.onClose.
|
|
@@ -1007,8 +1019,11 @@ return {
|
|
|
1007
1019
|
*
|
|
1008
1020
|
* @returns {object}
|
|
1009
1021
|
*/
|
|
1010
|
-
function context() {
|
|
1022
|
+
function context(section = null) {
|
|
1011
1023
|
return {
|
|
1024
|
+
// What opened this section, when something did (a menu
|
|
1025
|
+
// item asking for a new rule). Only for that section.
|
|
1026
|
+
request: target && section?.id === target.section ? (target.request ?? null) : null,
|
|
1012
1027
|
account: mailboxes.find(b => b.id === currentAccountId) ?? mailboxes[0] ?? null,
|
|
1013
1028
|
mailboxes,
|
|
1014
1029
|
base: BASE,
|
|
@@ -1086,7 +1101,7 @@ return {
|
|
|
1086
1101
|
panel.appendChild(head);
|
|
1087
1102
|
}
|
|
1088
1103
|
try {
|
|
1089
|
-
await section.render(panel, context());
|
|
1104
|
+
await section.render(panel, context(section));
|
|
1090
1105
|
} catch (err) {
|
|
1091
1106
|
const message = document.createElement('p');
|
|
1092
1107
|
message.className = 'text-sm text-danger';
|
|
@@ -1135,8 +1150,15 @@ return {
|
|
|
1135
1150
|
host.appendChild(tabs);
|
|
1136
1151
|
slideover.element.appendChild(host);
|
|
1137
1152
|
E.tabs(tabs);
|
|
1138
|
-
|
|
1139
|
-
|
|
1153
|
+
// The tab asked for, through its own trigger so the tabs
|
|
1154
|
+
// component switches as it would on a click; else the first.
|
|
1155
|
+
const first = panels.findIndex(p => p.section.id === target?.section);
|
|
1156
|
+
if (first > 0) {
|
|
1157
|
+
list.children[first].click();
|
|
1158
|
+
} else {
|
|
1159
|
+
panels[0].drawn = true;
|
|
1160
|
+
draw(panels[0].section, panels[0].panel);
|
|
1161
|
+
}
|
|
1140
1162
|
slideover.open();
|
|
1141
1163
|
return;
|
|
1142
1164
|
}
|
|
@@ -3742,6 +3764,12 @@ return {
|
|
|
3742
3764
|
{type: 'divider'}
|
|
3743
3765
|
] : []),
|
|
3744
3766
|
...toolActionItems(message),
|
|
3767
|
+
// An edition with filing rules: start one from this message.
|
|
3768
|
+
...(hasRules ? [{
|
|
3769
|
+
label: 'Create a rule from this message\u2026',
|
|
3770
|
+
icon: 'filter',
|
|
3771
|
+
action: () => openSettings({section: 'rules', request: {newRule: ruleFromMessage(message)}})
|
|
3772
|
+
}] : []),
|
|
3745
3773
|
{type: 'divider'},
|
|
3746
3774
|
{
|
|
3747
3775
|
label: 'Mark as spam',
|
|
@@ -32,6 +32,14 @@ export const MAX_RULES = 50;
|
|
|
32
32
|
/** How many conditions or actions one rule may hold. */
|
|
33
33
|
export const MAX_CLAUSES = 10;
|
|
34
34
|
|
|
35
|
+
/** How many groups one mailbox's rules may be sorted into. */
|
|
36
|
+
export const MAX_GROUPS = 20;
|
|
37
|
+
|
|
38
|
+
/** A group name: one line, short, trimmed. */
|
|
39
|
+
function groupName(value) {
|
|
40
|
+
return String(value ?? '').replace(/[\r\n\t]+/g, ' ').trim().slice(0, 60);
|
|
41
|
+
}
|
|
42
|
+
|
|
35
43
|
/**
|
|
36
44
|
* The parts of a message a rule can look at.
|
|
37
45
|
*
|
|
@@ -217,6 +225,9 @@ export function normaliseRule(input, index = 0) {
|
|
|
217
225
|
: `rule-${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36).slice(-4)}`,
|
|
218
226
|
name: String(source.name ?? '').replace(/[\r\n]+/g, ' ').trim() || `Rule ${index + 1}`,
|
|
219
227
|
enabled: source.enabled !== false,
|
|
228
|
+
// The group it is filed under; '' for none. A group is a NAME, so a
|
|
229
|
+
// rule and the group list can never point at an id that went away.
|
|
230
|
+
group: groupName(source.group),
|
|
220
231
|
match: source.match === 'any' ? 'any' : 'all',
|
|
221
232
|
conditions,
|
|
222
233
|
actions
|
|
@@ -244,6 +255,76 @@ export function normaliseRules(account) {
|
|
|
244
255
|
return list;
|
|
245
256
|
}
|
|
246
257
|
|
|
258
|
+
/**
|
|
259
|
+
* A mailbox's rule groups, in the order they are shown and run: the stored
|
|
260
|
+
* list (`account.ruleGroups`, `[{name, enabled}]`), then any group a rule
|
|
261
|
+
* names that the list lacks - so a group cannot vanish from under its rules.
|
|
262
|
+
* Names compare without case; the first spelling wins.
|
|
263
|
+
*
|
|
264
|
+
* @param {object|null|undefined} account - `{ruleGroups, rules}`
|
|
265
|
+
* @returns {{name: string, enabled: boolean}[]}
|
|
266
|
+
*/
|
|
267
|
+
export function normaliseRuleGroups(account) {
|
|
268
|
+
const source = account && typeof account === 'object' ? account : {};
|
|
269
|
+
const out = [];
|
|
270
|
+
const seen = new Set();
|
|
271
|
+
const add = (name, enabled) => {
|
|
272
|
+
const clean = groupName(name);
|
|
273
|
+
if (!clean || seen.has(clean.toLowerCase()) || out.length >= MAX_GROUPS) return;
|
|
274
|
+
seen.add(clean.toLowerCase());
|
|
275
|
+
out.push({name: clean, enabled: enabled !== false});
|
|
276
|
+
};
|
|
277
|
+
for (const group of Array.isArray(source.ruleGroups) ? source.ruleGroups : []) {
|
|
278
|
+
add(typeof group === 'string' ? group : group?.name, group?.enabled);
|
|
279
|
+
}
|
|
280
|
+
for (const rule of Array.isArray(source.rules) ? source.rules : []) add(rule?.group, true);
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The rules that actually run: switched on, and not in a group that is
|
|
286
|
+
* switched off. Order is kept - it is the order they run in.
|
|
287
|
+
*
|
|
288
|
+
* @param {object[]} rules
|
|
289
|
+
* @param {{name: string, enabled: boolean}[]} [groups]
|
|
290
|
+
* @returns {object[]}
|
|
291
|
+
*/
|
|
292
|
+
export function rulesInEffect(rules, groups = []) {
|
|
293
|
+
const off = new Set((groups ?? []).filter(g => g.enabled === false).map(g => g.name.toLowerCase()));
|
|
294
|
+
return (rules ?? []).filter(rule => rule?.enabled !== false
|
|
295
|
+
&& !(rule.group && off.has(String(rule.group).toLowerCase())));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* A new rule started from a message - "Create a rule from this message".
|
|
300
|
+
*
|
|
301
|
+
* Mail from a mailing list is caught by its list; anything else by its
|
|
302
|
+
* sender's address, the narrowest thing that still catches the next one.
|
|
303
|
+
* The action is a move with the folder left to choose: the rule is not saved
|
|
304
|
+
* until someone has picked where the mail goes and pressed Save.
|
|
305
|
+
*
|
|
306
|
+
* @param {object} message - a list row or opened message
|
|
307
|
+
* @returns {object}
|
|
308
|
+
*/
|
|
309
|
+
export function ruleFromMessage(message) {
|
|
310
|
+
const sender = (message?.from ?? [])[0] ?? {};
|
|
311
|
+
const address = String(sender.address ?? '').trim();
|
|
312
|
+
const list = String(message?.listId ?? '').trim();
|
|
313
|
+
const condition = list
|
|
314
|
+
? {field: 'list', op: 'contains', value: list.replace(/^.*<|>.*$/g, '')}
|
|
315
|
+
: {field: 'from', op: 'contains', value: address};
|
|
316
|
+
const who = list ? `the ${list.replace(/^.*<|>.*$/g, '')} list` : (sender.name || address || 'this sender');
|
|
317
|
+
return {
|
|
318
|
+
id: '',
|
|
319
|
+
name: `Mail from ${who}`.slice(0, 120),
|
|
320
|
+
enabled: true,
|
|
321
|
+
match: 'all',
|
|
322
|
+
group: '',
|
|
323
|
+
conditions: condition.value ? [condition] : [{field: 'from', op: 'contains', value: ''}],
|
|
324
|
+
actions: [{type: 'move', folder: ''}]
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
247
328
|
/**
|
|
248
329
|
* Does this rule apply to this message?
|
|
249
330
|
*
|
|
@@ -30,7 +30,7 @@ import path from 'node:path';
|
|
|
30
30
|
import {fileURLToPath} from 'node:url';
|
|
31
31
|
|
|
32
32
|
import {normaliseIdentity, normaliseIdentityList, findIdentity} from '../admin/mail/identity.js';
|
|
33
|
-
import {normaliseRules} from '../admin/mail/rules.js';
|
|
33
|
+
import {normaliseRuleGroups, normaliseRules, rulesInEffect} from '../admin/mail/rules.js';
|
|
34
34
|
import {normaliseUndoSeconds} from '../admin/mail/scheduling.js';
|
|
35
35
|
import {normaliseVacation} from '../admin/mail/vacation.js';
|
|
36
36
|
import {normaliseTemplates} from '../admin/mail/templates.js';
|
|
@@ -639,6 +639,26 @@ export function rulesToPublic(account) {
|
|
|
639
639
|
return normaliseRules(account);
|
|
640
640
|
}
|
|
641
641
|
|
|
642
|
+
/**
|
|
643
|
+
* The groups this mailbox's rules are sorted into, in order.
|
|
644
|
+
*
|
|
645
|
+
* @param {object|null} account
|
|
646
|
+
* @returns {{name: string, enabled: boolean}[]}
|
|
647
|
+
*/
|
|
648
|
+
export function ruleGroupsToPublic(account) {
|
|
649
|
+
return normaliseRuleGroups({ruleGroups: account?.ruleGroups, rules: normaliseRules(account)});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* The rules a sync pass runs: switched on, and not in a switched-off group.
|
|
654
|
+
*
|
|
655
|
+
* @param {object|null} account
|
|
656
|
+
* @returns {object[]}
|
|
657
|
+
*/
|
|
658
|
+
export function rulesToRun(account) {
|
|
659
|
+
return rulesInEffect(normaliseRules(account), ruleGroupsToPublic(account));
|
|
660
|
+
}
|
|
661
|
+
|
|
642
662
|
/**
|
|
643
663
|
* Store the filing rules for a mailbox.
|
|
644
664
|
*
|
|
@@ -653,15 +673,19 @@ export function rulesToPublic(account) {
|
|
|
653
673
|
* @param {object[]} rules
|
|
654
674
|
* @returns {Promise<object|null>}
|
|
655
675
|
*/
|
|
656
|
-
export async function setRules(userId, accountId, rules) {
|
|
676
|
+
export async function setRules(userId, accountId, rules, groups = undefined) {
|
|
657
677
|
return withStoreLock(async () => {
|
|
658
678
|
const all = await readAll();
|
|
659
679
|
const index = all.findIndex(a => a.id === accountId && a.userId === userId);
|
|
660
680
|
if (index === -1) return null;
|
|
661
681
|
|
|
682
|
+
const saved = normaliseRules({rules});
|
|
662
683
|
all[index] = {
|
|
663
684
|
...all[index],
|
|
664
|
-
rules:
|
|
685
|
+
rules: saved,
|
|
686
|
+
// Only when given: a caller that knows nothing of groups (an older
|
|
687
|
+
// screen) keeps the stored ones.
|
|
688
|
+
...(Array.isArray(groups) ? {ruleGroups: normaliseRuleGroups({ruleGroups: groups, rules: saved})} : {}),
|
|
665
689
|
rulesFrom: all[index].rulesFrom ?? new Date().toISOString(),
|
|
666
690
|
updatedAt: new Date().toISOString()
|
|
667
691
|
};
|
|
@@ -218,7 +218,10 @@ export async function syncAccount({pool, store, account, config}) {
|
|
|
218
218
|
const {paths, source} = resolveSyncFolders(account, folders);
|
|
219
219
|
const results = {};
|
|
220
220
|
|
|
221
|
-
|
|
221
|
+
// Switched on, and not in a switched-off group.
|
|
222
|
+
const rules = accounts.rulesToRun
|
|
223
|
+
? accounts.rulesToRun(account)
|
|
224
|
+
: accounts.rulesToPublic(account).filter(rule => rule.enabled);
|
|
222
225
|
const ruleFolders = new Set(rules.length ? accounts.ruleFolders(account) : []);
|
|
223
226
|
|
|
224
227
|
// The away message answers mail arriving in the same folders rules run
|