domma-cms 0.55.1 → 0.66.0
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/CLAUDE.md +130 -2
- package/README.md +4 -4
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +3 -3
- package/admin/js/lib/page-picker.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/slideover-resizable.js +1 -0
- package/admin/js/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/plugin-code.html +1 -1
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +16 -97
- package/admin/js/templates/theme.html +173 -0
- package/admin/js/views/context-menu-editor.js +55 -0
- package/admin/js/views/context-menus.js +5 -0
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +25 -23
- package/admin/js/views/search.js +1 -0
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -0
- package/bin/cli.js +2 -0
- package/bin/update.js +13 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/theme.json +18 -0
- package/package.json +12 -4
- package/plugins/_lib/admin/mail/compose-window.js +914 -0
- package/plugins/_lib/admin/mail/contacts.js +301 -0
- package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
- package/plugins/_lib/admin/mail/folder-tree.js +254 -0
- package/plugins/_lib/admin/mail/identity.js +480 -0
- package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
- package/plugins/_lib/admin/mail/keyboard.js +136 -0
- package/plugins/_lib/admin/mail/mail.css +1 -0
- package/plugins/_lib/admin/mail/mail.html +71 -0
- package/plugins/_lib/admin/mail/panes.js +253 -0
- package/plugins/_lib/admin/mail/reader-view.js +4453 -0
- package/plugins/_lib/admin/mail/resizable.js +26 -0
- package/plugins/_lib/admin/mail/rules.js +343 -0
- package/plugins/_lib/admin/mail/scheduling.js +203 -0
- package/plugins/_lib/admin/mail/section-kit.js +277 -0
- package/plugins/_lib/admin/mail/templates.js +238 -0
- package/plugins/_lib/admin/mail/threads.js +200 -0
- package/plugins/_lib/admin/mail/vacation.js +269 -0
- package/plugins/_lib/admin/ui/help.css +1 -0
- package/plugins/_lib/admin/ui/help.js +174 -0
- package/plugins/_lib/admin/ui/resizable.js +151 -0
- package/plugins/_lib/dataStore.js +117 -0
- package/plugins/_lib/mail/accounts.js +919 -0
- package/plugins/_lib/mail/bodyTokens.js +101 -0
- package/plugins/_lib/mail/compose.js +256 -0
- package/plugins/_lib/mail/defaults.js +57 -0
- package/plugins/_lib/mail/diagnostics.js +154 -0
- package/plugins/_lib/mail/envelope.js +161 -0
- package/plugins/_lib/mail/folders.js +192 -0
- package/plugins/_lib/mail/handoff.js +274 -0
- package/plugins/_lib/mail/imapPool.js +293 -0
- package/plugins/_lib/mail/mbox.js +74 -0
- package/plugins/_lib/mail/pollSchedule.js +79 -0
- package/plugins/_lib/mail/poller.js +135 -0
- package/plugins/_lib/mail/priority.js +138 -0
- package/plugins/_lib/mail/readRoutes.js +680 -0
- package/plugins/_lib/mail/render.js +291 -0
- package/plugins/_lib/mail/ruleRunner.js +152 -0
- package/plugins/_lib/mail/scheduler.js +254 -0
- package/plugins/_lib/mail/secretbox.js +229 -0
- package/plugins/_lib/mail/send.js +396 -0
- package/plugins/_lib/mail/store.js +1002 -0
- package/plugins/_lib/mail/sync.js +292 -0
- package/plugins/_lib/mail/syncPlan.js +126 -0
- package/plugins/_lib/mail/syncSelection.js +82 -0
- package/plugins/_lib/mail/unsubscribe.js +183 -0
- package/plugins/_lib/mail/vacationRunner.js +114 -0
- package/plugins/_lib/mail/write.js +473 -0
- package/plugins/_lib/schemaSync.js +83 -0
- package/plugins/_template/admin/css/index.css +0 -0
- package/plugins/_template/admin/templates/index.html +4 -4
- package/plugins/_template/admin/views/index.js +7 -0
- package/plugins/analytics/admin/css/index.css +1 -0
- package/plugins/analytics/admin/templates/analytics.html +22 -13
- package/plugins/analytics/plugin.json +3 -0
- package/plugins/blog/admin/css/index.css +1 -0
- package/plugins/blog/admin/templates/blog.html +30 -18
- package/plugins/blog/admin/templates/categories.html +2 -2
- package/plugins/blog/admin/templates/comments.html +2 -2
- package/plugins/blog/admin/templates/post-editor.html +34 -34
- package/plugins/blog/admin/templates/settings.html +6 -3
- package/plugins/blog/admin/views/blog.js +8 -5
- package/plugins/blog/admin/views/categories.js +5 -10
- package/plugins/blog/admin/views/comments.js +5 -5
- package/plugins/blog/admin/views/post-editor.js +39 -20
- package/plugins/blog/admin/views/settings.js +52 -50
- package/plugins/blog/collections/categories/schema.json +7 -6
- package/plugins/blog/collections/comments/schema.json +11 -10
- package/plugins/blog/collections/posts/schema.json +14 -13
- package/plugins/blog/plugin.js +36 -13
- package/plugins/blog/plugin.json +13 -5
- package/plugins/blog/plugin.public.js +312 -0
- package/plugins/contacts/admin/css/index.css +1 -0
- package/plugins/contacts/admin/templates/contacts.html +128 -0
- package/plugins/contacts/admin/views/contacts.js +237 -4
- package/plugins/contacts/collections/user-contacts/schema.json +108 -0
- package/plugins/contacts/plugin.js +214 -27
- package/plugins/contacts/plugin.json +4 -1
- package/plugins/invoice/admin/css/index.css +1 -0
- package/plugins/invoice/admin/templates/editor.html +140 -49
- package/plugins/invoice/admin/templates/index.html +153 -23
- package/plugins/invoice/admin/templates/issuers.html +2 -5
- package/plugins/invoice/admin/templates/receivers.html +2 -5
- package/plugins/invoice/admin/views/contacts-source.js +266 -0
- package/plugins/invoice/admin/views/editor.js +366 -199
- package/plugins/invoice/admin/views/export.js +199 -0
- package/plugins/invoice/admin/views/help-content.js +61 -0
- package/plugins/invoice/admin/views/index.js +582 -94
- package/plugins/invoice/admin/views/issuers.js +24 -17
- package/plugins/invoice/admin/views/media.js +172 -0
- package/plugins/invoice/admin/views/party-view.js +305 -67
- package/plugins/invoice/admin/views/payments.js +127 -0
- package/plugins/invoice/admin/views/print.js +130 -0
- package/plugins/invoice/admin/views/receivers.js +49 -16
- package/plugins/invoice/admin/views/send.js +212 -0
- package/plugins/invoice/admin/views/settings.js +594 -0
- package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
- package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
- package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
- package/plugins/invoice/collections/invoices/schema.json +19 -13
- package/plugins/invoice/config.js +27 -6
- package/plugins/invoice/pdf.js +164 -0
- package/plugins/invoice/plugin.js +1217 -44
- package/plugins/invoice/plugin.json +10 -9
- package/plugins/invoice/templates/_base.css +1 -0
- package/plugins/invoice/templates/classic-nologo.html +100 -0
- package/plugins/invoice/templates/classic.html +91 -0
- package/plugins/invoice/templates/invoice-print.html +24 -0
- package/plugins/invoice/templates/minimal.html +99 -0
- package/plugins/invoice/templates/modern-nologo.html +114 -0
- package/plugins/invoice/templates/modern.html +113 -0
- package/plugins/invoice/templates/templates.json +11 -0
- package/plugins/mail-reader/admin/views/mail.js +19 -0
- package/plugins/mail-reader/config.js +7 -0
- package/plugins/mail-reader/plugin.js +48 -0
- package/plugins/mail-reader/plugin.json +33 -0
- package/plugins/notes/admin/views/notes.js +1 -1
- package/plugins/notes/plugin.json +2 -2
- package/plugins/surveys/lib/audience.js +37 -0
- package/plugins/surveys/lib/campaigns.js +43 -0
- package/plugins/surveys/lib/ledger.js +110 -0
- package/plugins/surveys/lib/sending.js +106 -0
- package/plugins/surveys/lib/stats.js +62 -0
- package/plugins/surveys/lib/submit.js +95 -0
- package/plugins/surveys/lib/tokens.js +28 -0
- package/plugins/surveys/plugin.public.js +149 -0
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
- package/public/css/forms.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/search.js +1 -0
- package/public/js/site.js +1 -1
- package/scripts/build.js +37 -3
- package/scripts/copy-domma.js +48 -0
- package/scripts/seed.js +1996 -0
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/forms.js +42 -3
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugins.js +50 -6
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +74 -0
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/markdown.js +70 -9
- package/server/services/permissionRegistry.js +24 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/plugins.js +229 -6
- package/server/services/renderer.js +144 -22
- package/server/services/roles.js +1 -1
- package/server/services/search-migration.js +82 -0
- package/server/services/search.js +413 -0
- package/server/services/sidebar-migration.js +1 -0
- package/server/services/themeSettings.js +541 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/notes/data/notes.json +0 -1
- package/plugins/site-search/admin/views/site-search.js +0 -116
- package/plugins/site-search/config.js +0 -15
- package/plugins/site-search/plugin.js +0 -188
- package/plugins/site-search/plugin.json +0 -40
- package/plugins/site-search/public/inject-body.html +0 -17
- package/plugins/site-search/public/inject-head.html +0 -1
- package/plugins/site-search/public/search.css +0 -1
- package/plugins/site-search/public/search.js +0 -1
- package/plugins/todo/data/todos.json +0 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mail reader's resizable slideovers.
|
|
3
|
+
*
|
|
4
|
+
* The behaviour is generic and lives in `_lib/admin/ui/resizable.js`; this
|
|
5
|
+
* supplies the handle class mail.css styles and keeps the import path every
|
|
6
|
+
* mail module already uses.
|
|
7
|
+
*
|
|
8
|
+
* The inner import carries this module's own `?v=` token: a static import
|
|
9
|
+
* would be fetched unversioned and served from cache for as long as the
|
|
10
|
+
* browser felt like it, which is exactly the staleness the token exists to
|
|
11
|
+
* prevent.
|
|
12
|
+
*
|
|
13
|
+
* @module _lib/admin/mail/resizable
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const ASSET_VERSION = new URL(import.meta.url).searchParams.get('v') ?? '1';
|
|
17
|
+
const {makeResizable: resize} = await import(`../ui/resizable.js?v=${ASSET_VERSION}`);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} slideover - a Domma slideover instance
|
|
21
|
+
* @param {{storageKey: string, defaultWidth?: number}} options
|
|
22
|
+
* @returns {() => void} a teardown for the listeners this adds
|
|
23
|
+
*/
|
|
24
|
+
export function makeResizable(slideover, options) {
|
|
25
|
+
return resize(slideover, {handleClass: 'mail-slideover-resizer', ...options});
|
|
26
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filing rules.
|
|
3
|
+
*
|
|
4
|
+
* What a mail client is actually for, once the mailbox is bigger than a day's
|
|
5
|
+
* reading: mail that files itself. A rule is a set of conditions and a set of
|
|
6
|
+
* actions, evaluated against the envelope the mirror already holds, so no
|
|
7
|
+
* extra fetch is needed to decide what to do with a message.
|
|
8
|
+
*
|
|
9
|
+
* ## Pure, and browser-side too
|
|
10
|
+
*
|
|
11
|
+
* The same module the settings screen uses to describe the vocabulary and to
|
|
12
|
+
* preview what a rule would have caught. Evaluating a draft rule against the
|
|
13
|
+
* last few hundred messages, in the browser, before saving it, is the
|
|
14
|
+
* difference between a rules feature people trust and one they try once.
|
|
15
|
+
*
|
|
16
|
+
* See the placement note at the top of `identity.js` for why this is under
|
|
17
|
+
* `admin/` rather than beside the sync engine that runs it.
|
|
18
|
+
*
|
|
19
|
+
* ## No regular expressions
|
|
20
|
+
*
|
|
21
|
+
* `matches` is a wildcard, not a regex. A stored regex is a pattern someone
|
|
22
|
+
* else's CPU evaluates on every message that arrives, which is a denial of
|
|
23
|
+
* service waiting for a nested quantifier, and `*@example.com` is what people
|
|
24
|
+
* actually mean when they reach for one anyway.
|
|
25
|
+
*
|
|
26
|
+
* @module _lib/admin/mail/rules
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** How many rules one mailbox may hold. */
|
|
30
|
+
export const MAX_RULES = 50;
|
|
31
|
+
|
|
32
|
+
/** How many conditions or actions one rule may hold. */
|
|
33
|
+
export const MAX_CLAUSES = 10;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The parts of a message a rule can look at.
|
|
37
|
+
*
|
|
38
|
+
* Only what the mirror stores. There is deliberately no `body` - the store
|
|
39
|
+
* holds envelopes and never message text, and a rule that needed the body
|
|
40
|
+
* would mean downloading every message to decide whether to file it.
|
|
41
|
+
*/
|
|
42
|
+
export const RULE_FIELDS = [
|
|
43
|
+
{field: 'from', label: 'Sender', kind: 'address'},
|
|
44
|
+
{field: 'to', label: 'Recipient', kind: 'address'},
|
|
45
|
+
{field: 'subject', label: 'Subject', kind: 'text'},
|
|
46
|
+
{field: 'list', label: 'Mailing list', kind: 'text'},
|
|
47
|
+
{field: 'hasAttachment', label: 'Has an attachment', kind: 'boolean'},
|
|
48
|
+
{field: 'priority', label: 'Priority', kind: 'text'},
|
|
49
|
+
{field: 'size', label: 'Size in KB', kind: 'number'}
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** How a field is compared, and which kinds of field each one suits. */
|
|
53
|
+
export const RULE_OPS = [
|
|
54
|
+
{op: 'contains', label: 'contains', kinds: ['address', 'text']},
|
|
55
|
+
{op: 'notContains', label: 'does not contain', kinds: ['address', 'text']},
|
|
56
|
+
{op: 'equals', label: 'is exactly', kinds: ['address', 'text']},
|
|
57
|
+
{op: 'startsWith', label: 'starts with', kinds: ['address', 'text']},
|
|
58
|
+
{op: 'endsWith', label: 'ends with', kinds: ['address', 'text']},
|
|
59
|
+
{op: 'matches', label: 'matches (use * as a wildcard)', kinds: ['address', 'text']},
|
|
60
|
+
{op: 'isTrue', label: 'is true', kinds: ['boolean']},
|
|
61
|
+
{op: 'isFalse', label: 'is false', kinds: ['boolean']},
|
|
62
|
+
{op: 'greaterThan', label: 'is more than', kinds: ['number']},
|
|
63
|
+
{op: 'lessThan', label: 'is less than', kinds: ['number']}
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What a rule can do to a message.
|
|
68
|
+
*
|
|
69
|
+
* `delete` moves to Trash rather than expunging. A rule that silently
|
|
70
|
+
* destroyed mail would be the one feature in this plugin capable of losing
|
|
71
|
+
* something that cannot be got back, and a misplaced condition is exactly
|
|
72
|
+
* the kind of mistake people make while learning rules.
|
|
73
|
+
*/
|
|
74
|
+
export const RULE_ACTIONS = [
|
|
75
|
+
{type: 'move', label: 'Move to folder', needs: 'folder'},
|
|
76
|
+
{type: 'copy', label: 'Copy to folder', needs: 'folder'},
|
|
77
|
+
{type: 'markRead', label: 'Mark as read', needs: null},
|
|
78
|
+
{type: 'flag', label: 'Flag it', needs: null},
|
|
79
|
+
{type: 'junk', label: 'Treat as spam', needs: null},
|
|
80
|
+
{type: 'delete', label: 'Move to Trash', needs: null},
|
|
81
|
+
{type: 'stop', label: 'Stop processing further rules', needs: null}
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
/** The field kinds, by field name, for validating an op against a field. */
|
|
85
|
+
const KIND_OF = Object.fromEntries(RULE_FIELDS.map(f => [f.field, f.kind]));
|
|
86
|
+
|
|
87
|
+
/** The ops that take no value at all. */
|
|
88
|
+
const VALUELESS = new Set(['isTrue', 'isFalse']);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Turn a wildcard pattern into a regular expression.
|
|
92
|
+
*
|
|
93
|
+
* Everything is escaped except `*` and `?`, so the pattern cannot smuggle in
|
|
94
|
+
* a quantifier. `*` and `?` themselves compile to `.*` and `.`, neither of
|
|
95
|
+
* which can backtrack catastrophically against the other.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} pattern
|
|
98
|
+
* @returns {RegExp}
|
|
99
|
+
*/
|
|
100
|
+
function wildcard(pattern) {
|
|
101
|
+
const escaped = String(pattern ?? '')
|
|
102
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
103
|
+
.replace(/\*/g, '\u0000')
|
|
104
|
+
.replace(/\?/g, '\u0001')
|
|
105
|
+
.replace(/\u0000/g, '.*')
|
|
106
|
+
.replace(/\u0001/g, '.');
|
|
107
|
+
return new RegExp(`^${escaped}$`, 'i');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A message field as the text a condition is tested against.
|
|
112
|
+
*
|
|
113
|
+
* Address lists become "Name <address>" joined by spaces, so one condition
|
|
114
|
+
* can match either half without the author having to know which they meant.
|
|
115
|
+
*
|
|
116
|
+
* @param {object} message - a mirror record
|
|
117
|
+
* @param {string} field
|
|
118
|
+
* @returns {string|boolean|number}
|
|
119
|
+
*/
|
|
120
|
+
export function fieldValue(message, field) {
|
|
121
|
+
const addresses = list => (list ?? [])
|
|
122
|
+
.map(a => [a?.name, a?.address].filter(Boolean).join(' '))
|
|
123
|
+
.join(' ');
|
|
124
|
+
|
|
125
|
+
switch (field) {
|
|
126
|
+
case 'from': return addresses(message?.from);
|
|
127
|
+
case 'to': return addresses(message?.to);
|
|
128
|
+
case 'subject': return String(message?.subject ?? '');
|
|
129
|
+
case 'list': return String(message?.listId ?? '');
|
|
130
|
+
case 'hasAttachment': return Boolean(message?.hasAttachment);
|
|
131
|
+
case 'priority': return String(message?.priority ?? 'normal');
|
|
132
|
+
// Stored in bytes, asked about in kilobytes. Nobody writes a rule
|
|
133
|
+
// about 5242880.
|
|
134
|
+
case 'size': return Math.round(Number(message?.size ?? 0) / 1024);
|
|
135
|
+
default: return '';
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Does one condition hold?
|
|
141
|
+
*
|
|
142
|
+
* @param {{field: string, op: string, value: string}} condition
|
|
143
|
+
* @param {object} message
|
|
144
|
+
* @returns {boolean}
|
|
145
|
+
*/
|
|
146
|
+
export function conditionHolds(condition, message) {
|
|
147
|
+
const actual = fieldValue(message, condition?.field);
|
|
148
|
+
const op = condition?.op;
|
|
149
|
+
const wanted = condition?.value;
|
|
150
|
+
|
|
151
|
+
if (op === 'isTrue') return actual === true;
|
|
152
|
+
if (op === 'isFalse') return actual === false;
|
|
153
|
+
|
|
154
|
+
if (op === 'greaterThan') return Number(actual) > Number(wanted);
|
|
155
|
+
if (op === 'lessThan') return Number(actual) < Number(wanted);
|
|
156
|
+
|
|
157
|
+
const haystack = String(actual).toLowerCase();
|
|
158
|
+
const needle = String(wanted ?? '').trim().toLowerCase();
|
|
159
|
+
// An empty needle would make `contains` true for everything, which is a
|
|
160
|
+
// half-written rule filing the whole inbox. Treated as "does not hold"
|
|
161
|
+
// so an unfinished condition is inert rather than catastrophic.
|
|
162
|
+
if (!needle) return false;
|
|
163
|
+
|
|
164
|
+
switch (op) {
|
|
165
|
+
case 'contains': return haystack.includes(needle);
|
|
166
|
+
case 'notContains': return !haystack.includes(needle);
|
|
167
|
+
case 'equals': return haystack === needle;
|
|
168
|
+
case 'startsWith': return haystack.startsWith(needle);
|
|
169
|
+
case 'endsWith': return haystack.endsWith(needle);
|
|
170
|
+
case 'matches': return wildcard(needle).test(haystack);
|
|
171
|
+
default: return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Put a stored rule into shape.
|
|
177
|
+
*
|
|
178
|
+
* @param {object} input
|
|
179
|
+
* @param {number} index
|
|
180
|
+
* @returns {object}
|
|
181
|
+
*/
|
|
182
|
+
export function normaliseRule(input, index = 0) {
|
|
183
|
+
const source = input && typeof input === 'object' ? input : {};
|
|
184
|
+
|
|
185
|
+
const conditions = (Array.isArray(source.conditions) ? source.conditions : [])
|
|
186
|
+
.slice(0, MAX_CLAUSES)
|
|
187
|
+
.map(condition => {
|
|
188
|
+
const field = KIND_OF[condition?.field] ? condition.field : 'from';
|
|
189
|
+
const kind = KIND_OF[field];
|
|
190
|
+
const allowed = RULE_OPS.filter(o => o.kinds.includes(kind)).map(o => o.op);
|
|
191
|
+
const op = allowed.includes(condition?.op) ? condition.op : allowed[0];
|
|
192
|
+
return {
|
|
193
|
+
field,
|
|
194
|
+
op,
|
|
195
|
+
value: VALUELESS.has(op) ? '' : String(condition?.value ?? '').slice(0, 500)
|
|
196
|
+
};
|
|
197
|
+
})
|
|
198
|
+
.filter(Boolean);
|
|
199
|
+
|
|
200
|
+
const known = new Set(RULE_ACTIONS.map(a => a.type));
|
|
201
|
+
const needsFolder = new Set(RULE_ACTIONS.filter(a => a.needs === 'folder').map(a => a.type));
|
|
202
|
+
const actions = (Array.isArray(source.actions) ? source.actions : [])
|
|
203
|
+
.slice(0, MAX_CLAUSES)
|
|
204
|
+
.filter(action => known.has(action?.type))
|
|
205
|
+
.map(action => ({
|
|
206
|
+
type: action.type,
|
|
207
|
+
folder: needsFolder.has(action.type) ? String(action.folder ?? '').slice(0, 500) : ''
|
|
208
|
+
}))
|
|
209
|
+
// An action that needs a destination and has none does nothing but
|
|
210
|
+
// look configured. Dropped rather than kept, so what is stored is
|
|
211
|
+
// what will happen.
|
|
212
|
+
.filter(action => !needsFolder.has(action.type) || action.folder);
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
id: typeof source.id === 'string' && source.id.trim()
|
|
216
|
+
? source.id.trim()
|
|
217
|
+
: `rule-${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36).slice(-4)}`,
|
|
218
|
+
name: String(source.name ?? '').replace(/[\r\n]+/g, ' ').trim() || `Rule ${index + 1}`,
|
|
219
|
+
enabled: source.enabled !== false,
|
|
220
|
+
match: source.match === 'any' ? 'any' : 'all',
|
|
221
|
+
conditions,
|
|
222
|
+
actions
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Every rule a mailbox holds, in the order they run.
|
|
228
|
+
*
|
|
229
|
+
* @param {object|null|undefined} account
|
|
230
|
+
* @returns {object[]}
|
|
231
|
+
*/
|
|
232
|
+
export function normaliseRules(account) {
|
|
233
|
+
const source = account && typeof account === 'object' ? account : {};
|
|
234
|
+
const raw = Array.isArray(source.rules) ? source.rules : [];
|
|
235
|
+
const list = raw.slice(0, MAX_RULES).map(normaliseRule);
|
|
236
|
+
|
|
237
|
+
const seen = new Set();
|
|
238
|
+
for (const entry of list) {
|
|
239
|
+
while (seen.has(entry.id)) {
|
|
240
|
+
entry.id = `rule-${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36).slice(-4)}`;
|
|
241
|
+
}
|
|
242
|
+
seen.add(entry.id);
|
|
243
|
+
}
|
|
244
|
+
return list;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Does this rule apply to this message?
|
|
249
|
+
*
|
|
250
|
+
* A rule with no conditions never matches. "Everything" is a legitimate thing
|
|
251
|
+
* to want and an illegitimate thing to arrive at by leaving a form blank.
|
|
252
|
+
*
|
|
253
|
+
* @param {object} rule
|
|
254
|
+
* @param {object} message
|
|
255
|
+
* @returns {boolean}
|
|
256
|
+
*/
|
|
257
|
+
export function ruleMatches(rule, message) {
|
|
258
|
+
if (!rule?.enabled) return false;
|
|
259
|
+
const conditions = rule.conditions ?? [];
|
|
260
|
+
if (!conditions.length) return false;
|
|
261
|
+
|
|
262
|
+
return rule.match === 'any'
|
|
263
|
+
? conditions.some(condition => conditionHolds(condition, message))
|
|
264
|
+
: conditions.every(condition => conditionHolds(condition, message));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* What the rules, run in order, decide about one message.
|
|
269
|
+
*
|
|
270
|
+
* Returns the actions to carry out rather than carrying them out: this module
|
|
271
|
+
* knows nothing about IMAP, which is what makes the decision testable without
|
|
272
|
+
* a mail server. The caller applies them.
|
|
273
|
+
*
|
|
274
|
+
* Conflicts are resolved by order, not by merging. A message moved twice ends
|
|
275
|
+
* up wherever the LAST move said, because that is what "rules run in order"
|
|
276
|
+
* means everywhere else that has rules, and a rule list that quietly
|
|
277
|
+
* reordered itself would be impossible to reason about.
|
|
278
|
+
*
|
|
279
|
+
* @param {object[]} rules
|
|
280
|
+
* @param {object} message
|
|
281
|
+
* @returns {{actions: object[], matched: string[]}}
|
|
282
|
+
*/
|
|
283
|
+
export function planActions(rules, message) {
|
|
284
|
+
const actions = [];
|
|
285
|
+
const matched = [];
|
|
286
|
+
|
|
287
|
+
for (const rule of rules ?? []) {
|
|
288
|
+
if (!ruleMatches(rule, message)) continue;
|
|
289
|
+
matched.push(rule.id);
|
|
290
|
+
|
|
291
|
+
let stop = false;
|
|
292
|
+
for (const action of rule.actions ?? []) {
|
|
293
|
+
if (action.type === 'stop') { stop = true; continue; }
|
|
294
|
+
actions.push({...action, rule: rule.id});
|
|
295
|
+
}
|
|
296
|
+
if (stop) break;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// One destination at most. Two moves is not two moves - IMAP has already
|
|
300
|
+
// taken the message away after the first, and the second would fail on a
|
|
301
|
+
// UID that no longer exists. The last one wins, as above.
|
|
302
|
+
const moves = actions.filter(a => a.type === 'move' || a.type === 'delete' || a.type === 'junk');
|
|
303
|
+
const finalMove = moves.length ? moves[moves.length - 1] : null;
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
actions: [
|
|
307
|
+
...actions.filter(a => !['move', 'delete', 'junk'].includes(a.type)),
|
|
308
|
+
...(finalMove ? [finalMove] : [])
|
|
309
|
+
],
|
|
310
|
+
matched
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Describe a rule in a sentence, for a list that has to be scannable.
|
|
316
|
+
*
|
|
317
|
+
* @param {object} rule
|
|
318
|
+
* @returns {string}
|
|
319
|
+
*/
|
|
320
|
+
export function describeRule(rule) {
|
|
321
|
+
const entry = normaliseRule(rule);
|
|
322
|
+
if (!entry.conditions.length) return 'Matches nothing yet.';
|
|
323
|
+
|
|
324
|
+
const label = field => RULE_FIELDS.find(f => f.field === field)?.label ?? field;
|
|
325
|
+
const opLabel = op => RULE_OPS.find(o => o.op === op)?.label ?? op;
|
|
326
|
+
|
|
327
|
+
const when = entry.conditions
|
|
328
|
+
.map(c => `${label(c.field)} ${opLabel(c.op)}${VALUELESS.has(c.op) ? '' : ` "${c.value}"`}`)
|
|
329
|
+
.join(entry.match === 'any' ? ' or ' : ' and ');
|
|
330
|
+
|
|
331
|
+
// Lower-cased one label at a time, never the whole sentence: a folder
|
|
332
|
+
// name is part of that sentence and IMAP folder names are case-sensitive,
|
|
333
|
+
// so "move to lists/github" names a folder that does not exist.
|
|
334
|
+
const then = entry.actions.length
|
|
335
|
+
? entry.actions.map(a => {
|
|
336
|
+
const action = RULE_ACTIONS.find(x => x.type === a.type);
|
|
337
|
+
const verb = action.label.replace(/ to folder$/, '').toLowerCase();
|
|
338
|
+
return a.folder ? `${verb} to ${a.folder}` : action.label.toLowerCase();
|
|
339
|
+
}).join(', ')
|
|
340
|
+
: 'do nothing';
|
|
341
|
+
|
|
342
|
+
return `When ${when}, ${then}.`;
|
|
343
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When something should happen.
|
|
3
|
+
*
|
|
4
|
+
* Three features want the same arithmetic - hold this message for ten seconds
|
|
5
|
+
* so it can be taken back, send this one at eight tomorrow, put this one out
|
|
6
|
+
* of sight until Monday - so the arithmetic lives here once, pure, rather
|
|
7
|
+
* than three times in three routes.
|
|
8
|
+
*
|
|
9
|
+
* Shared with the browser (see the placement note in `identity.js`): the
|
|
10
|
+
* compose window offers the quick choices and the server validates what comes
|
|
11
|
+
* back, and those two must agree about what "tomorrow morning" means or a
|
|
12
|
+
* message scheduled from the picker is refused by the route that offered it.
|
|
13
|
+
*
|
|
14
|
+
* ## Everything is absolute
|
|
15
|
+
*
|
|
16
|
+
* A scheduled time is stored as an absolute instant, never as "in two hours".
|
|
17
|
+
* The process restarts, the clock changes twice a year, and a relative offset
|
|
18
|
+
* survives neither.
|
|
19
|
+
*
|
|
20
|
+
* @module _lib/admin/mail/scheduling
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** How long a sent message may be held back so it can be recalled. */
|
|
24
|
+
export const UNDO_SECONDS = [0, 5, 10, 20, 30];
|
|
25
|
+
|
|
26
|
+
/** The default hold. Long enough to notice the mistake, short enough not to wait. */
|
|
27
|
+
export const DEFAULT_UNDO_SECONDS = 10;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How far ahead anything may be scheduled.
|
|
31
|
+
*
|
|
32
|
+
* A ceiling rather than a policy: a queued message holds its attachments in
|
|
33
|
+
* the store until it goes, and "send this in four years" is a way to find
|
|
34
|
+
* out, in four years, that the mail server's password changed.
|
|
35
|
+
*/
|
|
36
|
+
export const MAX_SCHEDULE_DAYS = 90;
|
|
37
|
+
|
|
38
|
+
/** Hours the quick choices use. */
|
|
39
|
+
const MORNING_HOUR = 8;
|
|
40
|
+
const EVENING_HOUR = 18;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How long a message is held after Send, for this mailbox.
|
|
44
|
+
*
|
|
45
|
+
* @param {*} value
|
|
46
|
+
* @returns {number}
|
|
47
|
+
*/
|
|
48
|
+
export function normaliseUndoSeconds(value) {
|
|
49
|
+
// Absence is not a choice. `Number(null)` is 0 and 0 is a legal setting
|
|
50
|
+
// meaning "send immediately", so coercing first would read a mailbox
|
|
51
|
+
// that has never been asked as one that had opted out of the hold.
|
|
52
|
+
if (value === null || value === undefined || value === '') return DEFAULT_UNDO_SECONDS;
|
|
53
|
+
const seconds = Number(value);
|
|
54
|
+
return UNDO_SECONDS.includes(seconds) ? seconds : DEFAULT_UNDO_SECONDS;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A copy of a date moved to a given hour, with everything below it zeroed.
|
|
59
|
+
*
|
|
60
|
+
* @param {Date} from
|
|
61
|
+
* @param {number} hour
|
|
62
|
+
* @param {number} [addDays]
|
|
63
|
+
* @returns {Date}
|
|
64
|
+
*/
|
|
65
|
+
function at(from, hour, addDays = 0) {
|
|
66
|
+
const when = new Date(from.getTime());
|
|
67
|
+
when.setDate(when.getDate() + addDays);
|
|
68
|
+
when.setHours(hour, 0, 0, 0);
|
|
69
|
+
return when;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The next weekday at a given hour.
|
|
74
|
+
*
|
|
75
|
+
* @param {Date} from
|
|
76
|
+
* @param {number} weekday - 0 Sunday .. 6 Saturday
|
|
77
|
+
* @param {number} hour
|
|
78
|
+
* @returns {Date}
|
|
79
|
+
*/
|
|
80
|
+
function nextWeekday(from, weekday, hour) {
|
|
81
|
+
// Always at least one day ahead: "next Monday" asked on a Monday means
|
|
82
|
+
// the Monday coming, not the one already underway.
|
|
83
|
+
const ahead = ((weekday - from.getDay() + 7) % 7) || 7;
|
|
84
|
+
return at(from, hour, ahead);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The quick choices offered for "send later" and "snooze".
|
|
89
|
+
*
|
|
90
|
+
* Only ones still in the future: offering "This evening" at nine at night is
|
|
91
|
+
* offering a time that has been and gone, and a picker that lets you choose
|
|
92
|
+
* the past is a picker that has to explain itself afterwards.
|
|
93
|
+
*
|
|
94
|
+
* @param {Date} [now]
|
|
95
|
+
* @returns {{id: string, label: string, at: string}[]}
|
|
96
|
+
*/
|
|
97
|
+
export function quickTimes(now = new Date()) {
|
|
98
|
+
const options = [
|
|
99
|
+
{id: 'hour', label: 'In an hour', when: new Date(now.getTime() + 60 * 60 * 1000)},
|
|
100
|
+
{id: 'evening', label: 'This evening', when: at(now, EVENING_HOUR)},
|
|
101
|
+
{id: 'tomorrow', label: 'Tomorrow morning', when: at(now, MORNING_HOUR, 1)},
|
|
102
|
+
{id: 'weekend', label: 'This weekend', when: nextWeekday(now, 6, MORNING_HOUR)},
|
|
103
|
+
{id: 'nextweek', label: 'Next week', when: nextWeekday(now, 1, MORNING_HOUR)}
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
return options
|
|
108
|
+
// A minute's grace, so a choice does not vanish between being drawn
|
|
109
|
+
// and being clicked.
|
|
110
|
+
.filter(option => option.when.getTime() > now.getTime() + 60_000)
|
|
111
|
+
// Two of these land on the same instant more often than it looks:
|
|
112
|
+
// asked on a Sunday, "tomorrow morning" and "next week" are both
|
|
113
|
+
// Monday at eight. Offering the same time twice under two names
|
|
114
|
+
// makes the picker look broken, so the earlier - and more specific -
|
|
115
|
+
// wording keeps it.
|
|
116
|
+
.filter(option => {
|
|
117
|
+
const key = option.when.getTime();
|
|
118
|
+
if (seen.has(key)) return false;
|
|
119
|
+
seen.add(key);
|
|
120
|
+
return true;
|
|
121
|
+
})
|
|
122
|
+
.map(({id, label, when}) => ({id, label, at: when.toISOString()}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Validate a requested time.
|
|
127
|
+
*
|
|
128
|
+
* Refused rather than clamped. Someone who asked for a time in the past has
|
|
129
|
+
* either mistyped or is in a different timezone from the one they think, and
|
|
130
|
+
* quietly sending immediately answers neither question.
|
|
131
|
+
*
|
|
132
|
+
* @param {*} value
|
|
133
|
+
* @param {{now?: Date, maxDays?: number}} [options]
|
|
134
|
+
* @returns {{ok: true, at: string} | {ok: false, error: string}}
|
|
135
|
+
*/
|
|
136
|
+
export function normaliseSendAt(value, {now = new Date(), maxDays = MAX_SCHEDULE_DAYS} = {}) {
|
|
137
|
+
const when = value instanceof Date ? value : new Date(String(value ?? ''));
|
|
138
|
+
if (Number.isNaN(when.getTime())) {
|
|
139
|
+
return {ok: false, error: 'That is not a time I can read.'};
|
|
140
|
+
}
|
|
141
|
+
// Thirty seconds of slack, for a clock that disagrees with the server's
|
|
142
|
+
// by a little and a form that took a moment to submit.
|
|
143
|
+
if (when.getTime() < now.getTime() - 30_000) {
|
|
144
|
+
return {ok: false, error: 'That time has already passed.'};
|
|
145
|
+
}
|
|
146
|
+
const limit = now.getTime() + maxDays * 24 * 60 * 60 * 1000;
|
|
147
|
+
if (when.getTime() > limit) {
|
|
148
|
+
return {ok: false, error: `Nothing can be scheduled more than ${maxDays} days ahead.`};
|
|
149
|
+
}
|
|
150
|
+
return {ok: true, at: when.toISOString()};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Say when something will happen, the way a person would.
|
|
155
|
+
*
|
|
156
|
+
* @param {string|Date} value
|
|
157
|
+
* @param {Date} [now]
|
|
158
|
+
* @returns {string}
|
|
159
|
+
*/
|
|
160
|
+
export function describeWhen(value, now = new Date()) {
|
|
161
|
+
const when = value instanceof Date ? value : new Date(String(value ?? ''));
|
|
162
|
+
if (Number.isNaN(when.getTime())) return 'at an unknown time';
|
|
163
|
+
|
|
164
|
+
const seconds = Math.round((when.getTime() - now.getTime()) / 1000);
|
|
165
|
+
if (seconds <= 0) return 'now';
|
|
166
|
+
if (seconds < 90) return `in ${seconds} seconds`;
|
|
167
|
+
|
|
168
|
+
const minutes = Math.round(seconds / 60);
|
|
169
|
+
if (minutes < 60) return `in ${minutes} minutes`;
|
|
170
|
+
|
|
171
|
+
const clock = when.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
|
|
172
|
+
const sameDay = when.toDateString() === now.toDateString();
|
|
173
|
+
if (sameDay) return `at ${clock}`;
|
|
174
|
+
|
|
175
|
+
const tomorrow = new Date(now.getTime());
|
|
176
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
177
|
+
if (when.toDateString() === tomorrow.toDateString()) return `tomorrow at ${clock}`;
|
|
178
|
+
|
|
179
|
+
// Inside the week, the day name is what orients people. Beyond it, the
|
|
180
|
+
// date is - "Thursday" three weeks out tells you nothing.
|
|
181
|
+
const days = Math.round((when.getTime() - now.getTime()) / 86_400_000);
|
|
182
|
+
if (days < 7) return `on ${when.toLocaleDateString([], {weekday: 'long'})} at ${clock}`;
|
|
183
|
+
return `on ${when.toLocaleDateString()} at ${clock}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Which queued items are ready to run.
|
|
188
|
+
*
|
|
189
|
+
* A separate function so the worker's decision can be tested without a
|
|
190
|
+
* database: hand it rows and a clock, and it says which ones are due.
|
|
191
|
+
*
|
|
192
|
+
* @param {Array<{at?: string, status?: string}>} items
|
|
193
|
+
* @param {Date|number} [now]
|
|
194
|
+
* @returns {object[]}
|
|
195
|
+
*/
|
|
196
|
+
export function dueNow(items, now = Date.now()) {
|
|
197
|
+
const at = now instanceof Date ? now.getTime() : Number(now);
|
|
198
|
+
return (items ?? []).filter(item => {
|
|
199
|
+
if (item?.status && item.status !== 'pending') return false;
|
|
200
|
+
const when = new Date(String(item?.at ?? '')).getTime();
|
|
201
|
+
return Number.isFinite(when) && when <= at;
|
|
202
|
+
});
|
|
203
|
+
}
|