domma-cms 0.54.2 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CLAUDE.md +151 -77
  2. package/README.md +4 -4
  3. package/admin/css/admin.css +1 -1
  4. package/admin/dist/domma/domma-tools.css +3 -3
  5. package/admin/dist/domma/domma-tools.min.js +3 -3
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +3 -3
  8. package/admin/js/lib/page-picker.js +1 -0
  9. package/admin/js/lib/plugin-accent.js +1 -0
  10. package/admin/js/lib/plugin-chrome.js +1 -0
  11. package/admin/js/lib/shortcode-context-menu.js +2 -2
  12. package/admin/js/lib/sidebar-grouping.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  14. package/admin/js/lib/sidebar-renderer.js +4 -4
  15. package/admin/js/lib/slideover-resizable.js +1 -0
  16. package/admin/js/templates/context-menu-editor.html +212 -0
  17. package/admin/js/templates/context-menus.html +16 -0
  18. package/admin/js/templates/menu-editor.html +19 -25
  19. package/admin/js/templates/plugin-code.html +1 -1
  20. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  21. package/admin/js/templates/settings.html +27 -97
  22. package/admin/js/templates/theme.html +173 -0
  23. package/admin/js/views/context-menu-editor.js +55 -0
  24. package/admin/js/views/context-menus.js +5 -0
  25. package/admin/js/views/form-editor.js +7 -7
  26. package/admin/js/views/index.js +1 -1
  27. package/admin/js/views/menu-editor.js +13 -13
  28. package/admin/js/views/plugin-marketplace.js +1 -1
  29. package/admin/js/views/plugins.js +25 -23
  30. package/admin/js/views/search.js +1 -0
  31. package/admin/js/views/settings.js +3 -3
  32. package/admin/js/views/theme.js +1 -0
  33. package/bin/cli.js +11 -2
  34. package/bin/lib/smtp-defaults.js +53 -0
  35. package/bin/update.js +13 -2
  36. package/config/menus/admin-sidebar.json +129 -23
  37. package/config/plugins.json +5 -5
  38. package/config/search.json +13 -0
  39. package/config/theme.json +18 -0
  40. package/package.json +12 -4
  41. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  42. package/plugins/_lib/admin/mail/contacts.js +301 -0
  43. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  44. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  45. package/plugins/_lib/admin/mail/identity.js +480 -0
  46. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  47. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  48. package/plugins/_lib/admin/mail/mail.css +1 -0
  49. package/plugins/_lib/admin/mail/mail.html +71 -0
  50. package/plugins/_lib/admin/mail/panes.js +253 -0
  51. package/plugins/_lib/admin/mail/reader-view.js +4453 -0
  52. package/plugins/_lib/admin/mail/resizable.js +26 -0
  53. package/plugins/_lib/admin/mail/rules.js +343 -0
  54. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  55. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  56. package/plugins/_lib/admin/mail/templates.js +238 -0
  57. package/plugins/_lib/admin/mail/threads.js +200 -0
  58. package/plugins/_lib/admin/mail/vacation.js +269 -0
  59. package/plugins/_lib/admin/ui/help.css +1 -0
  60. package/plugins/_lib/admin/ui/help.js +174 -0
  61. package/plugins/_lib/admin/ui/resizable.js +151 -0
  62. package/plugins/_lib/dataStore.js +117 -0
  63. package/plugins/_lib/mail/accounts.js +919 -0
  64. package/plugins/_lib/mail/bodyTokens.js +101 -0
  65. package/plugins/_lib/mail/compose.js +256 -0
  66. package/plugins/_lib/mail/defaults.js +57 -0
  67. package/plugins/_lib/mail/diagnostics.js +154 -0
  68. package/plugins/_lib/mail/envelope.js +161 -0
  69. package/plugins/_lib/mail/folders.js +192 -0
  70. package/plugins/_lib/mail/handoff.js +274 -0
  71. package/plugins/_lib/mail/imapPool.js +293 -0
  72. package/plugins/_lib/mail/mbox.js +74 -0
  73. package/plugins/_lib/mail/pollSchedule.js +79 -0
  74. package/plugins/_lib/mail/poller.js +135 -0
  75. package/plugins/_lib/mail/priority.js +138 -0
  76. package/plugins/_lib/mail/readRoutes.js +680 -0
  77. package/plugins/_lib/mail/render.js +291 -0
  78. package/plugins/_lib/mail/ruleRunner.js +152 -0
  79. package/plugins/_lib/mail/scheduler.js +254 -0
  80. package/plugins/_lib/mail/secretbox.js +229 -0
  81. package/plugins/_lib/mail/send.js +396 -0
  82. package/plugins/_lib/mail/store.js +1002 -0
  83. package/plugins/_lib/mail/sync.js +292 -0
  84. package/plugins/_lib/mail/syncPlan.js +126 -0
  85. package/plugins/_lib/mail/syncSelection.js +82 -0
  86. package/plugins/_lib/mail/unsubscribe.js +183 -0
  87. package/plugins/_lib/mail/vacationRunner.js +114 -0
  88. package/plugins/_lib/mail/write.js +473 -0
  89. package/plugins/_lib/schemaSync.js +83 -0
  90. package/plugins/_template/admin/css/index.css +0 -0
  91. package/plugins/_template/admin/templates/index.html +4 -4
  92. package/plugins/_template/admin/views/index.js +7 -0
  93. package/plugins/analytics/admin/css/index.css +1 -0
  94. package/plugins/analytics/admin/templates/analytics.html +22 -13
  95. package/plugins/analytics/plugin.json +3 -0
  96. package/plugins/blog/admin/css/index.css +1 -0
  97. package/plugins/blog/admin/templates/blog.html +30 -18
  98. package/plugins/blog/admin/templates/categories.html +2 -2
  99. package/plugins/blog/admin/templates/comments.html +2 -2
  100. package/plugins/blog/admin/templates/post-editor.html +34 -34
  101. package/plugins/blog/admin/templates/settings.html +6 -3
  102. package/plugins/blog/admin/views/blog.js +8 -5
  103. package/plugins/blog/admin/views/categories.js +5 -10
  104. package/plugins/blog/admin/views/comments.js +5 -5
  105. package/plugins/blog/admin/views/post-editor.js +39 -20
  106. package/plugins/blog/admin/views/settings.js +52 -50
  107. package/plugins/blog/collections/categories/schema.json +7 -6
  108. package/plugins/blog/collections/comments/schema.json +11 -10
  109. package/plugins/blog/collections/posts/schema.json +14 -13
  110. package/plugins/blog/plugin.js +36 -13
  111. package/plugins/blog/plugin.json +13 -5
  112. package/plugins/blog/plugin.public.js +312 -0
  113. package/plugins/contacts/admin/css/index.css +1 -0
  114. package/plugins/contacts/admin/templates/contacts.html +128 -0
  115. package/plugins/contacts/admin/views/contacts.js +237 -4
  116. package/plugins/contacts/collections/user-contacts/schema.json +108 -0
  117. package/plugins/contacts/plugin.js +214 -27
  118. package/plugins/contacts/plugin.json +4 -1
  119. package/plugins/invoice/admin/css/index.css +1 -0
  120. package/plugins/invoice/admin/templates/editor.html +140 -49
  121. package/plugins/invoice/admin/templates/index.html +153 -23
  122. package/plugins/invoice/admin/templates/issuers.html +2 -5
  123. package/plugins/invoice/admin/templates/receivers.html +2 -5
  124. package/plugins/invoice/admin/views/contacts-source.js +266 -0
  125. package/plugins/invoice/admin/views/editor.js +366 -199
  126. package/plugins/invoice/admin/views/export.js +199 -0
  127. package/plugins/invoice/admin/views/help-content.js +61 -0
  128. package/plugins/invoice/admin/views/index.js +582 -94
  129. package/plugins/invoice/admin/views/issuers.js +24 -17
  130. package/plugins/invoice/admin/views/media.js +172 -0
  131. package/plugins/invoice/admin/views/party-view.js +305 -67
  132. package/plugins/invoice/admin/views/payments.js +127 -0
  133. package/plugins/invoice/admin/views/print.js +130 -0
  134. package/plugins/invoice/admin/views/receivers.js +49 -16
  135. package/plugins/invoice/admin/views/send.js +212 -0
  136. package/plugins/invoice/admin/views/settings.js +594 -0
  137. package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
  138. package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
  139. package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
  140. package/plugins/invoice/collections/invoices/schema.json +19 -13
  141. package/plugins/invoice/config.js +27 -6
  142. package/plugins/invoice/pdf.js +164 -0
  143. package/plugins/invoice/plugin.js +1217 -44
  144. package/plugins/invoice/plugin.json +10 -9
  145. package/plugins/invoice/templates/_base.css +1 -0
  146. package/plugins/invoice/templates/classic-nologo.html +100 -0
  147. package/plugins/invoice/templates/classic.html +91 -0
  148. package/plugins/invoice/templates/invoice-print.html +24 -0
  149. package/plugins/invoice/templates/minimal.html +99 -0
  150. package/plugins/invoice/templates/modern-nologo.html +114 -0
  151. package/plugins/invoice/templates/modern.html +113 -0
  152. package/plugins/invoice/templates/templates.json +11 -0
  153. package/plugins/mail-reader/admin/views/mail.js +19 -0
  154. package/plugins/mail-reader/config.js +7 -0
  155. package/plugins/mail-reader/plugin.js +48 -0
  156. package/plugins/mail-reader/plugin.json +33 -0
  157. package/plugins/notes/admin/views/notes.js +1 -1
  158. package/plugins/notes/plugin.json +2 -2
  159. package/plugins/surveys/lib/audience.js +37 -0
  160. package/plugins/surveys/lib/campaigns.js +43 -0
  161. package/plugins/surveys/lib/ledger.js +110 -0
  162. package/plugins/surveys/lib/sending.js +106 -0
  163. package/plugins/surveys/lib/stats.js +62 -0
  164. package/plugins/surveys/lib/submit.js +95 -0
  165. package/plugins/surveys/lib/tokens.js +28 -0
  166. package/plugins/surveys/plugin.public.js +149 -0
  167. package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
  168. package/public/css/forms.css +1 -1
  169. package/public/css/menu-highlight.css +1 -1
  170. package/public/css/search.css +1 -0
  171. package/public/css/site.css +1 -1
  172. package/public/js/collection-context.js +2 -2
  173. package/public/js/context-menus.js +1 -0
  174. package/public/js/form-logic-engine.js +1 -1
  175. package/public/js/forms.js +2 -2
  176. package/public/js/menu-decor.mjs +1 -1
  177. package/public/js/search.js +1 -0
  178. package/public/js/site.js +1 -1
  179. package/scripts/build.js +37 -3
  180. package/scripts/copy-domma.js +48 -0
  181. package/scripts/seed.js +1996 -0
  182. package/scripts/setup.js +8 -0
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/context-menus.js +104 -0
  185. package/server/routes/api/forms.js +42 -3
  186. package/server/routes/api/notifications.js +69 -19
  187. package/server/routes/api/plugins.js +50 -6
  188. package/server/routes/api/search.js +43 -0
  189. package/server/routes/api/theme.js +69 -0
  190. package/server/routes/public.js +42 -7
  191. package/server/server.js +74 -0
  192. package/server/services/adapters/FileAdapter.js +6 -1
  193. package/server/services/content.js +26 -0
  194. package/server/services/contextMenus.js +477 -0
  195. package/server/services/email.js +29 -3
  196. package/server/services/health.js +23 -2
  197. package/server/services/markdown.js +70 -9
  198. package/server/services/menuRender.js +28 -4
  199. package/server/services/menus.js +25 -1
  200. package/server/services/permissionRegistry.js +24 -0
  201. package/server/services/pluginFiles.js +52 -11
  202. package/server/services/plugins.js +229 -6
  203. package/server/services/renderer.js +148 -22
  204. package/server/services/roles.js +1 -1
  205. package/server/services/search-migration.js +82 -0
  206. package/server/services/search.js +413 -0
  207. package/server/services/sidebar-migration.js +1 -0
  208. package/server/services/themeSettings.js +541 -0
  209. package/server/services/users.js +8 -0
  210. package/server/templates/page.html +4 -2
  211. package/plugins/contacts/data/contacts.json +0 -20
  212. package/plugins/notes/data/notes.json +0 -1
  213. package/plugins/site-search/admin/views/site-search.js +0 -116
  214. package/plugins/site-search/config.js +0 -15
  215. package/plugins/site-search/plugin.js +0 -188
  216. package/plugins/site-search/plugin.json +0 -40
  217. package/plugins/site-search/public/inject-body.html +0 -17
  218. package/plugins/site-search/public/inject-head.html +0 -1
  219. package/plugins/site-search/public/search.css +0 -1
  220. package/plugins/site-search/public/search.js +0 -1
  221. package/plugins/todo/data/todos.json +0 -1
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The shape the mirror holds one message in.
3
+ *
4
+ * Its own module because two things write mirror rows - the sync engine on a
5
+ * poll, and a move ingesting the message it just placed in the destination -
6
+ * and two definitions of "a stored message" would drift. A row written by one
7
+ * path and read by the other has to be indistinguishable.
8
+ *
9
+ * @module _lib/mail/envelope
10
+ */
11
+ import {threadRoot} from '../admin/mail/threads.js';
12
+ import {looksAutomated} from '../admin/mail/vacation.js';
13
+ import {readPriority} from './priority.js';
14
+
15
+ /**
16
+ * What a fetch asks for when it wants a full envelope.
17
+ *
18
+ * Priority is not IN the envelope - there is no such field - so the three
19
+ * headers that carry it are fetched alongside. A handful of bytes per message,
20
+ * and the alternative is a second round trip per row.
21
+ *
22
+ * `references` and `list-id` are here for the same reason. IMAP's ENVELOPE
23
+ * carries `Message-ID` and `In-Reply-To` but not `References`, which is the
24
+ * header a conversation is actually reconstructed from; and `List-Id` is the
25
+ * one thing that reliably distinguishes a mailing list from a person, which
26
+ * is most of what filing rules are written about.
27
+ */
28
+ export const ENVELOPE_QUERY = {
29
+ uid: true,
30
+ envelope: true,
31
+ flags: true,
32
+ size: true,
33
+ bodyStructure: true,
34
+ headers: [
35
+ 'x-priority', 'importance', 'x-msmail-priority', 'references', 'list-id',
36
+ // What tells an away message not to answer. Fetched here rather than
37
+ // per message later: the responder runs over new mail on every poll,
38
+ // and a second round trip per message to read three headers would be
39
+ // the most expensive part of the feature.
40
+ 'auto-submitted', 'precedence', 'return-path'
41
+ ]
42
+ };
43
+
44
+ /**
45
+ * Does this structure carry something a reader would call an attachment?
46
+ *
47
+ * @param {object} node
48
+ * @returns {boolean}
49
+ */
50
+ export function hasAttachment(node) {
51
+ if (!node) return false;
52
+ if (node.disposition === 'attachment') return true;
53
+ return (node.childNodes ?? []).some(hasAttachment);
54
+ }
55
+
56
+ /**
57
+ * Turn an ImapFlow message into the shape the store holds.
58
+ *
59
+ * @param {object} message
60
+ * @returns {object}
61
+ */
62
+ export function toRecord(message) {
63
+ const flags = [...(message.flags ?? [])];
64
+ const envelope = message.envelope ?? {};
65
+ const addresses = list => (list ?? []).map(a => ({name: a.name ?? '', address: a.address ?? ''}));
66
+ const headers = parseHeaderBlock(message.headers);
67
+
68
+ const record = {
69
+ uid: message.uid,
70
+ seq: message.seq,
71
+ subject: envelope.subject ?? '',
72
+ from: addresses(envelope.from),
73
+ to: addresses(envelope.to),
74
+ // Carried so an away message can tell "addressed to me" from
75
+ // "delivered to me" - a Bcc'd blast is exactly what must not be
76
+ // answered five hundred times.
77
+ cc: addresses(envelope.cc),
78
+ date: envelope.date ?? null,
79
+ size: message.size ?? 0,
80
+ flags,
81
+ seen: flags.includes('\\Seen'),
82
+ flagged: flags.includes('\\Flagged'),
83
+ answered: flags.includes('\\Answered'),
84
+ hasAttachment: hasAttachment(message.bodyStructure),
85
+ messageId: envelope.messageId ?? null,
86
+ inReplyTo: envelope.inReplyTo ?? null,
87
+ // Stored as it arrived - a space-separated string - and normalised
88
+ // by whoever reads it. Splitting here would make the stored shape
89
+ // depend on which server sent it.
90
+ references: typeof headers.get === 'function'
91
+ ? (headers.get('references') ?? null)
92
+ : (headers.references ?? null),
93
+ // The bare list id, without the angle brackets and without the
94
+ // descriptive part some senders put in front of it, so a rule can be
95
+ // written about "github.com" rather than about
96
+ // "My List <list.github.com>".
97
+ listId: readListId(headers),
98
+ // Decided once, at fetch time, so the responder is a field read rather
99
+ // than a header parse. This single boolean is what closes the mail
100
+ // loop: our own replies carry `Auto-Submitted: auto-replied`, so they
101
+ // arrive at the far end already marked as not worth answering.
102
+ automated: looksAutomated(headers),
103
+ priority: readPriority(headers)
104
+ };
105
+
106
+ // Computed here rather than by the caller, so every writer of a mirror
107
+ // row agrees about it - the sync engine and the move that ingests a
108
+ // message it has just placed are two different code paths writing the
109
+ // same collection.
110
+ return {...record, threadRoot: threadRoot(record)};
111
+ }
112
+
113
+ /**
114
+ * The List-Id header, reduced to the bit worth matching on.
115
+ *
116
+ * `List-Id: Dev Discussion <dev.example.com>` answers `dev.example.com`. A
117
+ * sender that gives only the bracketed part, or only a bare token, answers
118
+ * that instead.
119
+ *
120
+ * @param {Map|object} headers
121
+ * @returns {string}
122
+ */
123
+ export function readListId(headers) {
124
+ const raw = typeof headers?.get === 'function'
125
+ ? headers.get('list-id')
126
+ : headers?.['list-id'];
127
+ const value = String(raw ?? '').trim();
128
+ if (!value) return '';
129
+ const angled = value.match(/<([^>]+)>/);
130
+ return (angled ? angled[1] : value).trim();
131
+ }
132
+
133
+ /**
134
+ * Turn imapflow's raw header buffer into something `readPriority` can read.
135
+ *
136
+ * A `headers` fetch hands back the raw bytes of the requested block, not a
137
+ * parsed map - so this does the one line of parsing needed, rather than
138
+ * pulling in a MIME parser to read three fields.
139
+ *
140
+ * @param {Buffer|string|Map|object|null} raw
141
+ * @returns {Object.<string, string>}
142
+ */
143
+ export function parseHeaderBlock(raw) {
144
+ if (!raw) return {};
145
+ // Already a map or object from another path - hand it straight on.
146
+ if (typeof raw.get === 'function' || (typeof raw === 'object' && !Buffer.isBuffer(raw)
147
+ && typeof raw !== 'string')) {
148
+ return raw;
149
+ }
150
+
151
+ const out = {};
152
+ // Unfold first: a header may continue onto the next line indented, and
153
+ // splitting naively would lose the continuation.
154
+ const text = String(raw).replace(/\r?\n[ \t]+/g, ' ');
155
+ for (const line of text.split(/\r?\n/)) {
156
+ const at = line.indexOf(':');
157
+ if (at === -1) continue;
158
+ out[line.slice(0, at).trim().toLowerCase()] = line.slice(at + 1).trim();
159
+ }
160
+ return out;
161
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Creating, renaming and deleting mailboxes.
3
+ *
4
+ * The IMAP calls are one line each; what is worth having in its own module is
5
+ * everything around them. A mailbox path is a string with a server-chosen
6
+ * delimiter in it, so "create a folder called X inside Y" is path arithmetic
7
+ * that goes wrong quietly - a name containing the delimiter silently creates a
8
+ * nested folder, and renaming a parent moves every child with it whether the
9
+ * caller expected that or not.
10
+ *
11
+ * Pure, so the arithmetic and the refusals can be tested without a server.
12
+ *
13
+ * @module _lib/mail/folders
14
+ */
15
+
16
+ /**
17
+ * Folders the server has told us it uses for something.
18
+ *
19
+ * Deleting or renaming one of these does not tidy a mailbox, it breaks it:
20
+ * the server files sent mail in Sent by SPECIAL-USE, and a client that cannot
21
+ * find Trash falls back to expunging.
22
+ */
23
+ const PROTECTED_USES = new Set([
24
+ '\\Inbox', '\\Sent', '\\Drafts', '\\Trash', '\\Junk', '\\Archive', '\\All', '\\Flagged'
25
+ ]);
26
+
27
+ /** The longest a single folder name may be. Servers vary; this is generous. */
28
+ const MAX_NAME = 100;
29
+
30
+ /**
31
+ * Is this a usable name for one folder?
32
+ *
33
+ * The delimiter is refused rather than escaped. Someone typing `Work.2026`
34
+ * into a "folder name" box means a folder with a dot in it, but the server
35
+ * would read it as `2026` inside `Work` - so the honest answer is to say the
36
+ * character cannot be used rather than to quietly create something else.
37
+ *
38
+ * @param {string} name
39
+ * @param {string} delimiter - the server's hierarchy separator
40
+ * @returns {{ok: true} | {ok: false, error: string}}
41
+ */
42
+ export function validateFolderName(name, delimiter) {
43
+ const trimmed = String(name ?? '').trim();
44
+ if (!trimmed) return {ok: false, error: 'A folder needs a name.'};
45
+ if (trimmed.length > MAX_NAME) {
46
+ return {ok: false, error: `A folder name cannot be longer than ${MAX_NAME} characters.`};
47
+ }
48
+ if (delimiter && trimmed.includes(delimiter)) {
49
+ return {
50
+ ok: false,
51
+ error: `A folder name cannot contain "${delimiter}" - that is how this server separates folders. Create it inside another folder instead.`
52
+ };
53
+ }
54
+ // Control characters are not typed on purpose and break the protocol line.
55
+ if (/[\u0000-\u001f\u007f]/.test(trimmed)) {
56
+ return {ok: false, error: 'A folder name cannot contain control characters.'};
57
+ }
58
+ return {ok: true};
59
+ }
60
+
61
+ /**
62
+ * The full path a new folder would have.
63
+ *
64
+ * @param {string|null} parentPath - null or '' for a top-level folder
65
+ * @param {string} name
66
+ * @param {string} delimiter
67
+ * @returns {string}
68
+ */
69
+ export function buildFolderPath(parentPath, name, delimiter) {
70
+ const leaf = String(name ?? '').trim();
71
+ const parent = String(parentPath ?? '').trim();
72
+ if (!parent) return leaf;
73
+ // A server with no delimiter has a flat namespace, so there is no "inside".
74
+ if (!delimiter) return leaf;
75
+ return `${parent}${delimiter}${leaf}`;
76
+ }
77
+
78
+ /**
79
+ * The path a folder would have after being renamed, keeping it where it is.
80
+ *
81
+ * Renaming is a MOVE in IMAP - there is no rename verb - so the new path has
82
+ * to carry the same parent, or "rename Contracts to Deals" would move it to
83
+ * the top level.
84
+ *
85
+ * @param {string} path - the folder's current full path
86
+ * @param {string} name - the new leaf name
87
+ * @param {string} delimiter
88
+ * @returns {string}
89
+ */
90
+ export function renameTarget(path, name, delimiter) {
91
+ const current = String(path ?? '');
92
+ const cut = delimiter ? current.lastIndexOf(delimiter) : -1;
93
+ const parent = cut === -1 ? '' : current.slice(0, cut);
94
+ return buildFolderPath(parent, name, delimiter);
95
+ }
96
+
97
+ /**
98
+ * May this folder be renamed or deleted?
99
+ *
100
+ * @param {{path: string, specialUse?: string|null}} folder
101
+ * @param {{action?: string}} [options]
102
+ * @returns {{ok: true} | {ok: false, error: string}}
103
+ */
104
+ export function canModifyFolder(folder, {action = 'change'} = {}) {
105
+ const path = String(folder?.path ?? '').trim();
106
+ if (!path) return {ok: false, error: 'No folder was given.'};
107
+
108
+ // INBOX is INBOX on every server, whatever it reports for SPECIAL-USE.
109
+ if (path.toUpperCase() === 'INBOX') {
110
+ return {ok: false, error: `The Inbox cannot be ${action}d.`};
111
+ }
112
+ if (folder?.specialUse && PROTECTED_USES.has(folder.specialUse)) {
113
+ return {
114
+ ok: false,
115
+ error: `This server uses that folder for ${describeUse(folder.specialUse)}, so it cannot be ${action}d.`
116
+ };
117
+ }
118
+ return {ok: true};
119
+ }
120
+
121
+ /**
122
+ * A SPECIAL-USE attribute in words.
123
+ *
124
+ * @param {string} use
125
+ * @returns {string}
126
+ */
127
+ function describeUse(use) {
128
+ return {
129
+ '\\Sent': 'sent mail',
130
+ '\\Drafts': 'drafts',
131
+ '\\Trash': 'deleted mail',
132
+ '\\Junk': 'spam',
133
+ '\\Archive': 'archived mail',
134
+ '\\All': 'all mail',
135
+ '\\Flagged': 'starred mail'
136
+ }[use] ?? 'something';
137
+ }
138
+
139
+ /**
140
+ * The folders that would disappear along with this one.
141
+ *
142
+ * IMAP deletes one mailbox at a time, and a server may refuse to delete a
143
+ * parent that still has children - but more importantly, someone deleting
144
+ * `Work` should be told it takes `Work.Contracts.2026` with it BEFORE they
145
+ * agree to it, not after.
146
+ *
147
+ * @param {string} path
148
+ * @param {{path: string}[]} folders - every folder on the account
149
+ * @param {string} delimiter
150
+ * @returns {string[]} descendant paths, deepest first
151
+ */
152
+ export function descendantsOf(path, folders, delimiter) {
153
+ if (!delimiter) return [];
154
+ const prefix = `${path}${delimiter}`;
155
+ return (folders ?? [])
156
+ .map(f => f.path)
157
+ .filter(p => p.startsWith(prefix))
158
+ // Deepest first, so a server that refuses to delete a non-empty
159
+ // parent is never asked to.
160
+ .sort((a, b) => b.split(delimiter).length - a.split(delimiter).length || b.localeCompare(a));
161
+ }
162
+
163
+ /**
164
+ * Work out which mailboxes are actually subscribed.
165
+ *
166
+ * Harder than it looks, and the reason this is a function rather than a
167
+ * property read. imapflow reports an UNSUBSCRIBED mailbox as
168
+ * `subscribed: undefined` - not `false` - so a `!== false` test reads every
169
+ * hidden folder as visible, which is exactly how this first shipped.
170
+ *
171
+ * Reading `=== true` instead has the opposite failure: a server that does not
172
+ * return the SUBSCRIBED attribute at all reports `undefined` for everything,
173
+ * and every folder would read as hidden. So the attribute is trusted only
174
+ * when the server demonstrably uses it - if not one mailbox claims to be
175
+ * subscribed, the server is not answering the question and everything is
176
+ * treated as visible.
177
+ *
178
+ * `list({subscribed: true})` is not a way out: on a LIST-EXTENDED server it
179
+ * came back with all 104 mailboxes including the unsubscribed one.
180
+ *
181
+ * @param {{path: string, subscribed?: boolean}[]} boxes
182
+ * @returns {Map<string, boolean>} path -> visible
183
+ */
184
+ export function resolveSubscriptions(boxes) {
185
+ const list = boxes ?? [];
186
+ const serverAnswers = list.some(box => box.subscribed === true);
187
+ const out = new Map();
188
+ for (const box of list) {
189
+ out.set(box.path, serverAnswers ? box.subscribed === true : true);
190
+ }
191
+ return out;
192
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Getting mail out of the mailbox and into the CMS.
3
+ *
4
+ * The thing a mail plugin inside a content management system can do that a
5
+ * standalone client cannot: an attachment becomes a media file, and a message
6
+ * becomes a draft page or a collection entry. Every other feature here is one
7
+ * a webmail client would have; these are the ones that are the point of it
8
+ * living in Domma at all.
9
+ *
10
+ * Pure, and separate from the routes that use it, because the rules about
11
+ * what may be written into the public media directory are the security
12
+ * boundary of the whole feature and need testing without a mail server.
13
+ *
14
+ * @module _lib/mail/handoff
15
+ */
16
+
17
+ /**
18
+ * Attachment types that may be saved into the media library.
19
+ *
20
+ * Deliberately NARROWER than the CMS's own upload allowlist, in two places:
21
+ *
22
+ * - **No SVG.** An SVG is a script container, and `/media/` is served from
23
+ * the site's own origin. The CMS allows one on a deliberate upload by an
24
+ * administrator who chose the file; this is a file a stranger emailed in,
25
+ * and one click between the two is not enough separation.
26
+ * - **No HTML.** For the same reason, more obviously.
27
+ *
28
+ * Anything not listed is refused rather than saved under a safe extension:
29
+ * renaming a file does not change what it is, and a media library full of
30
+ * `.bin` is not a media library.
31
+ */
32
+ export const SAVEABLE_TYPES = new Map([
33
+ ['image/jpeg', 'jpg'], ['image/jpg', 'jpg'], ['image/png', 'png'],
34
+ ['image/gif', 'gif'], ['image/webp', 'webp'], ['image/x-icon', 'ico'],
35
+ ['image/bmp', 'bmp'], ['image/tiff', 'tiff'],
36
+ ['application/pdf', 'pdf'], ['text/plain', 'txt'], ['text/csv', 'csv'],
37
+ ['application/msword', 'doc'],
38
+ ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'docx'],
39
+ ['application/vnd.ms-excel', 'xls'],
40
+ ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'xlsx'],
41
+ ['video/mp4', 'mp4'], ['video/webm', 'webm'], ['video/ogg', 'ogv'],
42
+ ['audio/mpeg', 'mp3'], ['audio/mp3', 'mp3'], ['audio/ogg', 'oga'],
43
+ ['audio/wav', 'wav'], ['audio/webm', 'weba']
44
+ ]);
45
+
46
+ /**
47
+ * The media type of an attachment, without its parameters.
48
+ *
49
+ * @param {*} value
50
+ * @returns {string}
51
+ */
52
+ export function mediaType(value) {
53
+ return String(value ?? '').toLowerCase().split(';')[0].trim();
54
+ }
55
+
56
+ /**
57
+ * The name this attachment will be saved under.
58
+ *
59
+ * The extension comes from the ALLOWED TYPE, never from the sender's
60
+ * filename, and that is the whole point of this function.
61
+ *
62
+ * `/media/` is served by `@fastify/static`, which sets `Content-Type` from
63
+ * the file extension. The declared MIME type and the filename are two
64
+ * separate fields, both chosen by whoever sent the message - so an
65
+ * attachment declared `image/png` and named `logo.svg` passed the type check
66
+ * and then landed on disk as `.svg`, served from the site's own origin as
67
+ * `image/svg+xml`. An SVG is a script container and that origin is where the
68
+ * admin's token lives. `nosniff` does not help: it stops the browser
69
+ * sniffing AWAY from a declared type, not the extension declaring one.
70
+ *
71
+ * Rewriting rather than refusing, because a mislabelled extension is far more
72
+ * often a sender being careless than a sender being hostile - and a file
73
+ * saved as the type it actually claims to be is both safe and correct.
74
+ *
75
+ * @param {string} filename
76
+ * @param {string} contentType
77
+ * @returns {string}
78
+ */
79
+ export function mediaNameFor(filename, contentType) {
80
+ const extension = SAVEABLE_TYPES.get(mediaType(contentType));
81
+ const base = safeName(filename);
82
+ // Everything before the last dot, so `report.final.pdf` keeps its stem.
83
+ const dot = base.lastIndexOf('.');
84
+ const stem = (dot > 0 ? base.slice(0, dot) : base) || 'attachment';
85
+ return extension ? `${stem}.${extension}` : stem;
86
+ }
87
+
88
+ /**
89
+ * Make an attachment's filename safe to write.
90
+ *
91
+ * The same transformation the media route uses - `path.basename` then a
92
+ * character allowlist - spelled out rather than imported, because this module
93
+ * is deliberately free of node built-ins so it can be tested and reasoned
94
+ * about on its own. A name that reduces to nothing gets one.
95
+ *
96
+ * @param {string} name
97
+ * @returns {string}
98
+ */
99
+ export function safeName(name) {
100
+ const base = String(name ?? '')
101
+ // Everything up to and including the last separator, either kind.
102
+ .replace(/^.*[\\/]/, '')
103
+ .replace(/[^a-zA-Z0-9._-]/g, '_')
104
+ // A leading dot makes a hidden file; a name of only dots makes none.
105
+ .replace(/^\.+/, '');
106
+ return base.slice(0, 120) || 'attachment';
107
+ }
108
+
109
+ /**
110
+ * A name that is not already taken, by adding a counter before the extension.
111
+ *
112
+ * `saveMedia` overwrites silently, so a second `invoice.pdf` from a second
113
+ * sender would replace the first. Anything derived from mail has to assume
114
+ * the name is not unique, because it was chosen by somebody else.
115
+ *
116
+ * @param {string} name
117
+ * @param {Set<string>|string[]} taken
118
+ * @returns {string}
119
+ */
120
+ export function uniqueName(name, taken) {
121
+ const used = taken instanceof Set ? taken : new Set(taken ?? []);
122
+ const clean = safeName(name);
123
+ if (!used.has(clean)) return clean;
124
+
125
+ const dot = clean.lastIndexOf('.');
126
+ const stem = dot > 0 ? clean.slice(0, dot) : clean;
127
+ const ext = dot > 0 ? clean.slice(dot) : '';
128
+
129
+ for (let n = 2; n < 1000; n += 1) {
130
+ const candidate = `${stem}-${n}${ext}`;
131
+ if (!used.has(candidate)) return candidate;
132
+ }
133
+ // A thousand files of one name is not a case worth a cleverer answer.
134
+ return `${stem}-${Date.now()}${ext}`;
135
+ }
136
+
137
+ /**
138
+ * May this attachment be saved into the media library?
139
+ *
140
+ * @param {{filename?: string, contentType?: string, size?: number}} attachment
141
+ * @param {{maxBytes?: number}} [limits]
142
+ * @returns {{ok: true} | {ok: false, error: string}}
143
+ */
144
+ export function canSave(attachment, {maxBytes = 20_000_000} = {}) {
145
+ const type = mediaType(attachment?.contentType);
146
+ if (!SAVEABLE_TYPES.has(type)) {
147
+ return {
148
+ ok: false,
149
+ error: `${type || 'That file type'} cannot be saved to the media library. Images, documents, audio and video can.`
150
+ };
151
+ }
152
+ if (Number(attachment?.size ?? 0) > maxBytes) {
153
+ return {ok: false, error: 'That attachment is too large for the media library.'};
154
+ }
155
+ return {ok: true};
156
+ }
157
+
158
+ /**
159
+ * A URL path for a page made from a message.
160
+ *
161
+ * Under a folder rather than at the root: a mailbox can produce a great many
162
+ * of these, and a site whose top level fills up with them is a site nobody
163
+ * will use the feature on twice. Dated, because two messages very often share
164
+ * a subject.
165
+ *
166
+ * @param {string} subject
167
+ * @param {Date} [date]
168
+ * @param {string} [prefix]
169
+ * @returns {string}
170
+ */
171
+ export function pagePathFor(subject, date = new Date(), prefix = 'from-mail') {
172
+ const slug = String(subject ?? '')
173
+ .toLowerCase()
174
+ .replace(/^((re|fwd|fw)\s*:\s*)+/i, '')
175
+ .replace(/[^a-z0-9]+/g, '-')
176
+ .replace(/^-+|-+$/g, '')
177
+ .slice(0, 60) || 'message';
178
+
179
+ const when = Number.isNaN(date?.getTime?.()) ? new Date() : date;
180
+ const stamp = when.toISOString().slice(0, 10);
181
+ return `/${prefix}/${stamp}-${slug}`;
182
+ }
183
+
184
+ /**
185
+ * Turn a message's plain text into a Markdown body.
186
+ *
187
+ * The TEXT part, never the HTML. Mail HTML is table layouts, inline styles
188
+ * and tracking, and embedding it in a Markdown file produces a page that is
189
+ * unusable to edit and, because a blank line ends an HTML block in Markdown,
190
+ * frequently one that renders its own markup as prose halfway down. The text
191
+ * part is what the message actually says.
192
+ *
193
+ * Quoted material is dropped: a page made from the fourth message in a thread
194
+ * should be that message, not it and the three under it.
195
+ *
196
+ * @param {string} text
197
+ * @returns {string}
198
+ */
199
+ export function textToMarkdown(text) {
200
+ const lines = String(text ?? '').replace(/\r\n/g, '\n').split('\n');
201
+
202
+ const kept = [];
203
+ for (const line of lines) {
204
+ // The quote and everything after it. Attribution lines vary too much
205
+ // to match reliably, but the first `>` is dependable and is where the
206
+ // original message starts in every client that quotes at all.
207
+ if (/^\s*>/.test(line)) break;
208
+ // The plain-text signature delimiter, likewise.
209
+ if (/^--\s*$/.test(line)) break;
210
+ kept.push(line);
211
+ }
212
+
213
+ // The attribution line sits ABOVE the first `>`, so breaking on the quote
214
+ // leaves it behind - "On Monday, Sam wrote:" as the last line of a page
215
+ // about something else. Dropped from the end, along with the blank line
216
+ // that introduced it, and only from the end: the same words in the middle
217
+ // of a message are the sender quoting someone in prose.
218
+ while (kept.length) {
219
+ const last = kept[kept.length - 1].trim();
220
+ if (!last) { kept.pop(); continue; }
221
+ if (/wrote:$/i.test(last) || /^-{2,}\s*(original|forwarded) message/i.test(last)) {
222
+ kept.pop();
223
+ continue;
224
+ }
225
+ break;
226
+ }
227
+
228
+ return kept
229
+ .join('\n')
230
+ // Markdown treats a single newline as a space, so a hard-wrapped mail
231
+ // would reflow into one paragraph. Blank-line-separated blocks are
232
+ // paragraphs; single breaks inside them keep their line.
233
+ .replace(/[ \t]+$/gm, '')
234
+ .replace(/\n{3,}/g, '\n\n')
235
+ .trim();
236
+ }
237
+
238
+ /**
239
+ * Match a message onto a collection's fields, by what they are called.
240
+ *
241
+ * A guess, offered to the user rather than applied behind their back - the
242
+ * route hands the mapping back and the screen shows it, so a field called
243
+ * `body` that was meant for something else is visible before anything is
244
+ * written.
245
+ *
246
+ * @param {Array<{name: string, type?: string}>} fields
247
+ * @returns {Object.<string, string>} field name -> message property
248
+ */
249
+ export function guessFieldMapping(fields) {
250
+ const candidates = {
251
+ subject: ['subject', 'title', 'name', 'heading'],
252
+ body: ['body', 'content', 'message', 'text', 'description', 'notes'],
253
+ fromName: ['from', 'sender', 'author', 'contact', 'fullname', 'name'],
254
+ fromEmail: ['email', 'emailaddress', 'from_email', 'fromemail', 'sender_email'],
255
+ date: ['date', 'received', 'sent', 'createdat', 'when']
256
+ };
257
+
258
+ const mapping = {};
259
+ const used = new Set();
260
+
261
+ // In this order, so `name` goes to the subject on a collection that has
262
+ // both `name` and `from` rather than to the sender.
263
+ for (const [property, names] of Object.entries(candidates)) {
264
+ for (const field of fields ?? []) {
265
+ const key = String(field?.name ?? '').toLowerCase().replace(/[^a-z]/g, '');
266
+ if (used.has(field.name)) continue;
267
+ if (!names.includes(key)) continue;
268
+ mapping[field.name] = property;
269
+ used.add(field.name);
270
+ break;
271
+ }
272
+ }
273
+ return mapping;
274
+ }