domma-cms 0.55.1 → 0.69.3

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 (305) hide show
  1. package/CLAUDE.md +194 -3
  2. package/README.md +18 -14
  3. package/admin/css/admin.css +1 -1
  4. package/admin/css/contacts.css +1 -0
  5. package/admin/dist/domma/domma-tools.css +3 -3
  6. package/admin/dist/domma/domma-tools.min.js +3 -3
  7. package/admin/index.html +1 -0
  8. package/admin/js/api.js +1 -1
  9. package/admin/js/app.js +4 -4
  10. package/admin/js/lib/analytics-shape.js +1 -0
  11. package/admin/js/lib/contacts-arrange.js +1 -0
  12. package/admin/js/lib/help-popover.js +1 -0
  13. package/admin/js/lib/notes-arrange.js +1 -0
  14. package/admin/js/lib/page-picker.js +1 -0
  15. package/admin/js/lib/plugin-accent.js +1 -0
  16. package/admin/js/lib/plugin-chrome.js +1 -0
  17. package/admin/js/lib/shortcode-context-menu.js +2 -2
  18. package/admin/js/lib/sidebar-grouping.js +1 -1
  19. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  20. package/admin/js/lib/sidebar-renderer.js +4 -4
  21. package/admin/js/lib/slideover-resizable.js +1 -0
  22. package/admin/js/lib/todo-arrange.js +1 -0
  23. package/admin/js/lib/tool-kit.js +1 -0
  24. package/admin/js/templates/analytics.html +138 -0
  25. package/admin/js/templates/contacts.html +156 -0
  26. package/admin/js/templates/context-menu-editor.html +212 -0
  27. package/admin/js/templates/context-menus.html +16 -0
  28. package/admin/js/templates/notes.html +137 -0
  29. package/admin/js/templates/plugin-code.html +1 -1
  30. package/admin/js/templates/plugin-marketplace.html +17 -4
  31. package/admin/js/templates/plugins.html +13 -7
  32. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  33. package/admin/js/templates/settings.html +16 -97
  34. package/admin/js/templates/theme.html +211 -0
  35. package/admin/js/templates/todo.html +113 -0
  36. package/admin/js/views/analytics.js +62 -0
  37. package/admin/js/views/contacts.js +174 -0
  38. package/admin/js/views/context-menu-editor.js +55 -0
  39. package/admin/js/views/context-menus.js +5 -0
  40. package/admin/js/views/form-editor.js +7 -7
  41. package/admin/js/views/index.js +1 -1
  42. package/admin/js/views/notes.js +104 -0
  43. package/admin/js/views/plugin-marketplace.js +1 -1
  44. package/admin/js/views/plugins.js +28 -24
  45. package/admin/js/views/search.js +1 -0
  46. package/admin/js/views/settings.js +3 -3
  47. package/admin/js/views/theme.js +1 -0
  48. package/admin/js/views/todo.js +96 -0
  49. package/bin/cli.js +6 -10
  50. package/bin/lib/plugin-version.js +28 -0
  51. package/bin/update.js +27 -2
  52. package/config/menus/admin-sidebar.json +129 -23
  53. package/config/plugins.json +5 -5
  54. package/config/search.json +13 -0
  55. package/config/server.json +3 -1
  56. package/package.json +14 -10
  57. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  58. package/plugins/_lib/admin/mail/contacts.js +301 -0
  59. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  60. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  61. package/plugins/_lib/admin/mail/identity.js +480 -0
  62. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  63. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  64. package/plugins/_lib/admin/mail/mail.css +1 -0
  65. package/plugins/_lib/admin/mail/mail.html +71 -0
  66. package/plugins/_lib/admin/mail/panes.js +253 -0
  67. package/plugins/_lib/admin/mail/reader-view.js +4473 -0
  68. package/plugins/_lib/admin/mail/resizable.js +26 -0
  69. package/plugins/_lib/admin/mail/rules.js +343 -0
  70. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  71. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  72. package/plugins/_lib/admin/mail/templates.js +238 -0
  73. package/plugins/_lib/admin/mail/threads.js +200 -0
  74. package/plugins/_lib/admin/mail/vacation.js +269 -0
  75. package/plugins/_lib/admin/ui/help.css +1 -0
  76. package/plugins/_lib/admin/ui/help.js +174 -0
  77. package/plugins/_lib/admin/ui/resizable.js +151 -0
  78. package/plugins/_lib/dataStore.js +117 -0
  79. package/plugins/_lib/mail/accounts.js +919 -0
  80. package/plugins/_lib/mail/bodyTokens.js +101 -0
  81. package/plugins/_lib/mail/compose.js +256 -0
  82. package/plugins/_lib/mail/defaults.js +57 -0
  83. package/plugins/_lib/mail/diagnostics.js +154 -0
  84. package/plugins/_lib/mail/envelope.js +161 -0
  85. package/plugins/_lib/mail/folders.js +192 -0
  86. package/plugins/_lib/mail/handoff.js +274 -0
  87. package/plugins/_lib/mail/imapPool.js +293 -0
  88. package/plugins/_lib/mail/mbox.js +74 -0
  89. package/plugins/_lib/mail/pollSchedule.js +79 -0
  90. package/plugins/_lib/mail/poller.js +135 -0
  91. package/plugins/_lib/mail/priority.js +138 -0
  92. package/plugins/_lib/mail/readRoutes.js +680 -0
  93. package/plugins/_lib/mail/render.js +291 -0
  94. package/plugins/_lib/mail/ruleRunner.js +152 -0
  95. package/plugins/_lib/mail/scheduler.js +254 -0
  96. package/plugins/_lib/mail/secretbox.js +229 -0
  97. package/plugins/_lib/mail/send.js +396 -0
  98. package/plugins/_lib/mail/store.js +1022 -0
  99. package/plugins/_lib/mail/sync.js +292 -0
  100. package/plugins/_lib/mail/syncPlan.js +126 -0
  101. package/plugins/_lib/mail/syncSelection.js +82 -0
  102. package/plugins/_lib/mail/unsubscribe.js +183 -0
  103. package/plugins/_lib/mail/vacationRunner.js +114 -0
  104. package/plugins/_lib/mail/write.js +473 -0
  105. package/plugins/_lib/schemaSync.js +83 -0
  106. package/plugins/_template/admin/css/index.css +0 -0
  107. package/plugins/_template/admin/templates/index.html +4 -4
  108. package/plugins/_template/admin/views/index.js +7 -0
  109. package/plugins/blog/CLAUDE.md +237 -0
  110. package/plugins/blog/admin/css/index.css +178 -0
  111. package/plugins/blog/admin/templates/blog.html +35 -41
  112. package/plugins/blog/admin/templates/categories.html +19 -6
  113. package/plugins/blog/admin/templates/comments.html +26 -9
  114. package/plugins/blog/admin/templates/post-editor.html +63 -40
  115. package/plugins/blog/admin/templates/settings.html +3 -9
  116. package/plugins/blog/admin/views/blog.js +288 -159
  117. package/plugins/blog/admin/views/categories.js +212 -206
  118. package/plugins/blog/admin/views/comments.js +207 -158
  119. package/plugins/blog/admin/views/kit.js +372 -0
  120. package/plugins/blog/admin/views/post-editor.js +306 -39
  121. package/plugins/blog/admin/views/settings-cog.js +56 -0
  122. package/plugins/blog/admin/views/settings.js +16 -84
  123. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  124. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  125. package/plugins/blog/blocks/blog-card-row.css +22 -0
  126. package/plugins/blog/blocks/blog-card-row.html +11 -0
  127. package/plugins/blog/blocks/blog-card.css +35 -0
  128. package/plugins/blog/blocks/blog-card.html +16 -0
  129. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  130. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  131. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  132. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  133. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  134. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  135. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  136. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  137. package/plugins/blog/collections/categories/schema.json +7 -6
  138. package/plugins/blog/collections/comments/schema.json +11 -10
  139. package/plugins/blog/collections/posts/schema.json +16 -13
  140. package/plugins/blog/config.js +6 -0
  141. package/plugins/blog/lib/layouts.js +304 -0
  142. package/plugins/blog/lib/page.js +158 -0
  143. package/plugins/blog/lib/render.js +119 -0
  144. package/plugins/blog/lib/samples.js +234 -0
  145. package/plugins/blog/plugin.js +259 -26
  146. package/plugins/blog/plugin.json +21 -73
  147. package/plugins/blog/plugin.public.js +217 -0
  148. package/plugins/blog/public/blog.css +177 -0
  149. package/plugins/blog/public/blog.js +586 -0
  150. package/plugins/blog/public/samples/aurora.svg +7 -0
  151. package/plugins/blog/public/samples/dusk.svg +7 -0
  152. package/plugins/blog/public/samples/ember.svg +7 -0
  153. package/plugins/blog/public/samples/harbour.svg +7 -0
  154. package/plugins/blog/public/samples/meadow.svg +7 -0
  155. package/plugins/blog/public/samples/rosewood.svg +7 -0
  156. package/plugins/blog/templates/index.html +9 -8
  157. package/plugins/blog/templates/post.html +4 -17
  158. package/plugins/blog/tests/layouts.test.js +97 -0
  159. package/plugins/blog/tests/public.test.js +71 -0
  160. package/plugins/free-tier.lock.json +74 -0
  161. package/plugins/mail-reader/CLAUDE.md +78 -0
  162. package/plugins/mail-reader/admin/views/mail.js +19 -0
  163. package/plugins/mail-reader/config.js +7 -0
  164. package/plugins/mail-reader/plugin.js +48 -0
  165. package/plugins/mail-reader/plugin.json +34 -0
  166. package/public/css/forms.css +1 -1
  167. package/public/css/search.css +1 -0
  168. package/public/css/site.css +1 -1
  169. package/public/css/theme-switcher.css +1 -0
  170. package/public/js/analytics.js +1 -0
  171. package/public/js/collection-context.js +2 -2
  172. package/public/js/context-menus.js +1 -0
  173. package/public/js/form-logic-engine.js +1 -1
  174. package/public/js/forms.js +2 -2
  175. package/public/js/search.js +1 -0
  176. package/public/js/site.js +1 -1
  177. package/public/js/theme-switcher.js +1 -0
  178. package/scripts/build.js +77 -4
  179. package/scripts/copy-domma.js +48 -0
  180. package/scripts/run-tests.mjs +92 -0
  181. package/scripts/seed.js +1996 -0
  182. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/contacts.js +506 -0
  185. package/server/routes/api/context-menus.js +104 -0
  186. package/server/routes/api/dashboard.js +8 -2
  187. package/server/routes/api/forms.js +42 -3
  188. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  189. package/server/routes/api/notifications.js +69 -19
  190. package/server/routes/api/plugin-marketplace.js +78 -23
  191. package/server/routes/api/plugins.js +198 -9
  192. package/server/routes/api/search.js +43 -0
  193. package/server/routes/api/theme.js +69 -0
  194. package/server/routes/api/todo.js +178 -0
  195. package/server/routes/public.js +42 -7
  196. package/server/server.js +135 -1
  197. package/server/services/adapters/FileAdapter.js +6 -1
  198. package/server/services/collections.js +13 -3
  199. package/server/services/content.js +26 -0
  200. package/server/services/contextMenus.js +477 -0
  201. package/server/services/managerClient.js +72 -136
  202. package/server/services/markdown.js +124 -10
  203. package/server/services/permissionRegistry.js +74 -0
  204. package/server/services/pluginEntitlement.js +171 -0
  205. package/server/services/pluginEnvelope.js +242 -0
  206. package/server/services/pluginFiles.js +52 -11
  207. package/server/services/pluginInstaller.js +246 -26
  208. package/server/services/pluginScaffold.js +5 -0
  209. package/server/services/plugins.js +340 -7
  210. package/server/services/presetCollections.js +344 -0
  211. package/server/services/promoted-plugins-migration.js +254 -0
  212. package/server/services/publicCors.js +46 -0
  213. package/server/services/renderer.js +224 -22
  214. package/server/services/roles.js +1 -1
  215. package/server/services/search-migration.js +82 -0
  216. package/server/services/search.js +413 -0
  217. package/server/services/sidebar-migration.js +1 -0
  218. package/server/services/sidebarBadges.js +37 -0
  219. package/server/services/siteGitignore.js +307 -0
  220. package/server/services/themeSettings.js +580 -0
  221. package/server/services/users.js +8 -0
  222. package/server/templates/page.html +4 -2
  223. package/plugins/analytics/admin/templates/analytics.html +0 -61
  224. package/plugins/analytics/admin/views/analytics.js +0 -176
  225. package/plugins/analytics/config.js +0 -14
  226. package/plugins/analytics/plugin.json +0 -57
  227. package/plugins/analytics/public/inject-body.html +0 -60
  228. package/plugins/analytics/public/inject-head.html +0 -1
  229. package/plugins/blog/templates/author.html +0 -9
  230. package/plugins/blog/templates/category.html +0 -9
  231. package/plugins/blog/templates/tag.html +0 -9
  232. package/plugins/contacts/admin/templates/contacts.html +0 -126
  233. package/plugins/contacts/admin/views/contacts.js +0 -710
  234. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  235. package/plugins/contacts/collections/user-contacts/schema.json +0 -71
  236. package/plugins/contacts/config.js +0 -6
  237. package/plugins/contacts/data/contacts.json +0 -20
  238. package/plugins/contacts/plugin.js +0 -291
  239. package/plugins/contacts/plugin.json +0 -33
  240. package/plugins/demo-viewer/config.js +0 -4
  241. package/plugins/demo-viewer/plugin.js +0 -36
  242. package/plugins/demo-viewer/plugin.json +0 -9
  243. package/plugins/invoice/admin/templates/editor.html +0 -129
  244. package/plugins/invoice/admin/templates/index.html +0 -43
  245. package/plugins/invoice/admin/templates/issuers.html +0 -5
  246. package/plugins/invoice/admin/templates/receivers.html +0 -5
  247. package/plugins/invoice/admin/views/editor.js +0 -267
  248. package/plugins/invoice/admin/views/index.js +0 -155
  249. package/plugins/invoice/admin/views/issuers.js +0 -23
  250. package/plugins/invoice/admin/views/party-view.js +0 -148
  251. package/plugins/invoice/admin/views/receivers.js +0 -22
  252. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
  253. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
  254. package/plugins/invoice/collections/invoices/schema.json +0 -27
  255. package/plugins/invoice/config.js +0 -16
  256. package/plugins/invoice/plugin.js +0 -283
  257. package/plugins/invoice/plugin.json +0 -85
  258. package/plugins/invoice/templates/invoice-print.html +0 -213
  259. package/plugins/notes/admin/templates/notes.html +0 -83
  260. package/plugins/notes/admin/views/notes.js +0 -281
  261. package/plugins/notes/collections/user-notes/schema.json +0 -54
  262. package/plugins/notes/config.js +0 -6
  263. package/plugins/notes/data/notes.json +0 -1
  264. package/plugins/notes/plugin.json +0 -33
  265. package/plugins/site-search/admin/views/site-search.js +0 -116
  266. package/plugins/site-search/config.js +0 -15
  267. package/plugins/site-search/plugin.js +0 -188
  268. package/plugins/site-search/plugin.json +0 -40
  269. package/plugins/site-search/public/inject-body.html +0 -17
  270. package/plugins/site-search/public/inject-head.html +0 -1
  271. package/plugins/site-search/public/search.css +0 -1
  272. package/plugins/site-search/public/search.js +0 -1
  273. package/plugins/surveys/admin/templates/audience.html +0 -47
  274. package/plugins/surveys/admin/templates/results.html +0 -58
  275. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  276. package/plugins/surveys/admin/templates/surveys.html +0 -25
  277. package/plugins/surveys/admin/views/audience.js +0 -301
  278. package/plugins/surveys/admin/views/results.js +0 -172
  279. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  280. package/plugins/surveys/admin/views/surveys.js +0 -161
  281. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  282. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  283. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  284. package/plugins/surveys/collections/surveys/schema.json +0 -23
  285. package/plugins/surveys/config.js +0 -8
  286. package/plugins/surveys/plugin.js +0 -174
  287. package/plugins/surveys/plugin.json +0 -65
  288. package/plugins/surveys/public/dist-shared.mjs +0 -1
  289. package/plugins/surveys/public/survey.css +0 -1
  290. package/plugins/surveys/public/survey.mjs +0 -1
  291. package/plugins/surveys/templates/survey-page.html +0 -63
  292. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  293. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  294. package/plugins/theme-switcher/config.js +0 -10
  295. package/plugins/theme-switcher/plugin.js +0 -26
  296. package/plugins/theme-switcher/plugin.json +0 -74
  297. package/plugins/theme-switcher/public/inject-body.html +0 -153
  298. package/plugins/theme-switcher/public/inject-head.html +0 -260
  299. package/plugins/todo/admin/templates/todo.html +0 -158
  300. package/plugins/todo/admin/views/todo.js +0 -343
  301. package/plugins/todo/collections/todos/schema.json +0 -60
  302. package/plugins/todo/config.js +0 -7
  303. package/plugins/todo/data/todos.json +0 -1
  304. package/plugins/todo/plugin.js +0 -102
  305. package/plugins/todo/plugin.json +0 -33
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Message rendering for the Mail Reader plugin.
3
+ *
4
+ * Email HTML is the most hostile content this CMS will ever display: it comes
5
+ * from strangers, it is full of tracking, and a good deal of it is actively
6
+ * trying to do something. Three layers stand between it and the admin:
7
+ *
8
+ * 1. sanitize-html strips scripts, styles, frames, forms and event handlers.
9
+ * 2. Remote images are neutered unless the reader explicitly asks for them,
10
+ * because loading one tells the sender the mail was opened, and by whom.
11
+ * 3. The result is handed to a sandboxed iframe (see admin/views/mail.js)
12
+ * with neither `allow-scripts` nor `allow-same-origin`, so even a hole in
13
+ * the first two layers has no origin to abuse.
14
+ *
15
+ * The CSP meta below is a fourth layer that costs nothing. None of these are
16
+ * individually sufficient, which is the point.
17
+ *
18
+ * @module mail-reader/services/render
19
+ */
20
+ import sanitizeHtml from 'sanitize-html';
21
+
22
+ /** A 1x1 transparent GIF - stands in for a blocked remote image. */
23
+ const BLOCKED_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
24
+
25
+ /**
26
+ * Tags worth keeping in a mail body. Everything absent from this list is
27
+ * unwrapped or dropped by sanitize-html.
28
+ */
29
+ const ALLOWED_TAGS = [
30
+ 'a', 'b', 'blockquote', 'br', 'caption', 'code', 'col', 'colgroup', 'dd', 'div', 'dl', 'dt',
31
+ 'em', 'figcaption', 'figure', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li',
32
+ 'ol', 'p', 'pre', 'q', 's', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'table',
33
+ 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'u', 'ul'
34
+ ];
35
+
36
+ /**
37
+ * Build a cid → data URI map from a parsed message's inline attachments.
38
+ *
39
+ * Inline images are embedded rather than served from a route: they are already
40
+ * in memory, they are usually small, and a route would mean a second
41
+ * authenticated round trip per image.
42
+ *
43
+ * @param {object[]} attachments
44
+ * @param {number} maxBytes - per-image ceiling; larger ones are left blocked
45
+ * @returns {Map<string, string>}
46
+ */
47
+ function inlineImageMap(attachments, maxBytes) {
48
+ const map = new Map();
49
+ for (const attachment of attachments ?? []) {
50
+ if (!attachment.cid || !attachment.content) continue;
51
+ if (!String(attachment.contentType ?? '').startsWith('image/')) continue;
52
+ if (attachment.content.length > maxBytes) continue;
53
+ map.set(attachment.cid, `data:${attachment.contentType};base64,${attachment.content.toString('base64')}`);
54
+ }
55
+ return map;
56
+ }
57
+
58
+ /**
59
+ * Sanitise a mail body and rewrite its images and links.
60
+ *
61
+ * @param {string} html
62
+ * @param {{allowRemoteImages: boolean, inlineImages: Map<string, string>}} options
63
+ * @returns {{html: string, blockedImages: number}}
64
+ */
65
+ function sanitiseBody(html, options) {
66
+ let blockedImages = 0;
67
+
68
+ const clean = sanitizeHtml(html, {
69
+ allowedTags: ALLOWED_TAGS,
70
+ allowedAttributes: {
71
+ // target/rel are added by transformTags below, and attribute
72
+ // filtering runs AFTER that - leave them off this list and the
73
+ // rel we just set is stripped straight back off again.
74
+ a: ['href', 'title', 'target', 'rel'],
75
+ img: ['src', 'alt', 'title', 'width', 'height', 'data-blocked-src'],
76
+ td: ['colspan', 'rowspan', 'align', 'valign'],
77
+ th: ['colspan', 'rowspan', 'align', 'valign'],
78
+ table: ['width', 'align'],
79
+ '*': ['style']
80
+ },
81
+ // Inline styles are what makes mail readable, so they stay - but only
82
+ // properties that cannot position, load or overlay anything.
83
+ allowedStyles: {
84
+ '*': {
85
+ 'color': [/^.*$/],
86
+ 'background-color': [/^.*$/],
87
+ 'font-size': [/^.*$/],
88
+ 'font-weight': [/^.*$/],
89
+ 'font-style': [/^.*$/],
90
+ 'font-family': [/^.*$/],
91
+ 'text-align': [/^.*$/],
92
+ 'text-decoration': [/^.*$/],
93
+ 'padding': [/^.*$/],
94
+ 'padding-top': [/^.*$/],
95
+ 'padding-bottom': [/^.*$/],
96
+ 'padding-left': [/^.*$/],
97
+ 'padding-right': [/^.*$/],
98
+ 'margin': [/^.*$/],
99
+ 'border': [/^.*$/],
100
+ 'border-collapse': [/^.*$/],
101
+ 'width': [/^.*$/],
102
+ 'max-width': [/^.*$/]
103
+ }
104
+ },
105
+ allowedSchemes: ['http', 'https', 'mailto', 'tel'],
106
+ allowedSchemesByTag: {img: ['http', 'https', 'data', 'cid']},
107
+ // Anything not on the tag list loses its markup but keeps its text,
108
+ // except these, where the text is machinery rather than content.
109
+ nonTextTags: ['style', 'script', 'textarea', 'option', 'noscript', 'title', 'head'],
110
+ transformTags: {
111
+ a: (tagName, attribs) => ({
112
+ tagName: 'a',
113
+ attribs: {
114
+ ...attribs,
115
+ target: '_blank',
116
+ rel: 'noopener noreferrer nofollow'
117
+ }
118
+ }),
119
+ img: (tagName, attribs) => {
120
+ const src = attribs.src ?? '';
121
+
122
+ if (src.startsWith('cid:')) {
123
+ const resolved = options.inlineImages.get(src.slice(4));
124
+ if (resolved) return {tagName: 'img', attribs: {...attribs, src: resolved}};
125
+ blockedImages += 1;
126
+ return {tagName: 'img', attribs: {...attribs, src: BLOCKED_PIXEL}};
127
+ }
128
+
129
+ if (src.startsWith('data:image/')) {
130
+ return {tagName: 'img', attribs};
131
+ }
132
+
133
+ if (!options.allowRemoteImages) {
134
+ blockedImages += 1;
135
+ return {
136
+ tagName: 'img',
137
+ attribs: {...attribs, src: BLOCKED_PIXEL, 'data-blocked-src': src}
138
+ };
139
+ }
140
+
141
+ return {tagName: 'img', attribs};
142
+ }
143
+ }
144
+ });
145
+
146
+ return {html: clean, blockedImages};
147
+ }
148
+
149
+ /**
150
+ * Escape text for safe interpolation into HTML.
151
+ *
152
+ * @param {string} value
153
+ * @returns {string}
154
+ */
155
+ function escapeHtml(value) {
156
+ return String(value)
157
+ .replace(/&/g, '&amp;')
158
+ .replace(/</g, '&lt;')
159
+ .replace(/>/g, '&gt;')
160
+ .replace(/"/g, '&quot;')
161
+ .replace(/'/g, '&#39;');
162
+ }
163
+
164
+ /**
165
+ * Wrap a sanitised body in a self-contained document for iframe srcdoc.
166
+ *
167
+ * The stylesheet is ours, not the message's, and deliberately minimal: it sets
168
+ * a readable default and stops wide tables blowing out the pane. Mail that
169
+ * relies on its own layout still renders, because its inline styles survived.
170
+ *
171
+ * @param {string} bodyHtml
172
+ * @returns {string}
173
+ */
174
+ function wrapDocument(bodyHtml) {
175
+ return `<!DOCTYPE html>
176
+ <html><head>
177
+ <meta charset="utf-8">
178
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data: http: https:; style-src 'unsafe-inline'; font-src data:;">
179
+ <base target="_blank">
180
+ <style>
181
+ html, body { margin: 0; padding: 16px; }
182
+ body {
183
+ font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
184
+ color: #1a1a1a; background: #ffffff; word-wrap: break-word;
185
+ }
186
+ img { max-width: 100%; height: auto; }
187
+ table { max-width: 100%; }
188
+ pre { white-space: pre-wrap; }
189
+ blockquote {
190
+ margin: 0 0 0 8px; padding-left: 12px;
191
+ border-left: 3px solid #d0d0d0; color: #555;
192
+ }
193
+ </style>
194
+ </head><body>${bodyHtml}</body></html>`;
195
+ }
196
+
197
+ /**
198
+ * Sanitise a fragment of someone else's HTML for inclusion in our own.
199
+ *
200
+ * Used when quoting a message into a reply: the original's formatting is
201
+ * worth keeping, its scripts and styles are not. The same rules as the reader
202
+ * uses, minus the document wrapper, and with remote images dropped rather
203
+ * than blocked - a quote should not carry a tracking pixel onward to
204
+ * everybody on the reply.
205
+ *
206
+ * @param {string} html
207
+ * @returns {string}
208
+ */
209
+ export function sanitiseFragment(html) {
210
+ const {html: clean} = sanitiseBody(String(html ?? ''), {
211
+ allowRemoteImages: false,
212
+ inlineImages: new Map()
213
+ });
214
+ // The blocked-image placeholders are meaningful in a reader and just
215
+ // clutter in a quote.
216
+ return clean.replace(/<img[^>]*data-blocked-src[^>]*>/gi, '');
217
+ }
218
+
219
+ /**
220
+ * Sanitise a signature the mailbox owner wrote.
221
+ *
222
+ * The same allowlist the reader and the quote use, with remote images ALLOWED
223
+ * - which is the one thing that separates it from `sanitiseFragment`. A
224
+ * blocked image in an incoming message is a tracking pixel defused; a blocked
225
+ * image in your own signature is your company logo missing from every message
226
+ * you send.
227
+ *
228
+ * Applied on save rather than on send, so the stored copy is the safe one:
229
+ * this HTML is handed back to the settings editor as well as pushed out to
230
+ * recipients, and a sanitiser that only ran on the way out would leave the
231
+ * admin rendering whatever was stored.
232
+ *
233
+ * @param {string} html
234
+ * @returns {string}
235
+ */
236
+ export function sanitiseSignature(html) {
237
+ const {html: clean} = sanitiseBody(String(html ?? ''), {
238
+ allowRemoteImages: true,
239
+ inlineImages: new Map()
240
+ });
241
+ return clean;
242
+ }
243
+
244
+ /**
245
+ * Turn a parsed message into everything the reader pane needs.
246
+ *
247
+ * @param {object} parsed - output of mailparser's simpleParser
248
+ * @param {{allowRemoteImages: boolean, inlineImageMaxBytes: number}} options
249
+ * @returns {{document: string, blockedImages: number, isHtml: boolean}}
250
+ */
251
+ export function renderBody(parsed, options) {
252
+ const inlineImages = inlineImageMap(parsed.attachments, options.inlineImageMaxBytes);
253
+
254
+ if (parsed.html) {
255
+ const {html, blockedImages} = sanitiseBody(parsed.html, {
256
+ allowRemoteImages: options.allowRemoteImages,
257
+ inlineImages
258
+ });
259
+ return {document: wrapDocument(html), blockedImages, isHtml: true};
260
+ }
261
+
262
+ // Plain-text mail: escape it and keep its line breaks. mailparser offers
263
+ // textAsHtml, but it is not sanitised, so it goes through the same path.
264
+ const text = parsed.text ?? '';
265
+ const {html} = sanitiseBody(`<pre>${escapeHtml(text)}</pre>`, {
266
+ allowRemoteImages: options.allowRemoteImages,
267
+ inlineImages
268
+ });
269
+ return {document: wrapDocument(html), blockedImages: 0, isHtml: false};
270
+ }
271
+
272
+ /**
273
+ * Summarise a message's attachments for the reader pane.
274
+ *
275
+ * Inline images that were embedded in the body are excluded - listing them
276
+ * as downloads when they are already visible is just noise.
277
+ *
278
+ * @param {object[]} attachments
279
+ * @returns {{index: number, filename: string, contentType: string, size: number}[]}
280
+ */
281
+ export function listAttachments(attachments) {
282
+ return (attachments ?? [])
283
+ .map((attachment, index) => ({attachment, index}))
284
+ .filter(({attachment}) => attachment.contentDisposition !== 'inline' || !attachment.cid)
285
+ .map(({attachment, index}) => ({
286
+ index,
287
+ filename: attachment.filename || `attachment-${index + 1}`,
288
+ contentType: attachment.contentType || 'application/octet-stream',
289
+ size: attachment.size ?? attachment.content?.length ?? 0
290
+ }));
291
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Carrying out what the rules decided.
3
+ *
4
+ * The decision is `planActions()` in `_lib/admin/mail/rules.js`, which is pure
5
+ * and knows nothing about IMAP. This is the half that talks to the server.
6
+ * Splitting them is what makes a rule testable without a mailbox, and what
7
+ * lets the settings screen preview a rule against real messages using exactly
8
+ * the code that will run it.
9
+ *
10
+ * ## Only new mail, ever
11
+ *
12
+ * Two guards, both necessary, because getting this wrong means silently
13
+ * refiling somebody's archive:
14
+ *
15
+ * 1. Rules run only over messages a sync pass has just ADDED, and never
16
+ * during a full resync - a UIDVALIDITY change re-adds the entire folder,
17
+ * and that is not new mail arriving, it is the same mail being
18
+ * re-fetched.
19
+ * 2. Messages older than `rulesFrom` - stamped the first time rules were
20
+ * saved - are skipped even if they were just added, which covers a
21
+ * folder being mirrored for the first time.
22
+ *
23
+ * Neither guard alone is enough, and the cost of both is a comparison.
24
+ *
25
+ * @module _lib/mail/ruleRunner
26
+ */
27
+ import {planActions} from '../admin/mail/rules.js';
28
+ import {
29
+ deleteMessage,
30
+ findJunkFolder,
31
+ JUNK_KEYWORD,
32
+ moveMessage
33
+ } from './write.js';
34
+
35
+ /**
36
+ * Which of a folder's newly added messages the rules may touch.
37
+ *
38
+ * @param {object[]} messages
39
+ * @param {{mode: string}} sync - the sync result for that folder
40
+ * @param {string|null} since - ISO timestamp, or null for no floor
41
+ * @returns {object[]}
42
+ */
43
+ export function eligible(messages, sync, since) {
44
+ // A full resync is the whole folder arriving again, not new mail.
45
+ if (sync?.mode === 'full') return [];
46
+
47
+ const floor = since ? new Date(since).getTime() : null;
48
+ return (messages ?? []).filter(message => {
49
+ if (floor === null) return true;
50
+ const date = new Date(String(message?.date ?? '')).getTime();
51
+ // A message with no date at all is treated as new. The alternative is
52
+ // skipping it forever, and an undated message is far more often a
53
+ // quirk of the sending client than an archive.
54
+ return !Number.isFinite(date) || date >= floor;
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Run the rules over one folder's new mail.
60
+ *
61
+ * Must be called with NO mailbox lock held. Moving a message opens its own
62
+ * mailbox on the same connection, and ImapFlow queues mailbox locks per
63
+ * client - so calling this from inside the sync's `withMailbox` would wait
64
+ * for a lock that is only released when the caller returns.
65
+ *
66
+ * Each message is handled independently and failures are collected rather
67
+ * than thrown: one message that has already been moved by another client must
68
+ * not stop the rest of the folder being filed.
69
+ *
70
+ * @param {{pool: object, store: object|null, account: object, poolKey: string,
71
+ * connection: object, folder: string, messages: object[], rules: object[]}} deps
72
+ * @returns {Promise<{considered: number, acted: number, moved: number, errors: string[]}>}
73
+ */
74
+ export async function applyRules({
75
+ pool, store, account, poolKey, connection, folder, messages, rules
76
+ }) {
77
+ const report = {considered: messages?.length ?? 0, acted: 0, moved: 0, errors: []};
78
+ if (!report.considered || !(rules ?? []).length) return report;
79
+
80
+ // Resolved once for the whole pass rather than per message: it is a LIST
81
+ // round trip, and a folder of newsletters would otherwise make one per
82
+ // message.
83
+ let junk;
84
+
85
+ for (const message of messages) {
86
+ const {actions} = planActions(rules, message);
87
+ if (!actions.length) continue;
88
+
89
+ try {
90
+ // Flags first, destination last. A message that is moved has a new
91
+ // UID in its new home, so anything flagged afterwards would be
92
+ // flagging a UID that no longer exists here.
93
+ const add = [];
94
+ for (const action of actions) {
95
+ if (action.type === 'markRead') add.push('\\Seen');
96
+ if (action.type === 'flag') add.push('\\Flagged');
97
+ if (action.type === 'junk') add.push(JUNK_KEYWORD);
98
+ }
99
+
100
+ if (add.length) {
101
+ await pool.withMailbox(poolKey, connection, folder, client =>
102
+ client.messageFlagsAdd({uid: String(message.uid)}, add, {uid: true}),
103
+ {writable: true});
104
+ }
105
+
106
+ const copy = actions.find(a => a.type === 'copy');
107
+ if (copy) {
108
+ await pool.withMailbox(poolKey, connection, folder, client =>
109
+ client.messageCopy({uid: String(message.uid)}, copy.folder, {uid: true}),
110
+ {writable: true});
111
+ }
112
+
113
+ const destination = actions.find(a => ['move', 'junk', 'delete'].includes(a.type));
114
+ if (destination) {
115
+ if (destination.type === 'delete') {
116
+ await deleteMessage({
117
+ pool, store, account, poolKey, connection, folder, uid: message.uid
118
+ });
119
+ } else {
120
+ if (destination.type === 'junk' && junk === undefined) {
121
+ junk = await findJunkFolder(pool, poolKey, connection);
122
+ }
123
+ const target = destination.type === 'junk' ? junk : destination.folder;
124
+ // A junk action on a server with no junk folder still
125
+ // marked the message; there is simply nowhere to put it,
126
+ // which is better than inventing a folder name.
127
+ if (target && target !== folder) {
128
+ await moveMessage({
129
+ pool, store, account, poolKey, connection,
130
+ folder, uid: message.uid, destination: target
131
+ });
132
+ report.moved += 1;
133
+ }
134
+ }
135
+ } else if (add.length && store) {
136
+ // Flagged in place: the mirror row is still ours to correct,
137
+ // and leaving it stale means the list shows unread mail that
138
+ // a rule has just read.
139
+ await store.setFlags(account.id, folder, [{
140
+ uid: message.uid,
141
+ flags: [...new Set([...(message.flags ?? []), ...add])]
142
+ }]);
143
+ }
144
+
145
+ report.acted += 1;
146
+ } catch (err) {
147
+ report.errors.push(`uid ${message.uid}: ${err.message}`);
148
+ }
149
+ }
150
+
151
+ return report;
152
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * The worker behind "send later", "undo send" and "snooze".
3
+ *
4
+ * One ticker, two queues, for the same reason the poller is one ticker rather
5
+ * than a timer per mailbox: the work is small, the queues are short, and a
6
+ * timer per queued message is a timer to cancel when the message is cancelled.
7
+ *
8
+ * ## Why a queue at all, for a ten second undo
9
+ *
10
+ * Holding a message in memory for ten seconds would be simpler and would lose
11
+ * mail. A restart during the hold - a deploy, a crash, `pm2 reload` - takes
12
+ * the only copy with it, and the sender has already been told it is on its
13
+ * way. Everything that has been accepted from a person is durable before they
14
+ * are told it was accepted.
15
+ *
16
+ * ## Sent exactly once
17
+ *
18
+ * The claim is a conditional update in the store (`claimDueSends`), so a tick
19
+ * that overruns into the next one cannot pick the same message up twice.
20
+ * Everything after the claim is best-effort and reported; the claim itself is
21
+ * the part that must be exact, because a duplicate message cannot be recalled.
22
+ *
23
+ * @module _lib/mail/scheduler
24
+ */
25
+ import * as accounts from './accounts.js';
26
+ import {sendMessage} from './send.js';
27
+
28
+ /** How often the ticker looks for work. */
29
+ const TICK_MS = 5_000;
30
+
31
+ /**
32
+ * How many times a queued send is retried before it is given up on.
33
+ *
34
+ * A mail server that is briefly unreachable should not lose the message; one
35
+ * that refuses it should not be asked forever. Three is enough for the first
36
+ * and few enough for the second.
37
+ */
38
+ const MAX_ATTEMPTS = 3;
39
+
40
+ /** How long to wait before trying a failed send again. */
41
+ const RETRY_MS = 120_000;
42
+
43
+ /**
44
+ * Create the scheduler.
45
+ *
46
+ * @param {{store: object, pool: object, config: object, logger: object,
47
+ * tickMs?: number}} deps
48
+ * @returns {{start: Function, stop: Function, runNow: Function}}
49
+ */
50
+ export function createScheduler({store, pool, config, logger, tickMs = TICK_MS}) {
51
+ let timer = null;
52
+ let stopped = false;
53
+ let running = false;
54
+
55
+ /**
56
+ * Send one claimed message.
57
+ *
58
+ * @param {object} item
59
+ * @returns {Promise<void>}
60
+ */
61
+ async function runSend(item) {
62
+ const account = await accounts.getAccount(item.userId, item.accountId);
63
+ if (!account) {
64
+ // The mailbox was removed while this was queued. There is nothing
65
+ // to send it from and never will be.
66
+ await store.finishSend(item._id, {
67
+ status: 'failed',
68
+ error: 'That mailbox no longer exists.'
69
+ });
70
+ return;
71
+ }
72
+
73
+ const smtp = accounts.toSmtpConnection(account, item.identityId ?? null);
74
+ if (!smtp) {
75
+ await store.finishSend(item._id, {
76
+ status: 'failed',
77
+ error: 'No outgoing server is configured for this mailbox.'
78
+ });
79
+ return;
80
+ }
81
+
82
+ try {
83
+ const result = await sendMessage({
84
+ smtp,
85
+ draft: item.draft,
86
+ pool,
87
+ poolKey: `${item.userId}:${item.accountId}`,
88
+ connection: accounts.toConnection(account),
89
+ sentFolder: smtp.sentFolder,
90
+ allowInsecureTLS: config.allowInsecureTLS === true,
91
+ replaces: item.replaces ?? null
92
+ });
93
+ await store.finishSend(item._id, {status: 'sent', result, error: null});
94
+ } catch (err) {
95
+ const giveUp = (item.attempts ?? 1) >= MAX_ATTEMPTS;
96
+ await store.finishSend(item._id, giveUp
97
+ ? {status: 'failed', error: err.message}
98
+ // Back to pending, a little later. `finishedAt` is set either
99
+ // way, which is harmless on a retry and is what tells a
100
+ // screen when the last attempt was.
101
+ : {status: 'pending', at: new Date(Date.now() + RETRY_MS), error: err.message});
102
+
103
+ logger.warn(
104
+ `[mail] queued send ${item._id} failed (attempt ${item.attempts}): ${err.message}`
105
+ );
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Bring one snoozed message back.
111
+ *
112
+ * Moved back where it came from and marked unread, because the whole
113
+ * point of putting it away was to be shown it again - a message that
114
+ * returns already read returns invisibly.
115
+ *
116
+ * @param {object} item
117
+ * @returns {Promise<void>}
118
+ */
119
+ async function runWake(item) {
120
+ const account = await accounts.getAccount(item.userId, item.accountId);
121
+ if (!account) {
122
+ await store.finishSnooze(item._id, {status: 'failed', error: 'That mailbox no longer exists.'});
123
+ return;
124
+ }
125
+
126
+ const poolKey = `${item.userId}:${item.accountId}`;
127
+ const connection = accounts.toConnection(account);
128
+
129
+ try {
130
+ // The UID the move reported, when the server has UIDPLUS. Without
131
+ // it the message is found by its Message-ID instead - a snooze on
132
+ // such a server is still a snooze, and refusing to bring it back
133
+ // would be losing the message rather than delaying it.
134
+ const uid = item.uid ?? await findByMessageId(poolKey, connection, item);
135
+ if (uid === null) {
136
+ throw new Error('That message is no longer in the snooze folder.');
137
+ }
138
+
139
+ // Unread first, then moved. The other order would be flagging a
140
+ // UID that has just stopped existing in this folder.
141
+ await pool.withMailbox(poolKey, connection, item.folder, client =>
142
+ client.messageFlagsRemove({uid: String(uid)}, ['\\Seen'], {uid: true}),
143
+ {writable: true});
144
+
145
+ const {moveMessage} = await import('./write.js');
146
+ const moved = await moveMessage({
147
+ pool, store, account, poolKey, connection,
148
+ folder: item.folder, uid, destination: item.returnTo
149
+ });
150
+
151
+ await store.finishSnooze(item._id, {
152
+ status: 'woken', error: null, newUid: moved.newUid ?? null
153
+ });
154
+ } catch (err) {
155
+ // Retried, not given up on. A mail server that is briefly
156
+ // unreachable is an ordinary condition, and a snooze abandoned on
157
+ // the first blip leaves the message parked in a folder the user
158
+ // does not read, absent from the list that was supposed to bring
159
+ // it back.
160
+ const attempts = (item.attempts ?? 0) + 1;
161
+ const giveUp = attempts >= MAX_ATTEMPTS;
162
+ await store.finishSnooze(item._id, giveUp
163
+ ? {status: 'failed', error: err.message, attempts}
164
+ : {status: 'pending', at: new Date(Date.now() + RETRY_MS), error: err.message, attempts});
165
+ logger.warn(
166
+ `[mail] could not wake snoozed message ${item._id} (attempt ${attempts}): ${err.message}`
167
+ );
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Find a parked message by its Message-ID.
173
+ *
174
+ * The way back on a server with no UIDPLUS, which cannot report the UID a
175
+ * moved message was given. A HEADER search is not fast, but the snooze
176
+ * folder holds only what is waiting, and this runs once per message when
177
+ * it comes due.
178
+ *
179
+ * @param {string} poolKey
180
+ * @param {object} connection
181
+ * @param {object} item
182
+ * @returns {Promise<number|null>}
183
+ */
184
+ async function findByMessageId(poolKey, connection, item) {
185
+ if (!item.messageId) return null;
186
+ return pool.withMailbox(poolKey, connection, item.folder, async (client) => {
187
+ const found = await client.search({header: {'message-id': item.messageId}}, {uid: true});
188
+ // The newest match, in the vanishingly unlikely case a folder
189
+ // holds two messages with one id.
190
+ return found?.length ? Number(found[found.length - 1]) : null;
191
+ });
192
+ }
193
+
194
+ /**
195
+ * One pass over both queues.
196
+ *
197
+ * Guarded against overlapping itself: a slow mail server can make a send
198
+ * take longer than the tick interval, and a second pass starting
199
+ * underneath the first would double the connections without doubling the
200
+ * throughput.
201
+ *
202
+ * @returns {Promise<void>}
203
+ */
204
+ async function tick() {
205
+ if (stopped || running) return;
206
+ running = true;
207
+ try {
208
+ for (const item of await store.claimDueSends()) {
209
+ if (stopped) return;
210
+ await runSend(item);
211
+ }
212
+ for (const item of await store.claimDueSnoozes()) {
213
+ if (stopped) return;
214
+ await runWake(item);
215
+ }
216
+ } catch (err) {
217
+ // The store being unreachable is an ordinary condition here -
218
+ // Mongo restarting, say - and the next tick will try again.
219
+ logger.warn(`[mail] scheduler tick failed: ${err.message}`);
220
+ } finally {
221
+ running = false;
222
+ }
223
+ }
224
+
225
+ return {
226
+ /**
227
+ * @returns {void}
228
+ */
229
+ start() {
230
+ if (timer) return;
231
+ stopped = false;
232
+ timer = setInterval(() => {
233
+ tick().catch(err => logger.warn(`[mail] scheduler tick failed: ${err.message}`));
234
+ }, tickMs);
235
+ timer.unref?.();
236
+ },
237
+
238
+ /**
239
+ * @returns {void}
240
+ */
241
+ stop() {
242
+ stopped = true;
243
+ if (timer) clearInterval(timer);
244
+ timer = null;
245
+ },
246
+
247
+ /**
248
+ * Run a pass immediately - used by "send it now" on a queued message.
249
+ *
250
+ * @returns {Promise<void>}
251
+ */
252
+ runNow: tick
253
+ };
254
+ }