domma-cms 0.54.2 → 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.
Files changed (221) hide show
  1. package/CLAUDE.md +151 -77
  2. package/README.md +4 -4
  3. package/admin/css/admin.css +1 -1
  4. package/admin/dist/domma/domma-tools.css +3 -3
  5. package/admin/dist/domma/domma-tools.min.js +3 -3
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +3 -3
  8. package/admin/js/lib/page-picker.js +1 -0
  9. package/admin/js/lib/plugin-accent.js +1 -0
  10. package/admin/js/lib/plugin-chrome.js +1 -0
  11. package/admin/js/lib/shortcode-context-menu.js +2 -2
  12. package/admin/js/lib/sidebar-grouping.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  14. package/admin/js/lib/sidebar-renderer.js +4 -4
  15. package/admin/js/lib/slideover-resizable.js +1 -0
  16. package/admin/js/templates/context-menu-editor.html +212 -0
  17. package/admin/js/templates/context-menus.html +16 -0
  18. package/admin/js/templates/menu-editor.html +19 -25
  19. package/admin/js/templates/plugin-code.html +1 -1
  20. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  21. package/admin/js/templates/settings.html +27 -97
  22. package/admin/js/templates/theme.html +173 -0
  23. package/admin/js/views/context-menu-editor.js +55 -0
  24. package/admin/js/views/context-menus.js +5 -0
  25. package/admin/js/views/form-editor.js +7 -7
  26. package/admin/js/views/index.js +1 -1
  27. package/admin/js/views/menu-editor.js +13 -13
  28. package/admin/js/views/plugin-marketplace.js +1 -1
  29. package/admin/js/views/plugins.js +25 -23
  30. package/admin/js/views/search.js +1 -0
  31. package/admin/js/views/settings.js +3 -3
  32. package/admin/js/views/theme.js +1 -0
  33. package/bin/cli.js +11 -2
  34. package/bin/lib/smtp-defaults.js +53 -0
  35. package/bin/update.js +13 -2
  36. package/config/menus/admin-sidebar.json +129 -23
  37. package/config/plugins.json +5 -5
  38. package/config/search.json +13 -0
  39. package/config/theme.json +18 -0
  40. package/package.json +12 -4
  41. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  42. package/plugins/_lib/admin/mail/contacts.js +301 -0
  43. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  44. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  45. package/plugins/_lib/admin/mail/identity.js +480 -0
  46. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  47. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  48. package/plugins/_lib/admin/mail/mail.css +1 -0
  49. package/plugins/_lib/admin/mail/mail.html +71 -0
  50. package/plugins/_lib/admin/mail/panes.js +253 -0
  51. package/plugins/_lib/admin/mail/reader-view.js +4453 -0
  52. package/plugins/_lib/admin/mail/resizable.js +26 -0
  53. package/plugins/_lib/admin/mail/rules.js +343 -0
  54. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  55. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  56. package/plugins/_lib/admin/mail/templates.js +238 -0
  57. package/plugins/_lib/admin/mail/threads.js +200 -0
  58. package/plugins/_lib/admin/mail/vacation.js +269 -0
  59. package/plugins/_lib/admin/ui/help.css +1 -0
  60. package/plugins/_lib/admin/ui/help.js +174 -0
  61. package/plugins/_lib/admin/ui/resizable.js +151 -0
  62. package/plugins/_lib/dataStore.js +117 -0
  63. package/plugins/_lib/mail/accounts.js +919 -0
  64. package/plugins/_lib/mail/bodyTokens.js +101 -0
  65. package/plugins/_lib/mail/compose.js +256 -0
  66. package/plugins/_lib/mail/defaults.js +57 -0
  67. package/plugins/_lib/mail/diagnostics.js +154 -0
  68. package/plugins/_lib/mail/envelope.js +161 -0
  69. package/plugins/_lib/mail/folders.js +192 -0
  70. package/plugins/_lib/mail/handoff.js +274 -0
  71. package/plugins/_lib/mail/imapPool.js +293 -0
  72. package/plugins/_lib/mail/mbox.js +74 -0
  73. package/plugins/_lib/mail/pollSchedule.js +79 -0
  74. package/plugins/_lib/mail/poller.js +135 -0
  75. package/plugins/_lib/mail/priority.js +138 -0
  76. package/plugins/_lib/mail/readRoutes.js +680 -0
  77. package/plugins/_lib/mail/render.js +291 -0
  78. package/plugins/_lib/mail/ruleRunner.js +152 -0
  79. package/plugins/_lib/mail/scheduler.js +254 -0
  80. package/plugins/_lib/mail/secretbox.js +229 -0
  81. package/plugins/_lib/mail/send.js +396 -0
  82. package/plugins/_lib/mail/store.js +1002 -0
  83. package/plugins/_lib/mail/sync.js +292 -0
  84. package/plugins/_lib/mail/syncPlan.js +126 -0
  85. package/plugins/_lib/mail/syncSelection.js +82 -0
  86. package/plugins/_lib/mail/unsubscribe.js +183 -0
  87. package/plugins/_lib/mail/vacationRunner.js +114 -0
  88. package/plugins/_lib/mail/write.js +473 -0
  89. package/plugins/_lib/schemaSync.js +83 -0
  90. package/plugins/_template/admin/css/index.css +0 -0
  91. package/plugins/_template/admin/templates/index.html +4 -4
  92. package/plugins/_template/admin/views/index.js +7 -0
  93. package/plugins/analytics/admin/css/index.css +1 -0
  94. package/plugins/analytics/admin/templates/analytics.html +22 -13
  95. package/plugins/analytics/plugin.json +3 -0
  96. package/plugins/blog/admin/css/index.css +1 -0
  97. package/plugins/blog/admin/templates/blog.html +30 -18
  98. package/plugins/blog/admin/templates/categories.html +2 -2
  99. package/plugins/blog/admin/templates/comments.html +2 -2
  100. package/plugins/blog/admin/templates/post-editor.html +34 -34
  101. package/plugins/blog/admin/templates/settings.html +6 -3
  102. package/plugins/blog/admin/views/blog.js +8 -5
  103. package/plugins/blog/admin/views/categories.js +5 -10
  104. package/plugins/blog/admin/views/comments.js +5 -5
  105. package/plugins/blog/admin/views/post-editor.js +39 -20
  106. package/plugins/blog/admin/views/settings.js +52 -50
  107. package/plugins/blog/collections/categories/schema.json +7 -6
  108. package/plugins/blog/collections/comments/schema.json +11 -10
  109. package/plugins/blog/collections/posts/schema.json +14 -13
  110. package/plugins/blog/plugin.js +36 -13
  111. package/plugins/blog/plugin.json +13 -5
  112. package/plugins/blog/plugin.public.js +312 -0
  113. package/plugins/contacts/admin/css/index.css +1 -0
  114. package/plugins/contacts/admin/templates/contacts.html +128 -0
  115. package/plugins/contacts/admin/views/contacts.js +237 -4
  116. package/plugins/contacts/collections/user-contacts/schema.json +108 -0
  117. package/plugins/contacts/plugin.js +214 -27
  118. package/plugins/contacts/plugin.json +4 -1
  119. package/plugins/invoice/admin/css/index.css +1 -0
  120. package/plugins/invoice/admin/templates/editor.html +140 -49
  121. package/plugins/invoice/admin/templates/index.html +153 -23
  122. package/plugins/invoice/admin/templates/issuers.html +2 -5
  123. package/plugins/invoice/admin/templates/receivers.html +2 -5
  124. package/plugins/invoice/admin/views/contacts-source.js +266 -0
  125. package/plugins/invoice/admin/views/editor.js +366 -199
  126. package/plugins/invoice/admin/views/export.js +199 -0
  127. package/plugins/invoice/admin/views/help-content.js +61 -0
  128. package/plugins/invoice/admin/views/index.js +582 -94
  129. package/plugins/invoice/admin/views/issuers.js +24 -17
  130. package/plugins/invoice/admin/views/media.js +172 -0
  131. package/plugins/invoice/admin/views/party-view.js +305 -67
  132. package/plugins/invoice/admin/views/payments.js +127 -0
  133. package/plugins/invoice/admin/views/print.js +130 -0
  134. package/plugins/invoice/admin/views/receivers.js +49 -16
  135. package/plugins/invoice/admin/views/send.js +212 -0
  136. package/plugins/invoice/admin/views/settings.js +594 -0
  137. package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
  138. package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
  139. package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
  140. package/plugins/invoice/collections/invoices/schema.json +19 -13
  141. package/plugins/invoice/config.js +27 -6
  142. package/plugins/invoice/pdf.js +164 -0
  143. package/plugins/invoice/plugin.js +1217 -44
  144. package/plugins/invoice/plugin.json +10 -9
  145. package/plugins/invoice/templates/_base.css +1 -0
  146. package/plugins/invoice/templates/classic-nologo.html +100 -0
  147. package/plugins/invoice/templates/classic.html +91 -0
  148. package/plugins/invoice/templates/invoice-print.html +24 -0
  149. package/plugins/invoice/templates/minimal.html +99 -0
  150. package/plugins/invoice/templates/modern-nologo.html +114 -0
  151. package/plugins/invoice/templates/modern.html +113 -0
  152. package/plugins/invoice/templates/templates.json +11 -0
  153. package/plugins/mail-reader/admin/views/mail.js +19 -0
  154. package/plugins/mail-reader/config.js +7 -0
  155. package/plugins/mail-reader/plugin.js +48 -0
  156. package/plugins/mail-reader/plugin.json +33 -0
  157. package/plugins/notes/admin/views/notes.js +1 -1
  158. package/plugins/notes/plugin.json +2 -2
  159. package/plugins/surveys/lib/audience.js +37 -0
  160. package/plugins/surveys/lib/campaigns.js +43 -0
  161. package/plugins/surveys/lib/ledger.js +110 -0
  162. package/plugins/surveys/lib/sending.js +106 -0
  163. package/plugins/surveys/lib/stats.js +62 -0
  164. package/plugins/surveys/lib/submit.js +95 -0
  165. package/plugins/surveys/lib/tokens.js +28 -0
  166. package/plugins/surveys/plugin.public.js +149 -0
  167. package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
  168. package/public/css/forms.css +1 -1
  169. package/public/css/menu-highlight.css +1 -1
  170. package/public/css/search.css +1 -0
  171. package/public/css/site.css +1 -1
  172. package/public/js/collection-context.js +2 -2
  173. package/public/js/context-menus.js +1 -0
  174. package/public/js/form-logic-engine.js +1 -1
  175. package/public/js/forms.js +2 -2
  176. package/public/js/menu-decor.mjs +1 -1
  177. package/public/js/search.js +1 -0
  178. package/public/js/site.js +1 -1
  179. package/scripts/build.js +37 -3
  180. package/scripts/copy-domma.js +48 -0
  181. package/scripts/seed.js +1996 -0
  182. package/scripts/setup.js +8 -0
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/context-menus.js +104 -0
  185. package/server/routes/api/forms.js +42 -3
  186. package/server/routes/api/notifications.js +69 -19
  187. package/server/routes/api/plugins.js +50 -6
  188. package/server/routes/api/search.js +43 -0
  189. package/server/routes/api/theme.js +69 -0
  190. package/server/routes/public.js +42 -7
  191. package/server/server.js +74 -0
  192. package/server/services/adapters/FileAdapter.js +6 -1
  193. package/server/services/content.js +26 -0
  194. package/server/services/contextMenus.js +477 -0
  195. package/server/services/email.js +29 -3
  196. package/server/services/health.js +23 -2
  197. package/server/services/markdown.js +70 -9
  198. package/server/services/menuRender.js +28 -4
  199. package/server/services/menus.js +25 -1
  200. package/server/services/permissionRegistry.js +24 -0
  201. package/server/services/pluginFiles.js +52 -11
  202. package/server/services/plugins.js +229 -6
  203. package/server/services/renderer.js +148 -22
  204. package/server/services/roles.js +1 -1
  205. package/server/services/search-migration.js +82 -0
  206. package/server/services/search.js +413 -0
  207. package/server/services/sidebar-migration.js +1 -0
  208. package/server/services/themeSettings.js +541 -0
  209. package/server/services/users.js +8 -0
  210. package/server/templates/page.html +4 -2
  211. package/plugins/contacts/data/contacts.json +0 -20
  212. package/plugins/notes/data/notes.json +0 -1
  213. package/plugins/site-search/admin/views/site-search.js +0 -116
  214. package/plugins/site-search/config.js +0 -15
  215. package/plugins/site-search/plugin.js +0 -188
  216. package/plugins/site-search/plugin.json +0 -40
  217. package/plugins/site-search/public/inject-body.html +0 -17
  218. package/plugins/site-search/public/inject-head.html +0 -1
  219. package/plugins/site-search/public/search.css +0 -1
  220. package/plugins/site-search/public/search.js +0 -1
  221. 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
+ }