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,101 @@
1
+ /**
2
+ * Short-lived tokens authorising one message body fetch.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * A message renders inside an iframe. The obvious way to fill that iframe is
7
+ * `srcdoc`, and that is what the reader did first - but a `srcdoc` document
8
+ * **inherits the embedding page's Content-Security-Policy**, and the admin is
9
+ * served with `img-src 'self' data: blob:`. The consequence was that "Load
10
+ * images" could flip the `src` to the real URL and the browser would then
11
+ * refuse to fetch it, silently, with no request ever made. The feature could
12
+ * not work, however correct the server was.
13
+ *
14
+ * A document loaded over the network carries its own CSP instead of
15
+ * inheriting, so the body is served from a real route. That route cannot read
16
+ * an Authorization header - an `<iframe src>` sends none - so the URL carries
17
+ * a signed token that says exactly which message, for which user, and for how
18
+ * long. Same shape as the CMS's own preview links.
19
+ *
20
+ * The token is deliberately narrow: it names one mailbox, one folder and one
21
+ * message, it expires in minutes, and it grants nothing else.
22
+ *
23
+ * @module _lib/mail/bodyTokens
24
+ */
25
+ import {createHmac, timingSafeEqual} from 'node:crypto';
26
+
27
+ import {resolveKeySource} from './secretbox.js';
28
+
29
+ /** Domain separation, so this key can never collide with the sealing key. */
30
+ const DOMAIN = 'domma-mail-body-v1';
31
+
32
+ /** How long a token stays valid. Long enough to load a page, short enough not to matter. */
33
+ const TTL_MS = 120_000;
34
+
35
+ /**
36
+ * Sign a payload.
37
+ *
38
+ * @param {string} body - base64url payload
39
+ * @returns {string} base64url signature
40
+ */
41
+ function signature(body) {
42
+ const {secret} = resolveKeySource();
43
+ return createHmac('sha256', DOMAIN + ':' + secret).update(body).digest('base64url');
44
+ }
45
+
46
+ /**
47
+ * Mint a token authorising one message body fetch.
48
+ *
49
+ * @param {{userId: string, accountId: string, folder: string, uid: number, images: boolean}} grant
50
+ * @returns {string}
51
+ */
52
+ export function sign(grant) {
53
+ const payload = {
54
+ u: grant.userId,
55
+ a: grant.accountId,
56
+ f: grant.folder,
57
+ m: grant.uid,
58
+ i: grant.images ? 1 : 0,
59
+ e: Date.now() + TTL_MS
60
+ };
61
+ const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
62
+ return `${body}.${signature(body)}`;
63
+ }
64
+
65
+ /**
66
+ * Check a token and return what it authorises.
67
+ *
68
+ * @param {string} token
69
+ * @returns {{userId: string, accountId: string, folder: string, uid: number, images: boolean}|null}
70
+ */
71
+ export function verify(token) {
72
+ if (typeof token !== 'string' || !token.includes('.')) return null;
73
+
74
+ const [body, provided] = token.split('.');
75
+ if (!body || !provided) return null;
76
+
77
+ const expected = signature(body);
78
+ // Compare in constant time, and only when the lengths already match -
79
+ // timingSafeEqual throws on a length mismatch.
80
+ const a = Buffer.from(provided);
81
+ const b = Buffer.from(expected);
82
+ if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
83
+
84
+ let payload;
85
+ try {
86
+ payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
87
+ } catch {
88
+ return null;
89
+ }
90
+
91
+ if (typeof payload?.e !== 'number' || Date.now() > payload.e) return null;
92
+ if (!payload.u || !payload.a || typeof payload.m !== 'number') return null;
93
+
94
+ return {
95
+ userId: payload.u,
96
+ accountId: payload.a,
97
+ folder: typeof payload.f === 'string' ? payload.f : 'INBOX',
98
+ uid: payload.m,
99
+ images: payload.i === 1
100
+ };
101
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Turning a message into a reply, and a reply into something sendable.
3
+ *
4
+ * Pure, and tested on its own, because the parts that go wrong here are not
5
+ * the sending - nodemailer does that - but the conventions around it. A reply
6
+ * that loses its threading headers starts a new conversation in every client
7
+ * that receives it, a reply-all that includes you talks to yourself, and a
8
+ * quote that drops the attribution line reads as if you wrote it.
9
+ *
10
+ * @module _lib/mail/compose
11
+ */
12
+ import {readPriority} from './priority.js';
13
+
14
+ /**
15
+ * Addresses to reply to.
16
+ *
17
+ * Reply-To wins over From when present: a sender who sets it is asking for
18
+ * replies somewhere else, and ignoring that is how replies to a mailing list
19
+ * end up in one person's inbox.
20
+ *
21
+ * @param {object} message
22
+ * @returns {{name: string, address: string}[]}
23
+ */
24
+ export function replyRecipients(message) {
25
+ const replyTo = message.replyTo ?? [];
26
+ if (replyTo.length) return replyTo;
27
+ return message.from ?? [];
28
+ }
29
+
30
+ /**
31
+ * Everyone a reply-all should reach.
32
+ *
33
+ * The sender plus the original recipients, minus you - a reply-all that
34
+ * includes your own address sends you a copy of your own mail, which every
35
+ * client then has to file.
36
+ *
37
+ * @param {object} message
38
+ * @param {string} self - the address replying
39
+ * @returns {{to: object[], cc: object[]}}
40
+ */
41
+ export function replyAllRecipients(message, self) {
42
+ const mine = String(self ?? '').trim().toLowerCase();
43
+ const notMine = list => (list ?? []).filter(a => String(a.address ?? '').toLowerCase() !== mine);
44
+
45
+ const to = replyRecipients(message);
46
+ const seen = new Set(to.map(a => String(a.address ?? '').toLowerCase()));
47
+
48
+ const cc = [...notMine(message.to), ...notMine(message.cc)]
49
+ .filter(a => {
50
+ const key = String(a.address ?? '').toLowerCase();
51
+ if (!key || seen.has(key)) return false;
52
+ seen.add(key);
53
+ return true;
54
+ });
55
+
56
+ return {to: notMine(to), cc};
57
+ }
58
+
59
+ /**
60
+ * The subject a reply carries.
61
+ *
62
+ * Only one "Re:" however many times a thread goes round, and the existing
63
+ * prefix is recognised case-insensitively because clients disagree about it.
64
+ *
65
+ * @param {string} subject
66
+ * @param {string} [prefix]
67
+ * @returns {string}
68
+ */
69
+ export function replySubject(subject, prefix = 'Re') {
70
+ const clean = String(subject ?? '').trim();
71
+ const pattern = new RegExp(`^${prefix}:\\s*`, 'i');
72
+ return pattern.test(clean) ? clean : `${prefix}: ${clean}`;
73
+ }
74
+
75
+ /**
76
+ * The References header for a reply.
77
+ *
78
+ * A thread is reconstructed from this chain, so it carries everything the
79
+ * parent carried plus the parent itself. Trimmed from the front when it grows
80
+ * long, keeping the root - which is what identifies the thread - and the most
81
+ * recent, which is what identifies the position in it.
82
+ *
83
+ * @param {{messageId?: string|null, references?: string|string[]|null}} parent
84
+ * @param {number} [limit]
85
+ * @returns {string[]}
86
+ */
87
+ export function buildReferences(parent, limit = 20) {
88
+ const existing = Array.isArray(parent?.references)
89
+ ? parent.references
90
+ : String(parent?.references ?? '').split(/\s+/).filter(Boolean);
91
+
92
+ const chain = [...existing];
93
+ if (parent?.messageId) chain.push(parent.messageId);
94
+
95
+ const unique = [...new Set(chain)];
96
+ if (unique.length <= limit) return unique;
97
+ return [unique[0], ...unique.slice(-(limit - 1))];
98
+ }
99
+
100
+ /**
101
+ * The attribution line above a quote.
102
+ *
103
+ * @param {object} message
104
+ * @returns {string}
105
+ */
106
+ export function attributionLine(message) {
107
+ const who = (message.from ?? [])[0];
108
+ const name = who?.name || who?.address || 'someone';
109
+ const when = message.date ? new Date(message.date).toUTCString() : 'an earlier message';
110
+ return `On ${when}, ${name} wrote:`;
111
+ }
112
+
113
+ /**
114
+ * Quote a message as plain text.
115
+ *
116
+ * Quoting the text part, never the HTML: quoting HTML means either embedding
117
+ * someone else's markup in your message or stripping it badly, and every mail
118
+ * client in existence understands "> ".
119
+ *
120
+ * @param {object} message
121
+ * @param {string} body
122
+ * @returns {string}
123
+ */
124
+ export function quoteText(message, body) {
125
+ const quoted = String(body ?? '')
126
+ .replace(/\r\n/g, '\n')
127
+ .split('\n')
128
+ .map(line => (line.startsWith('>') ? `>${line}` : `> ${line}`))
129
+ .join('\n');
130
+ return `\n\n${attributionLine(message)}\n${quoted}\n`;
131
+ }
132
+
133
+ /**
134
+ * Quote a message as HTML.
135
+ *
136
+ * A blockquote, which is what every client renders as a quote, around the
137
+ * original's own markup once it has been through the same sanitiser the
138
+ * reader uses. Remote images are dropped rather than blocked: a quote should
139
+ * not carry a tracking pixel onward to everyone on the reply.
140
+ *
141
+ * @param {object} message
142
+ * @param {string} html - the original's HTML, or null
143
+ * @param {string} text - the original's text, used when there is no HTML
144
+ * @param {(fragment: string) => string} sanitise
145
+ * @returns {string}
146
+ */
147
+ export function quoteHtml(message, html, text, sanitise) {
148
+ const escape = value => String(value ?? '')
149
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
150
+
151
+ const inner = html
152
+ ? sanitise(html)
153
+ : `<pre style="white-space:pre-wrap;font-family:inherit;margin:0">${escape(text)}</pre>`;
154
+
155
+ return `<p><br></p><p>${escape(attributionLine(message))}</p>`
156
+ + `<blockquote style="margin:0 0 0 .8em;padding-left:.8em;border-left:3px solid #d0d0d0">${inner}</blockquote>`;
157
+ }
158
+
159
+ /**
160
+ * Build a complete draft from a message being answered.
161
+ *
162
+ * @param {{message: object, self: string, mode: 'reply'|'replyAll'|'forward', body: string}} options
163
+ * @returns {object} a draft the compose window can open with
164
+ */
165
+ export function buildDraft({message, self, mode, body, html = null, sanitise = null}) {
166
+ const htmlQuote = sanitise ? quoteHtml(message, html, body, sanitise) : null;
167
+
168
+ if (mode === 'forward') {
169
+ return {
170
+ to: [],
171
+ cc: [],
172
+ subject: replySubject(message.subject, 'Fwd'),
173
+ // A forward carries no In-Reply-To: it is not an answer to the
174
+ // message, and claiming otherwise files it into a thread the new
175
+ // recipient has never seen.
176
+ inReplyTo: null,
177
+ references: [],
178
+ body: quoteText(message, body),
179
+ html: htmlQuote
180
+ };
181
+ }
182
+
183
+ const {to, cc} = mode === 'replyAll'
184
+ ? replyAllRecipients(message, self)
185
+ : {to: replyRecipients(message), cc: []};
186
+
187
+ return {
188
+ to,
189
+ cc,
190
+ subject: replySubject(message.subject),
191
+ inReplyTo: message.messageId ?? null,
192
+ references: buildReferences(message),
193
+ body: quoteText(message, body),
194
+ html: htmlQuote
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Turn a saved draft back into something the compose window can open.
200
+ *
201
+ * Not `buildDraft`, and deliberately not built on it: a reply derives its
202
+ * recipients, subject and quoted body FROM a message, whereas reopening a
203
+ * draft means taking the message verbatim - it already is the thing being
204
+ * written. Routing it through the reply arithmetic would quote the draft
205
+ * inside itself and address it to whoever it was already addressed to.
206
+ *
207
+ * `savedUid` is what makes the round trip work: saving again supersedes this
208
+ * version rather than adding a second one, and sending tidies it away. Without
209
+ * it, editing a draft four times leaves four drafts.
210
+ *
211
+ * @param {object} message - a parsed draft (mailparser shape, flattened)
212
+ * @param {number|null} uid - the draft's UID in the Drafts folder
213
+ * @returns {object}
214
+ */
215
+ export function editableDraft(message, uid = null) {
216
+ const addresses = list => (list ?? []).map(a => ({
217
+ name: a.name ?? '', address: a.address ?? ''
218
+ }));
219
+
220
+ return {
221
+ to: addresses(message.to),
222
+ cc: addresses(message.cc),
223
+ // A draft can have one; a received message never does. Dropping it on
224
+ // reopen would silently change who a half-written message goes to.
225
+ bcc: addresses(message.bcc),
226
+ subject: message.subject ?? '',
227
+ // Kept, so replying to the thread the draft belongs to survives a save
228
+ // and reopen. A draft written as a reply is still a reply.
229
+ inReplyTo: message.inReplyTo ?? null,
230
+ references: normaliseReferences(message.references),
231
+ body: message.text ?? '',
232
+ html: message.html || null,
233
+ // Kept, or a draft marked urgent would quietly become ordinary the
234
+ // moment it was reopened.
235
+ priority: readPriority(message.headers),
236
+ attachments: (message.attachments ?? []).map(file => ({
237
+ filename: file.filename ?? 'attachment',
238
+ contentType: file.contentType ?? 'application/octet-stream',
239
+ content: file.content ?? '',
240
+ size: file.size ?? 0
241
+ })),
242
+ savedUid: uid
243
+ };
244
+ }
245
+
246
+ /**
247
+ * References arrive as a string on one server and an array on the next.
248
+ *
249
+ * @param {string|string[]|null|undefined} value
250
+ * @returns {string[]}
251
+ */
252
+ function normaliseReferences(value) {
253
+ if (Array.isArray(value)) return value.filter(Boolean);
254
+ if (typeof value === 'string') return value.split(/\s+/).filter(Boolean);
255
+ return [];
256
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Reading defaults, shared by every edition.
3
+ *
4
+ * `mail-reader` uses these as-is; `email-pro` spreads them and adds what its
5
+ * synchronised store needs. Merged with user overrides from
6
+ * config/plugins.json by whichever plugin is loading.
7
+ *
8
+ * @module _lib/mail/defaults
9
+ */
10
+ export default {
11
+ /** Messages per page in the list pane. */
12
+ listLimit: 50,
13
+
14
+ /** Hard ceiling on ?limit=, so a crafted request cannot ask for a whole mailbox. */
15
+ maxListLimit: 200,
16
+
17
+ /** How long an unused IMAP connection is kept before it is closed. */
18
+ connectionIdleMs: 300_000,
19
+
20
+ /** Most simultaneous pooled connections. The least recently used is dropped beyond this. */
21
+ maxConnections: 20,
22
+
23
+ /** Connect, greeting and socket timeout. A dead server should fail fast, not hang the admin. */
24
+ connectionTimeoutMs: 20_000,
25
+
26
+ /**
27
+ * Load remote images in message bodies by default.
28
+ *
29
+ * Off, deliberately: a remote image in an email is usually a tracking
30
+ * pixel, and loading it reports the open back to the sender. The reader
31
+ * offers a per-message override that is never persisted.
32
+ */
33
+ allowRemoteImages: false,
34
+
35
+ /**
36
+ * Accept TLS certificates that do not verify.
37
+ *
38
+ * Only for a mail server with a self-signed certificate that you control.
39
+ * It disables the check that the server is who it says it is.
40
+ */
41
+ allowInsecureTLS: false,
42
+
43
+ /** Largest inline image embedded into a rendered body, in bytes. */
44
+ inlineImageMaxBytes: 2_000_000,
45
+
46
+ /** Largest message source fetched for rendering or attachment extraction, in bytes. */
47
+ maxMessageBytes: 25_000_000,
48
+
49
+ /**
50
+ * Largest total attachment payload on an outgoing message, in bytes.
51
+ *
52
+ * Twenty megabytes, because most providers refuse somewhere around
53
+ * twenty-five and it is better to be told here than to have the message
54
+ * accepted and then bounced an hour later.
55
+ */
56
+ maxAttachmentBytes: 20_000_000
57
+ };
@@ -0,0 +1,154 @@
1
+ /**
2
+ * What the server knows about itself, for a bug report.
3
+ *
4
+ * Built because the expensive failures here were never "what does the code
5
+ * do" - they were "which build is that browser actually running", which no
6
+ * amount of reading the source answers. This reports the facts that settle
7
+ * that question, so a report can start from evidence instead of a description.
8
+ *
9
+ * Nothing secret goes in. Hostnames and usernames are needed to tell one
10
+ * mailbox from another; passwords, sealed or otherwise, are not, and the key
11
+ * SOURCE is reported without the key.
12
+ *
13
+ * @module _lib/mail/diagnostics
14
+ */
15
+ import fs from 'node:fs/promises';
16
+ import path from 'node:path';
17
+ import {fileURLToPath} from 'node:url';
18
+
19
+ import * as accounts from './accounts.js';
20
+ import {resolveKeySource} from './secretbox.js';
21
+
22
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
+
24
+ /** The browser-facing files whose versions decide what a page is running. */
25
+ const SHARED_ASSETS = [
26
+ 'reader-view.js', 'folder-tree.js', 'panes.js',
27
+ 'image-senders-section.js', 'diagnostics-section.js',
28
+ 'mail.html', 'mail.css'
29
+ ];
30
+
31
+ /**
32
+ * The cache-busting token each shared asset would be served with.
33
+ *
34
+ * Compared against what the browser actually loaded, this is the difference
35
+ * between "stale cache" and "real bug" - a distinction that cost most of a
36
+ * day to make by hand.
37
+ *
38
+ * @returns {Promise<Object.<string, number|null>>}
39
+ */
40
+ async function sharedAssetVersions() {
41
+ const dir = path.join(__dirname, '..', 'admin', 'mail');
42
+ const out = {};
43
+ await Promise.all(SHARED_ASSETS.map(async name => {
44
+ try {
45
+ out[name] = Math.floor((await fs.stat(path.join(dir, name))).mtimeMs);
46
+ } catch {
47
+ out[name] = null;
48
+ }
49
+ }));
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * The plugin's declared version.
55
+ *
56
+ * @param {string} pluginName
57
+ * @returns {Promise<string|null>}
58
+ */
59
+ async function pluginVersion(pluginName) {
60
+ try {
61
+ const raw = await fs.readFile(path.join(__dirname, '..', '..', pluginName, 'plugin.json'), 'utf8');
62
+ return JSON.parse(raw).version ?? null;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * The stamp the loader would put on this plugin's view entry.
70
+ *
71
+ * The newest of the entry itself and the shared admin library, which is what
72
+ * makes a change to shared code reach a browser. Reported so a page can
73
+ * compare what it loaded against what it should have.
74
+ *
75
+ * @param {string} pluginName
76
+ * @returns {Promise<number>}
77
+ */
78
+ async function expectedStamp(pluginName) {
79
+ const dir = path.join(__dirname, '..', 'admin');
80
+ let newest = 0;
81
+
82
+ const walk = async current => {
83
+ let entries;
84
+ try {
85
+ entries = await fs.readdir(current, {withFileTypes: true});
86
+ } catch {
87
+ return;
88
+ }
89
+ for (const entry of entries) {
90
+ const full = path.join(current, entry.name);
91
+ if (entry.isDirectory()) await walk(full);
92
+ else {
93
+ try {
94
+ newest = Math.max(newest, Math.floor((await fs.stat(full)).mtimeMs));
95
+ } catch { /* ignore */ }
96
+ }
97
+ }
98
+ };
99
+ await walk(dir);
100
+
101
+ try {
102
+ const entry = path.join(__dirname, '..', '..', pluginName, 'admin', 'views', 'mail.js');
103
+ newest = Math.max(newest, Math.floor((await fs.stat(entry)).mtimeMs));
104
+ } catch { /* the plugin may name its view differently */ }
105
+
106
+ return newest;
107
+ }
108
+
109
+ /**
110
+ * Assemble the report for one user.
111
+ *
112
+ * @param {{pluginName: string, userId: string, config: object,
113
+ * extra?: () => Promise<object>|object}} options
114
+ * @returns {Promise<object>}
115
+ */
116
+ export async function buildDiagnostics({pluginName, userId, config, extra = null}) {
117
+ const owned = await accounts.listAccounts(userId);
118
+
119
+ return {
120
+ generatedAt: new Date().toISOString(),
121
+ plugin: {
122
+ name: pluginName,
123
+ version: await pluginVersion(pluginName)
124
+ },
125
+ runtime: {
126
+ node: process.version,
127
+ uptimeSeconds: Math.round(process.uptime())
128
+ },
129
+ assets: {...(await sharedAssetVersions()), expectedStamp: await expectedStamp(pluginName)},
130
+ credentials: {
131
+ // Which key is sealing passwords, never the key itself. A mailbox
132
+ // that stops opening after a deploy is usually this having moved.
133
+ keySource: resolveKeySource().source
134
+ },
135
+ config: {
136
+ listLimit: config.listLimit,
137
+ allowRemoteImages: config.allowRemoteImages,
138
+ allowInsecureTLS: config.allowInsecureTLS,
139
+ connectionIdleMs: config.connectionIdleMs
140
+ },
141
+ mailboxes: owned.map(account => ({
142
+ id: account.id,
143
+ label: account.label,
144
+ host: `${account.host}:${account.port}`,
145
+ secure: account.secure !== false,
146
+ username: account.username,
147
+ syncFolders: account.syncFolders ?? '(default)',
148
+ pollIntervalMinutes: account.pollIntervalMinutes ?? '(default)',
149
+ trustedImageSenders: (account.imageSenders ?? []).length,
150
+ rememberImageSenders: account.rememberImageSenders !== false
151
+ })),
152
+ ...(extra ? {edition: await extra()} : {})
153
+ };
154
+ }