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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.10.38",
3
+ "version": "1.10.39",
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",
@@ -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()) return;
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
- function buildSettings(made = () => {}) {
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
- panels[0].drawn = true;
1139
- draw(panels[0].section, panels[0].panel);
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: normaliseRules({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
- const rules = accounts.rulesToPublic(account).filter(rule => rule.enabled);
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