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,229 @@
1
+ /**
2
+ * Sealed-secret helper for the Mail Reader plugin.
3
+ *
4
+ * IMAP is a password protocol: to reconnect without asking the user every time
5
+ * we have to keep their mailbox password, and keeping it in plain text on disk
6
+ * is not an option. Everything stored goes through seal()/open() here.
7
+ *
8
+ * ## Key resolution
9
+ *
10
+ * The key is derived from whichever of these is present, in order:
11
+ *
12
+ * 1. `MAIL_SECRET` — explicit override for anyone who wants mail
13
+ * credentials on their own rotatable key.
14
+ * 2. `INSTANCE_SECRET` — written by `npm run setup` and by manager
15
+ * registration. Present on fresh installs; NOT
16
+ * present on sites that predate it, which is why
17
+ * this is a chain and not a single source.
18
+ * 3. a generated key in `data/.mailkey` (0600) — last resort so the plugin
19
+ * works out of the box. It sits next to the sealed
20
+ * data, so it defends a leaked *backup* of
21
+ * accounts.json, not a compromised host.
22
+ *
23
+ * Deliberately NOT derived from JWT_SECRET: rotating that logs everyone out,
24
+ * which is recoverable, and silently bricking every stored mailbox password at
25
+ * the same time is not.
26
+ *
27
+ * The source that sealed a value is recorded in the token, so moving up the
28
+ * chain (adding MAIL_SECRET to a site that had been using .mailkey) fails with
29
+ * a sentence that says what happened instead of a bare auth error.
30
+ *
31
+ * @module _lib/mail/secretbox
32
+ */
33
+ import {createCipheriv, createDecipheriv, randomBytes, scryptSync} from 'node:crypto';
34
+ import fs from 'node:fs';
35
+ import path from 'node:path';
36
+ import {fileURLToPath} from 'node:url';
37
+
38
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
+
40
+ /**
41
+ * Domain separation - stops this key colliding with any other use of the same
42
+ * secret. Deliberately NOT named after the plugin: `email-pro` is expected to
43
+ * open the same sealed accounts, and renaming this tag would invalidate every
44
+ * stored password for the sake of cosmetics.
45
+ */
46
+ const DOMAIN = 'domma-mail-v1';
47
+
48
+ /** Minimum acceptable length for an env-supplied secret. */
49
+ const MIN_SECRET_LENGTH = 32;
50
+
51
+ /** Shares the override used by accounts.js - see the note there. */
52
+ const DATA_DIR = process.env.DOMMA_MAIL_DATA_DIR || path.join(__dirname, '..', 'data');
53
+
54
+ const KEY_FILE = path.join(DATA_DIR, '.mailkey');
55
+
56
+ /** Where the key lived while this code belonged to the mail-reader plugin. */
57
+ const LEGACY_KEY_FILE = path.join(__dirname, '..', '..', 'mail-reader', 'data', '.mailkey');
58
+
59
+ /** scrypt is deliberately slow; cache derived keys so a reconnect is not a CPU event. */
60
+ const keyCache = new Map();
61
+
62
+ /**
63
+ * Raised when a sealed value cannot be opened with the key we can derive.
64
+ * Carries the two sources so the caller can explain the mismatch.
65
+ */
66
+ export class MailKeyError extends Error {
67
+ /**
68
+ * @param {string} message
69
+ * @param {{sealedWith?: string, availableFrom?: string}} [detail]
70
+ */
71
+ constructor(message, detail = {}) {
72
+ super(message);
73
+ this.name = 'MailKeyError';
74
+ this.sealedWith = detail.sealedWith ?? null;
75
+ this.availableFrom = detail.availableFrom ?? null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Read, or create, the local fallback key.
81
+ *
82
+ * Written 0600 at create time rather than chmod-ed afterwards, so there is no
83
+ * window where the file exists and is world-readable.
84
+ *
85
+ * @returns {string} hex key material
86
+ */
87
+ function localKeyMaterial() {
88
+ try {
89
+ const existing = fs.readFileSync(KEY_FILE, 'utf8').trim();
90
+ if (existing.length >= MIN_SECRET_LENGTH) return existing;
91
+ } catch {
92
+ // Missing or unreadable - fall through.
93
+ }
94
+
95
+ // Adopt the key from the old mail-reader-owned location before generating
96
+ // a new one. Generating first would strand every password already sealed
97
+ // with it, and the user would have no way to tell why.
98
+ try {
99
+ const legacy = fs.readFileSync(LEGACY_KEY_FILE, 'utf8').trim();
100
+ if (legacy.length >= MIN_SECRET_LENGTH) {
101
+ fs.mkdirSync(path.dirname(KEY_FILE), {recursive: true});
102
+ fs.writeFileSync(KEY_FILE, legacy + '\n', {encoding: 'utf8', mode: 0o600});
103
+ return legacy;
104
+ }
105
+ } catch {
106
+ // No legacy key - fall through and generate.
107
+ }
108
+
109
+ const generated = randomBytes(32).toString('hex');
110
+ fs.mkdirSync(path.dirname(KEY_FILE), {recursive: true});
111
+ fs.writeFileSync(KEY_FILE, generated + '\n', {encoding: 'utf8', mode: 0o600});
112
+ return generated;
113
+ }
114
+
115
+ /**
116
+ * Resolve the key material and say where it came from.
117
+ *
118
+ * @returns {{secret: string, source: 'env-mail'|'env-instance'|'local'}}
119
+ */
120
+ export function resolveKeySource() {
121
+ const mailSecret = process.env.MAIL_SECRET;
122
+ if (typeof mailSecret === 'string' && mailSecret.length >= MIN_SECRET_LENGTH) {
123
+ return {secret: mailSecret, source: 'env-mail'};
124
+ }
125
+
126
+ const instanceSecret = process.env.INSTANCE_SECRET;
127
+ if (typeof instanceSecret === 'string' && instanceSecret.length >= MIN_SECRET_LENGTH) {
128
+ return {secret: instanceSecret, source: 'env-instance'};
129
+ }
130
+
131
+ return {secret: localKeyMaterial(), source: 'local'};
132
+ }
133
+
134
+ /**
135
+ * Derive a 32-byte key for a given salt, memoised per (source, salt).
136
+ *
137
+ * @param {string} secret
138
+ * @param {string} source
139
+ * @param {Buffer} salt
140
+ * @returns {Buffer}
141
+ */
142
+ function deriveKey(secret, source, salt) {
143
+ const cacheKey = source + ':' + salt.toString('base64');
144
+ const cached = keyCache.get(cacheKey);
145
+ if (cached) return cached;
146
+
147
+ const key = scryptSync(DOMAIN + ':' + secret, salt, 32);
148
+ keyCache.set(cacheKey, key);
149
+ return key;
150
+ }
151
+
152
+ /**
153
+ * Encrypt a string for storage.
154
+ *
155
+ * Format: `v1.<source>.<salt>.<iv>.<tag>.<ciphertext>`, each part base64url.
156
+ *
157
+ * @param {string} plaintext
158
+ * @returns {string} sealed token
159
+ */
160
+ export function seal(plaintext) {
161
+ const {secret, source} = resolveKeySource();
162
+ const salt = randomBytes(16);
163
+ const iv = randomBytes(12);
164
+ const key = deriveKey(secret, source, salt);
165
+
166
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
167
+ const ciphertext = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
168
+ const tag = cipher.getAuthTag();
169
+
170
+ return [
171
+ 'v1',
172
+ source,
173
+ salt.toString('base64url'),
174
+ iv.toString('base64url'),
175
+ tag.toString('base64url'),
176
+ ciphertext.toString('base64url')
177
+ ].join('.');
178
+ }
179
+
180
+ /**
181
+ * Decrypt a sealed token.
182
+ *
183
+ * @param {string} token
184
+ * @returns {string} plaintext
185
+ * @throws {MailKeyError} when the token is malformed or the key no longer matches
186
+ */
187
+ export function open(token) {
188
+ if (typeof token !== 'string' || !token.startsWith('v1.')) {
189
+ throw new MailKeyError('Stored password is not in a recognised format.');
190
+ }
191
+
192
+ const [, sealedWith, saltPart, ivPart, tagPart, ctPart] = token.split('.');
193
+ if (!saltPart || !ivPart || !tagPart || !ctPart) {
194
+ throw new MailKeyError('Stored password is malformed.');
195
+ }
196
+
197
+ const {secret, source} = resolveKeySource();
198
+ const key = deriveKey(secret, source, Buffer.from(saltPart, 'base64url'));
199
+
200
+ try {
201
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivPart, 'base64url'));
202
+ decipher.setAuthTag(Buffer.from(tagPart, 'base64url'));
203
+ return Buffer.concat([
204
+ decipher.update(Buffer.from(ctPart, 'base64url')),
205
+ decipher.final()
206
+ ]).toString('utf8');
207
+ } catch {
208
+ const moved = sealedWith !== source;
209
+ throw new MailKeyError(
210
+ moved
211
+ ? `This password was saved using the ${describeSource(sealedWith)} and the server is now using the ${describeSource(source)}. Re-enter the password to save it against the new key.`
212
+ : 'Stored password could not be decrypted - the encryption key has changed. Re-enter the password.',
213
+ {sealedWith, availableFrom: source}
214
+ );
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Human-readable name for a key source, for error messages.
220
+ *
221
+ * @param {string} source
222
+ * @returns {string}
223
+ */
224
+ export function describeSource(source) {
225
+ if (source === 'env-mail') return 'MAIL_SECRET environment variable';
226
+ if (source === 'env-instance') return 'INSTANCE_SECRET environment variable';
227
+ if (source === 'local') return 'plugin-local key file';
228
+ return 'unknown key source';
229
+ }
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Sending, and keeping a copy where the rest of your clients will find it.
3
+ *
4
+ * The message is built once, as raw RFC822, and that same buffer is both sent
5
+ * and appended to Sent. Building it twice - once for the transport and once
6
+ * for the copy - is how a Sent folder ends up holding something subtly unlike
7
+ * what the recipient received.
8
+ *
9
+ * @module _lib/mail/send
10
+ */
11
+ import {createRequire} from 'node:module';
12
+
13
+ import nodemailer from 'nodemailer';
14
+
15
+ import {bareAddress, formatFrom} from '../admin/mail/identity.js';
16
+ import {normalisePriority, priorityHeaders} from './priority.js';
17
+
18
+ // MailComposer is not on nodemailer's public export, but it is the piece that
19
+ // turns a message object into the bytes that go on the wire - which is what a
20
+ // faithful Sent copy needs.
21
+ const MailComposer = createRequire(import.meta.url)('nodemailer/lib/mail-composer');
22
+
23
+ /**
24
+ * Turn an address list into what nodemailer expects.
25
+ *
26
+ * @param {Array<{name?: string, address?: string}>|string|undefined} list
27
+ * @returns {object[]}
28
+ */
29
+ function addresses(list) {
30
+ if (!list) return [];
31
+ if (typeof list === 'string') {
32
+ return list.split(',').map(part => ({address: part.trim()})).filter(a => a.address);
33
+ }
34
+ return list
35
+ .map(entry => (typeof entry === 'string' ? {address: entry.trim()} : entry))
36
+ .filter(entry => entry?.address);
37
+ }
38
+
39
+ /**
40
+ * Check a draft is worth trying to send.
41
+ *
42
+ * @param {object} draft
43
+ * @returns {{ok: true} | {ok: false, error: string}}
44
+ */
45
+ export function validateDraft(draft, {maxAttachmentBytes = 20_000_000} = {}) {
46
+ const to = addresses(draft?.to);
47
+ const cc = addresses(draft?.cc);
48
+ const bcc = addresses(draft?.bcc);
49
+
50
+ if (!to.length && !cc.length && !bcc.length) {
51
+ return {ok: false, error: 'A message needs at least one recipient.'};
52
+ }
53
+
54
+ const bad = [...to, ...cc, ...bcc]
55
+ .map(a => a.address)
56
+ .filter(address => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address));
57
+ if (bad.length) {
58
+ return {ok: false, error: `Not a valid address: ${bad.join(', ')}`};
59
+ }
60
+
61
+ if (!String(draft?.body ?? '').trim() && !String(draft?.subject ?? '').trim()) {
62
+ return {ok: false, error: 'A message needs a subject or a body.'};
63
+ }
64
+
65
+ const attachments = draft?.attachments ?? [];
66
+ if (!Array.isArray(attachments)) {
67
+ return {ok: false, error: 'Attachments must be a list.'};
68
+ }
69
+
70
+ let total = 0;
71
+ for (const file of attachments) {
72
+ if (!file?.filename || typeof file.content !== 'string') {
73
+ return {ok: false, error: 'Every attachment needs a filename and its content.'};
74
+ }
75
+ // Base64 length maps to bytes without decoding it first, which means
76
+ // an oversized payload is refused before it is turned into memory.
77
+ total += Math.floor(file.content.length * 3 / 4);
78
+ }
79
+
80
+ if (total > maxAttachmentBytes) {
81
+ const mb = n => `${(n / 1_000_000).toFixed(1)}MB`;
82
+ return {
83
+ ok: false,
84
+ error: `Attachments total ${mb(total)}, over the ${mb(maxAttachmentBytes)} limit. Most providers refuse around 25MB, so this is better refused here than bounced later.`
85
+ };
86
+ }
87
+
88
+ return {ok: true, attachmentBytes: total};
89
+ }
90
+
91
+ /**
92
+ * Build the raw bytes of a message.
93
+ *
94
+ * `from` is the header value, display name and all. The envelope address is
95
+ * the caller's business, not this function's - see `sendMessage`.
96
+ *
97
+ * @param {object} draft
98
+ * @param {string} from
99
+ * @param {{replyTo?: string, readReceiptTo?: string, headers?: object}} [options]
100
+ * @returns {Promise<Buffer>}
101
+ */
102
+ function buildRaw(draft, from, {replyTo = '', readReceiptTo = '', headers = {}} = {}) {
103
+ const composer = new MailComposer({
104
+ from,
105
+ // Only when the mailbox asks for one. An absent Reply-To means
106
+ // "answer the From address", which is what every client already does -
107
+ // setting it to the From address says the same thing in a header that
108
+ // some mailing lists then rewrite.
109
+ ...(replyTo ? {replyTo} : {}),
110
+ to: addresses(draft.to),
111
+ cc: addresses(draft.cc),
112
+ bcc: addresses(draft.bcc),
113
+ subject: draft.subject ?? '',
114
+ // Set by the caller for a QUEUED send, so that a retry after a
115
+ // failure carries the same id as the attempt before it.
116
+ //
117
+ // The claim in the store makes a send exactly-once up to the moment
118
+ // the message leaves; it cannot make SMTP exactly-once. A connection
119
+ // dropped between the terminating dot and the 250 looks identical to
120
+ // a message that was never accepted, and the only honest options are
121
+ // to risk losing it or to risk sending it twice. This takes the
122
+ // second and makes the duplicate detectable: both copies carry one
123
+ // Message-ID, which is what every receiver deduplicates on.
124
+ ...(draft.messageId ? {messageId: draft.messageId} : {}),
125
+ text: draft.body ?? '',
126
+ // Both parts when there is formatting, so the message is
127
+ // multipart/alternative: clients that render HTML get it, and those
128
+ // that do not - and anyone reading in a terminal - still get something
129
+ // written for them rather than a wall of tags.
130
+ ...(String(draft.html ?? '').trim() ? {html: draft.html} : {}),
131
+ // Threading. Present only for a reply - a forward deliberately carries
132
+ // neither, so it does not file itself into a conversation its new
133
+ // recipient has never seen.
134
+ ...(draft.inReplyTo ? {inReplyTo: draft.inReplyTo} : {}),
135
+ ...(draft.references?.length ? {references: draft.references} : {}),
136
+ // All three priority headers, or none. A recipient's client reads
137
+ // whichever one it knows, and setting only one is how a message looks
138
+ // urgent in Outlook and ordinary everywhere else. Normal sends none:
139
+ // the absence of the header IS normal.
140
+ headers: {
141
+ ...priorityHeaders(normalisePriority(draft.priority)),
142
+ // Both spellings when a receipt is wanted. `Disposition-Notification-To`
143
+ // is the standard one (RFC 8098) and `Return-Receipt-To` is what
144
+ // older clients read; sending one and not the other is how a
145
+ // receipt request works in half the world. A receipt is a request,
146
+ // not a mechanism - every client is free to ignore it, and many
147
+ // do, which is worth knowing before relying on one.
148
+ ...(readReceiptTo
149
+ ? {
150
+ 'Disposition-Notification-To': readReceiptTo,
151
+ 'Return-Receipt-To': readReceiptTo
152
+ }
153
+ : {}),
154
+ // Anything the caller must put on the wire itself. Today that is
155
+ // the away message announcing itself as automatic, which is what
156
+ // stops the responder at the far end answering it back.
157
+ ...headers
158
+ },
159
+ attachments: (draft.attachments ?? []).map(file => ({
160
+ filename: file.filename,
161
+ content: Buffer.from(file.content, 'base64'),
162
+ contentType: file.contentType
163
+ }))
164
+ });
165
+
166
+ return new Promise((resolve, reject) => {
167
+ composer.compile().build((err, message) => (err ? reject(err) : resolve(message)));
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Thrown to skip filing a copy, rather than checked around the whole block.
173
+ *
174
+ * The filing is already wrapped in a try/catch that treats any failure as
175
+ * "delivered but not filed", which is exactly the outcome wanted here - so
176
+ * the cheapest correct way in is through the same door.
177
+ */
178
+ class SkipFiling extends Error {}
179
+
180
+ /**
181
+ * Send a draft, and file a copy.
182
+ *
183
+ * The copy is best effort. A message that was delivered but could not be
184
+ * filed is still delivered, and failing the send at that point would invite
185
+ * someone to send it a second time.
186
+ *
187
+ * @param {{smtp: object, draft: object, pool: object, poolKey: string,
188
+ * connection: object, sentFolder: string|null}} options
189
+ * @returns {Promise<{messageId: string, accepted: string[], rejected: string[], filedTo: string|null, fileError: string|null}>}
190
+ */
191
+ export async function sendMessage({
192
+ smtp, draft, pool, poolKey, connection, sentFolder = null, allowInsecureTLS = false,
193
+ replaces = null, draftsFolder = null, headers = {}, fileCopy = true
194
+ }) {
195
+ // The header carries the display name; the envelope never can. They are
196
+ // built from the same stored address so they cannot name two different
197
+ // mailboxes, but they are not the same string.
198
+ const raw = await buildRaw(draft, formatFrom(smtp.from, smtp.fromName), {
199
+ replyTo: smtp.replyTo,
200
+ // Asked for per message, addressed to wherever replies would go -
201
+ // which is the Reply-To when there is one and the From otherwise.
202
+ // A receipt sent to an address the sender does not read is a receipt
203
+ // nobody sees.
204
+ readReceiptTo: draft.readReceipt
205
+ ? (smtp.replyTo || bareAddress(smtp.from))
206
+ : '',
207
+ headers
208
+ });
209
+
210
+ // The same setting that governs incoming TLS governs outgoing: a site
211
+ // running its own mail server with a self-signed certificate has the same
212
+ // problem in both directions, and two switches for one decision is how
213
+ // one of them ends up wrong.
214
+ const transport = nodemailer.createTransport({
215
+ ...smtp.transport,
216
+ tls: {rejectUnauthorized: !allowInsecureTLS}
217
+ });
218
+ const info = await transport.sendMail({
219
+ envelope: {
220
+ // Bare, always. A display name here is not decoration the server
221
+ // ignores - it is a MAIL FROM the server rejects, and `smtp.from`
222
+ // is a free-text field somebody may well have typed a name into.
223
+ from: bareAddress(smtp.from),
224
+ to: [...addresses(draft.to), ...addresses(draft.cc), ...addresses(draft.bcc)]
225
+ .map(a => a.address)
226
+ },
227
+ raw
228
+ });
229
+ transport.close();
230
+
231
+ let filedTo = null;
232
+ let fileError = null;
233
+ try {
234
+ // An away message deliberately does not file. One holiday can produce
235
+ // a hundred of them, and a Sent folder that is mostly "Out of office"
236
+ // is a Sent folder nobody can find anything in - the responder keeps
237
+ // its own log instead.
238
+ if (!fileCopy) throw new SkipFiling();
239
+ const destination = sentFolder ?? await findSentFolder(pool, poolKey, connection);
240
+ if (destination) {
241
+ await pool.withClient(poolKey, connection,
242
+ client => client.append(destination, raw, ['\\Seen']));
243
+ filedTo = destination;
244
+ }
245
+ } catch (err) {
246
+ if (!(err instanceof SkipFiling)) fileError = err.message;
247
+ }
248
+
249
+ // Only once it has actually gone. A draft cleared before the send would
250
+ // be a draft lost to a rejected recipient, and the text only existed
251
+ // there.
252
+ const draftRemoved = replaces ? await discardDraft({
253
+ pool, poolKey, connection, uid: replaces, draftsFolder
254
+ }) : false;
255
+
256
+ return {
257
+ messageId: info.messageId,
258
+ accepted: info.accepted ?? [],
259
+ rejected: info.rejected ?? [],
260
+ filedTo,
261
+ fileError,
262
+ draftRemoved
263
+ };
264
+ }
265
+
266
+ /**
267
+ * Take a superseded draft out of the Drafts folder.
268
+ *
269
+ * Expunged rather than moved to Trash, which is the one place this codebase
270
+ * departs from "deleting means Trash": the draft has just been sent, so the
271
+ * message still exists in Sent, and putting a copy of it in the bin as well
272
+ * leaves someone three copies of one email to reason about.
273
+ *
274
+ * Best-effort. A leftover draft is untidy; a send reported as failed because
275
+ * the tidying did is worse.
276
+ *
277
+ * @param {{pool: object, poolKey: string, connection: object, uid: number,
278
+ * draftsFolder?: string|null}} options
279
+ * @returns {Promise<boolean>}
280
+ */
281
+ async function discardDraft({pool, poolKey, connection, uid, draftsFolder = null}) {
282
+ try {
283
+ const destination = draftsFolder
284
+ ?? await findSpecialFolder(pool, poolKey, connection, '\\Drafts');
285
+ if (!destination) return false;
286
+
287
+ await pool.withMailbox(poolKey, connection, destination, async (client) => {
288
+ await client.messageFlagsAdd({uid: String(uid)}, ['\\Deleted'], {uid: true});
289
+ await client.messageDelete({uid: String(uid)}, {uid: true});
290
+ }, {writable: true});
291
+ return true;
292
+ } catch {
293
+ return false;
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Save a draft to the Drafts folder.
299
+ *
300
+ * Built with the same code that builds a sendable message, so what is stored
301
+ * is what would have gone out. Saved with `\\Draft` so other clients treat it
302
+ * as editable rather than as received mail.
303
+ *
304
+ * A previous version is replaced rather than accumulated: editing a draft four
305
+ * times should leave one draft, not four. The old copy is removed only after
306
+ * the new one is stored, so an interruption loses nothing.
307
+ *
308
+ * @param {{draft: object, from: string, replyTo?: string, pool: object, poolKey: string,
309
+ * connection: object, draftsFolder?: string|null, replaces?: number|null}} options
310
+ * @returns {Promise<{savedTo: string, uid: number|null, replaced: number|null}>}
311
+ */
312
+ export async function saveDraft({
313
+ draft, from, replyTo = '', pool, poolKey, connection, draftsFolder = null, replaces = null
314
+ }) {
315
+ // Written into the draft, not applied when it is eventually sent: a draft
316
+ // opened on the phone should show the same headers it will go out with.
317
+ const raw = await buildRaw(draft, from, {
318
+ replyTo,
319
+ readReceiptTo: draft.readReceipt ? (replyTo || bareAddress(from)) : ''
320
+ });
321
+
322
+ const destination = draftsFolder ?? await findSpecialFolder(pool, poolKey, connection, '\\Drafts');
323
+ if (!destination) {
324
+ throw new Error('This server has no Drafts folder to save into.');
325
+ }
326
+
327
+ const appended = await pool.withClient(poolKey, connection,
328
+ client => client.append(destination, raw, ['\\Draft', '\\Seen']));
329
+
330
+ // Only now is the previous version expendable.
331
+ let replaced = null;
332
+ if (replaces) {
333
+ try {
334
+ await pool.withMailbox(poolKey, connection, destination, async (client) => {
335
+ await client.messageFlagsAdd({uid: String(replaces)}, ['\\Deleted'], {uid: true});
336
+ await client.messageDelete({uid: String(replaces)}, {uid: true});
337
+ }, {writable: true});
338
+ replaced = replaces;
339
+ } catch {
340
+ // The new draft is saved; a leftover old one is untidy, not broken.
341
+ }
342
+ }
343
+
344
+ return {savedTo: destination, uid: appended?.uid ?? null, replaced};
345
+ }
346
+
347
+ /**
348
+ * Find a folder by its SPECIAL-USE attribute.
349
+ *
350
+ * @param {object} pool
351
+ * @param {string} poolKey
352
+ * @param {object} connection
353
+ * @param {string} use
354
+ * @returns {Promise<string|null>}
355
+ */
356
+ async function findSpecialFolder(pool, poolKey, connection, use) {
357
+ const boxes = await pool.withClient(poolKey, connection, client => client.list());
358
+ return boxes.find(box => box.specialUse === use)?.path ?? null;
359
+ }
360
+
361
+ /**
362
+ * Where this server keeps sent mail.
363
+ *
364
+ * By SPECIAL-USE, never by name: it is localised on plenty of servers, and
365
+ * guessing wrong means the copy lands in a folder nobody looks at.
366
+ *
367
+ * @param {object} pool
368
+ * @param {string} poolKey
369
+ * @param {object} connection
370
+ * @returns {Promise<string|null>}
371
+ */
372
+ async function findSentFolder(pool, poolKey, connection) {
373
+ return findSpecialFolder(pool, poolKey, connection, '\\Sent');
374
+ }
375
+
376
+ /**
377
+ * Try the outgoing server without sending anything.
378
+ *
379
+ * @param {object} transportConfig
380
+ * @returns {Promise<{ok: boolean, error?: string}>}
381
+ */
382
+ export async function verifySmtp(transportConfig, allowInsecureTLS = false) {
383
+ const transport = nodemailer.createTransport({
384
+ ...transportConfig,
385
+ connectionTimeout: 10_000,
386
+ tls: {rejectUnauthorized: !allowInsecureTLS}
387
+ });
388
+ try {
389
+ await transport.verify();
390
+ return {ok: true};
391
+ } catch (err) {
392
+ return {ok: false, error: err.message};
393
+ } finally {
394
+ transport.close();
395
+ }
396
+ }