domma-cms 0.55.1 → 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 (211) hide show
  1. package/CLAUDE.md +130 -2
  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/plugin-code.html +1 -1
  19. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  20. package/admin/js/templates/settings.html +16 -97
  21. package/admin/js/templates/theme.html +173 -0
  22. package/admin/js/views/context-menu-editor.js +55 -0
  23. package/admin/js/views/context-menus.js +5 -0
  24. package/admin/js/views/form-editor.js +7 -7
  25. package/admin/js/views/index.js +1 -1
  26. package/admin/js/views/plugin-marketplace.js +1 -1
  27. package/admin/js/views/plugins.js +25 -23
  28. package/admin/js/views/search.js +1 -0
  29. package/admin/js/views/settings.js +3 -3
  30. package/admin/js/views/theme.js +1 -0
  31. package/bin/cli.js +2 -0
  32. package/bin/update.js +13 -2
  33. package/config/menus/admin-sidebar.json +129 -23
  34. package/config/plugins.json +5 -5
  35. package/config/search.json +13 -0
  36. package/config/theme.json +18 -0
  37. package/package.json +12 -4
  38. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  39. package/plugins/_lib/admin/mail/contacts.js +301 -0
  40. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  41. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  42. package/plugins/_lib/admin/mail/identity.js +480 -0
  43. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  44. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  45. package/plugins/_lib/admin/mail/mail.css +1 -0
  46. package/plugins/_lib/admin/mail/mail.html +71 -0
  47. package/plugins/_lib/admin/mail/panes.js +253 -0
  48. package/plugins/_lib/admin/mail/reader-view.js +4453 -0
  49. package/plugins/_lib/admin/mail/resizable.js +26 -0
  50. package/plugins/_lib/admin/mail/rules.js +343 -0
  51. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  52. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  53. package/plugins/_lib/admin/mail/templates.js +238 -0
  54. package/plugins/_lib/admin/mail/threads.js +200 -0
  55. package/plugins/_lib/admin/mail/vacation.js +269 -0
  56. package/plugins/_lib/admin/ui/help.css +1 -0
  57. package/plugins/_lib/admin/ui/help.js +174 -0
  58. package/plugins/_lib/admin/ui/resizable.js +151 -0
  59. package/plugins/_lib/dataStore.js +117 -0
  60. package/plugins/_lib/mail/accounts.js +919 -0
  61. package/plugins/_lib/mail/bodyTokens.js +101 -0
  62. package/plugins/_lib/mail/compose.js +256 -0
  63. package/plugins/_lib/mail/defaults.js +57 -0
  64. package/plugins/_lib/mail/diagnostics.js +154 -0
  65. package/plugins/_lib/mail/envelope.js +161 -0
  66. package/plugins/_lib/mail/folders.js +192 -0
  67. package/plugins/_lib/mail/handoff.js +274 -0
  68. package/plugins/_lib/mail/imapPool.js +293 -0
  69. package/plugins/_lib/mail/mbox.js +74 -0
  70. package/plugins/_lib/mail/pollSchedule.js +79 -0
  71. package/plugins/_lib/mail/poller.js +135 -0
  72. package/plugins/_lib/mail/priority.js +138 -0
  73. package/plugins/_lib/mail/readRoutes.js +680 -0
  74. package/plugins/_lib/mail/render.js +291 -0
  75. package/plugins/_lib/mail/ruleRunner.js +152 -0
  76. package/plugins/_lib/mail/scheduler.js +254 -0
  77. package/plugins/_lib/mail/secretbox.js +229 -0
  78. package/plugins/_lib/mail/send.js +396 -0
  79. package/plugins/_lib/mail/store.js +1002 -0
  80. package/plugins/_lib/mail/sync.js +292 -0
  81. package/plugins/_lib/mail/syncPlan.js +126 -0
  82. package/plugins/_lib/mail/syncSelection.js +82 -0
  83. package/plugins/_lib/mail/unsubscribe.js +183 -0
  84. package/plugins/_lib/mail/vacationRunner.js +114 -0
  85. package/plugins/_lib/mail/write.js +473 -0
  86. package/plugins/_lib/schemaSync.js +83 -0
  87. package/plugins/_template/admin/css/index.css +0 -0
  88. package/plugins/_template/admin/templates/index.html +4 -4
  89. package/plugins/_template/admin/views/index.js +7 -0
  90. package/plugins/analytics/admin/css/index.css +1 -0
  91. package/plugins/analytics/admin/templates/analytics.html +22 -13
  92. package/plugins/analytics/plugin.json +3 -0
  93. package/plugins/blog/admin/css/index.css +1 -0
  94. package/plugins/blog/admin/templates/blog.html +30 -18
  95. package/plugins/blog/admin/templates/categories.html +2 -2
  96. package/plugins/blog/admin/templates/comments.html +2 -2
  97. package/plugins/blog/admin/templates/post-editor.html +34 -34
  98. package/plugins/blog/admin/templates/settings.html +6 -3
  99. package/plugins/blog/admin/views/blog.js +8 -5
  100. package/plugins/blog/admin/views/categories.js +5 -10
  101. package/plugins/blog/admin/views/comments.js +5 -5
  102. package/plugins/blog/admin/views/post-editor.js +39 -20
  103. package/plugins/blog/admin/views/settings.js +52 -50
  104. package/plugins/blog/collections/categories/schema.json +7 -6
  105. package/plugins/blog/collections/comments/schema.json +11 -10
  106. package/plugins/blog/collections/posts/schema.json +14 -13
  107. package/plugins/blog/plugin.js +36 -13
  108. package/plugins/blog/plugin.json +13 -5
  109. package/plugins/blog/plugin.public.js +312 -0
  110. package/plugins/contacts/admin/css/index.css +1 -0
  111. package/plugins/contacts/admin/templates/contacts.html +128 -0
  112. package/plugins/contacts/admin/views/contacts.js +237 -4
  113. package/plugins/contacts/collections/user-contacts/schema.json +108 -0
  114. package/plugins/contacts/plugin.js +214 -27
  115. package/plugins/contacts/plugin.json +4 -1
  116. package/plugins/invoice/admin/css/index.css +1 -0
  117. package/plugins/invoice/admin/templates/editor.html +140 -49
  118. package/plugins/invoice/admin/templates/index.html +153 -23
  119. package/plugins/invoice/admin/templates/issuers.html +2 -5
  120. package/plugins/invoice/admin/templates/receivers.html +2 -5
  121. package/plugins/invoice/admin/views/contacts-source.js +266 -0
  122. package/plugins/invoice/admin/views/editor.js +366 -199
  123. package/plugins/invoice/admin/views/export.js +199 -0
  124. package/plugins/invoice/admin/views/help-content.js +61 -0
  125. package/plugins/invoice/admin/views/index.js +582 -94
  126. package/plugins/invoice/admin/views/issuers.js +24 -17
  127. package/plugins/invoice/admin/views/media.js +172 -0
  128. package/plugins/invoice/admin/views/party-view.js +305 -67
  129. package/plugins/invoice/admin/views/payments.js +127 -0
  130. package/plugins/invoice/admin/views/print.js +130 -0
  131. package/plugins/invoice/admin/views/receivers.js +49 -16
  132. package/plugins/invoice/admin/views/send.js +212 -0
  133. package/plugins/invoice/admin/views/settings.js +594 -0
  134. package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
  135. package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
  136. package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
  137. package/plugins/invoice/collections/invoices/schema.json +19 -13
  138. package/plugins/invoice/config.js +27 -6
  139. package/plugins/invoice/pdf.js +164 -0
  140. package/plugins/invoice/plugin.js +1217 -44
  141. package/plugins/invoice/plugin.json +10 -9
  142. package/plugins/invoice/templates/_base.css +1 -0
  143. package/plugins/invoice/templates/classic-nologo.html +100 -0
  144. package/plugins/invoice/templates/classic.html +91 -0
  145. package/plugins/invoice/templates/invoice-print.html +24 -0
  146. package/plugins/invoice/templates/minimal.html +99 -0
  147. package/plugins/invoice/templates/modern-nologo.html +114 -0
  148. package/plugins/invoice/templates/modern.html +113 -0
  149. package/plugins/invoice/templates/templates.json +11 -0
  150. package/plugins/mail-reader/admin/views/mail.js +19 -0
  151. package/plugins/mail-reader/config.js +7 -0
  152. package/plugins/mail-reader/plugin.js +48 -0
  153. package/plugins/mail-reader/plugin.json +33 -0
  154. package/plugins/notes/admin/views/notes.js +1 -1
  155. package/plugins/notes/plugin.json +2 -2
  156. package/plugins/surveys/lib/audience.js +37 -0
  157. package/plugins/surveys/lib/campaigns.js +43 -0
  158. package/plugins/surveys/lib/ledger.js +110 -0
  159. package/plugins/surveys/lib/sending.js +106 -0
  160. package/plugins/surveys/lib/stats.js +62 -0
  161. package/plugins/surveys/lib/submit.js +95 -0
  162. package/plugins/surveys/lib/tokens.js +28 -0
  163. package/plugins/surveys/plugin.public.js +149 -0
  164. package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
  165. package/public/css/forms.css +1 -1
  166. package/public/css/search.css +1 -0
  167. package/public/css/site.css +1 -1
  168. package/public/js/collection-context.js +2 -2
  169. package/public/js/context-menus.js +1 -0
  170. package/public/js/form-logic-engine.js +1 -1
  171. package/public/js/forms.js +2 -2
  172. package/public/js/search.js +1 -0
  173. package/public/js/site.js +1 -1
  174. package/scripts/build.js +37 -3
  175. package/scripts/copy-domma.js +48 -0
  176. package/scripts/seed.js +1996 -0
  177. package/server/routes/api/collections.js +34 -0
  178. package/server/routes/api/context-menus.js +104 -0
  179. package/server/routes/api/forms.js +42 -3
  180. package/server/routes/api/notifications.js +69 -19
  181. package/server/routes/api/plugins.js +50 -6
  182. package/server/routes/api/search.js +43 -0
  183. package/server/routes/api/theme.js +69 -0
  184. package/server/routes/public.js +42 -7
  185. package/server/server.js +74 -0
  186. package/server/services/adapters/FileAdapter.js +6 -1
  187. package/server/services/content.js +26 -0
  188. package/server/services/contextMenus.js +477 -0
  189. package/server/services/markdown.js +70 -9
  190. package/server/services/permissionRegistry.js +24 -0
  191. package/server/services/pluginFiles.js +52 -11
  192. package/server/services/plugins.js +229 -6
  193. package/server/services/renderer.js +144 -22
  194. package/server/services/roles.js +1 -1
  195. package/server/services/search-migration.js +82 -0
  196. package/server/services/search.js +413 -0
  197. package/server/services/sidebar-migration.js +1 -0
  198. package/server/services/themeSettings.js +541 -0
  199. package/server/services/users.js +8 -0
  200. package/server/templates/page.html +4 -2
  201. package/plugins/contacts/data/contacts.json +0 -20
  202. package/plugins/notes/data/notes.json +0 -1
  203. package/plugins/site-search/admin/views/site-search.js +0 -116
  204. package/plugins/site-search/config.js +0 -15
  205. package/plugins/site-search/plugin.js +0 -188
  206. package/plugins/site-search/plugin.json +0 -40
  207. package/plugins/site-search/public/inject-body.html +0 -17
  208. package/plugins/site-search/public/inject-head.html +0 -1
  209. package/plugins/site-search/public/search.css +0 -1
  210. package/plugins/site-search/public/search.js +0 -1
  211. package/plugins/todo/data/todos.json +0 -1
@@ -0,0 +1,480 @@
1
+ /**
2
+ * Who a mailbox sends as, and what it signs off with.
3
+ *
4
+ * Pure, and kept apart from `accounts.js` and `send.js`, because the parts
5
+ * that go wrong here are conventions rather than transport. A display name
6
+ * that is not quoted breaks the From header on any name containing a comma; a
7
+ * name carried into the SMTP envelope makes the envelope invalid; a newline
8
+ * that reaches a header is header injection. All three are decided here, once,
9
+ * so that the send path and the draft path cannot disagree about them.
10
+ *
11
+ * ## Why this sits under `admin/`
12
+ *
13
+ * Both sides need it: the send path builds the From header with it, and the
14
+ * compose window adds the signature with it. The static mount serves
15
+ * `{plugin}/{admin|public}/**` and nothing else - `_lib/mail/` is where the
16
+ * credential store and the secret box live, and it is 404 to a browser by
17
+ * design. A module the browser must import therefore has to live here, and
18
+ * the server imports it across rather than the alternative, which is two
19
+ * copies of the placement rules drifting apart.
20
+ *
21
+ * @module _lib/admin/mail/identity
22
+ */
23
+
24
+ /** Where a signature sits relative to quoted text. */
25
+ export const PLACEMENTS = ['above-quote', 'below-quote'];
26
+
27
+ /**
28
+ * Strip anything that would end a header line.
29
+ *
30
+ * Every value here is interpolated into a mail header, so a CR or LF in one
31
+ * is not a formatting problem - it is a second header of the sender's
32
+ * choosing. Folded to a space rather than removed, so "Sales\nTeam" reads as
33
+ * "Sales Team" instead of "SalesTeam".
34
+ *
35
+ * @param {*} value
36
+ * @returns {string}
37
+ */
38
+ function headerSafe(value) {
39
+ return String(value ?? '').replace(/[\r\n]+/g, ' ').trim();
40
+ }
41
+
42
+ /**
43
+ * The bare address out of anything address-shaped.
44
+ *
45
+ * `"Sales" <sales@example.com>` and `sales@example.com` both answer
46
+ * `sales@example.com`. Used for the SMTP envelope, which takes an address and
47
+ * nothing else - a display name there is not a nicety, it is a syntax error
48
+ * the server rejects the whole message over.
49
+ *
50
+ * @param {*} value
51
+ * @returns {string}
52
+ */
53
+ export function bareAddress(value) {
54
+ const clean = headerSafe(value);
55
+ const angled = clean.match(/<([^>]+)>\s*$/);
56
+ return (angled ? angled[1] : clean).trim();
57
+ }
58
+
59
+ /**
60
+ * The From header for an address and an optional display name.
61
+ *
62
+ * The name is always emitted as a quoted string. A display name may go
63
+ * unquoted only while it is a run of atoms, and the characters that break
64
+ * that rule - a comma, a full stop, a colon - are exactly the ones real names
65
+ * and job titles contain ("Waterhouse, Darryl", "D. Waterhouse", "Sales:
66
+ * UK"). Quoting unconditionally is one branch instead of a predicate that
67
+ * gets the interesting cases wrong.
68
+ *
69
+ * @param {*} address
70
+ * @param {*} displayName
71
+ * @returns {string}
72
+ */
73
+ export function formatFrom(address, displayName) {
74
+ const clean = headerSafe(address);
75
+ const bare = bareAddress(clean);
76
+ const name = headerSafe(displayName);
77
+ // No display name of our own means leave the address exactly as stored,
78
+ // NOT reduced to its bare form. The Outgoing tab's From field is free
79
+ // text hinted "What recipients see", so mailboxes configured before this
80
+ // feature existed have `Sales Team <sales@example.com>` sitting in it -
81
+ // and stripping that back to the address the moment an empty identity is
82
+ // introduced would silently rename every sender on the site.
83
+ if (!name || !bare) return clean;
84
+ return `"${name.replace(/([\\"])/g, '\\$1')}" <${bare}>`;
85
+ }
86
+
87
+ /**
88
+ * Is this something that could be an email address at all?
89
+ *
90
+ * The same shape `validateDraft` holds recipients to, so a Reply-To is not
91
+ * accepted here on terms a recipient list would refuse.
92
+ *
93
+ * @param {*} value
94
+ * @returns {boolean}
95
+ */
96
+ export function looksLikeAddress(value) {
97
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(bareAddress(value));
98
+ }
99
+
100
+ /**
101
+ * Turn signature HTML into the plain-text version that rides alongside it.
102
+ *
103
+ * Derived rather than asked for twice. Nobody maintains two signatures, and a
104
+ * mailbox whose text part says something different from its HTML part is the
105
+ * thing that discovers, months later, that half its recipients were reading
106
+ * the wrong job title.
107
+ *
108
+ * @param {string} html
109
+ * @returns {string}
110
+ */
111
+ export function signatureToText(html) {
112
+ return String(html ?? '')
113
+ // Block-level ends become line breaks before the tags go, or every
114
+ // line of the signature runs into the next.
115
+ .replace(/<br\s*\/?>/gi, '\n')
116
+ .replace(/<\/(p|div|tr|li|h[1-6]|blockquote)\s*>/gi, '\n')
117
+ .replace(/<\/?(td|th)\s*[^>]*>/gi, ' ')
118
+ .replace(/<[^>]+>/g, '')
119
+ .replace(/&nbsp;/gi, ' ')
120
+ .replace(/&amp;/gi, '&')
121
+ .replace(/&lt;/gi, '<')
122
+ .replace(/&gt;/gi, '>')
123
+ .replace(/&quot;/gi, '"')
124
+ .replace(/&#39;|&apos;/gi, "'")
125
+ .replace(/[ \t]+\n/g, '\n')
126
+ .replace(/\n{3,}/g, '\n\n')
127
+ .trim();
128
+ }
129
+
130
+ /**
131
+ * Escape text for interpolation into HTML.
132
+ *
133
+ * @param {*} value
134
+ * @returns {string}
135
+ */
136
+ function escapeHtml(value) {
137
+ return String(value ?? '')
138
+ .replace(/&/g, '&amp;')
139
+ .replace(/</g, '&lt;')
140
+ .replace(/>/g, '&gt;')
141
+ .replace(/"/g, '&quot;');
142
+ }
143
+
144
+ /**
145
+ * Minimal HTML preserving the line breaks of some plain text.
146
+ *
147
+ * @param {string} text
148
+ * @returns {string}
149
+ */
150
+ export function textToHtml(text) {
151
+ const clean = String(text ?? '').trim();
152
+ if (!clean) return '';
153
+ return clean
154
+ .split(/\n{2,}/)
155
+ .map(block => `<p>${escapeHtml(block).replace(/\n/g, '<br>')}</p>`)
156
+ .join('');
157
+ }
158
+
159
+ /**
160
+ * Put a stored identity into a known shape.
161
+ *
162
+ * Whitelisted rather than merged: this object is read back by the browser and
163
+ * its signature HTML is embedded in outgoing mail, so an unexpected key in
164
+ * the store should not become an unexpected key on the wire.
165
+ *
166
+ * @param {object|null|undefined} input
167
+ * @returns {object}
168
+ */
169
+ export function normaliseIdentity(input) {
170
+ const source = input && typeof input === 'object' ? input : {};
171
+ const useOn = source.useOn && typeof source.useOn === 'object' ? source.useOn : {};
172
+
173
+ const signatureHtml = String(source.signatureHtml ?? '').trim();
174
+ // Only ever derived, never trusted from the caller: the browser sends the
175
+ // HTML it edited, and accepting a text part alongside it would let the two
176
+ // drift the moment anything but this editor did the sending.
177
+ const signatureText = signatureHtml
178
+ ? signatureToText(signatureHtml)
179
+ : String(source.signatureText ?? '').trim();
180
+
181
+ return {
182
+ displayName: headerSafe(source.displayName),
183
+ replyTo: looksLikeAddress(source.replyTo) ? bareAddress(source.replyTo) : '',
184
+ signatureHtml,
185
+ signatureText,
186
+ useOn: {
187
+ compose: useOn.compose !== false,
188
+ reply: useOn.reply !== false,
189
+ forward: useOn.forward !== false
190
+ },
191
+ placement: PLACEMENTS.includes(source.placement) ? source.placement : 'above-quote'
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Does this identity have a signature to add at all?
197
+ *
198
+ * @param {object} identity
199
+ * @returns {boolean}
200
+ */
201
+ export function hasSignature(identity) {
202
+ return Boolean(identity?.signatureHtml || identity?.signatureText);
203
+ }
204
+
205
+ /**
206
+ * Should a signature go on a draft opened this way?
207
+ *
208
+ * `edit` never does. A saved draft already carries whatever signature it was
209
+ * written with, and adding one on reopen is how a draft edited four times
210
+ * goes out with four signatures.
211
+ *
212
+ * @param {object} identity
213
+ * @param {'new'|'reply'|'replyAll'|'forward'|'edit'} mode
214
+ * @returns {boolean}
215
+ */
216
+ export function signsOn(identity, mode) {
217
+ if (!hasSignature(identity)) return false;
218
+ const on = normaliseIdentity(identity).useOn;
219
+ if (mode === 'forward') return on.forward;
220
+ if (mode === 'reply' || mode === 'replyAll') return on.reply;
221
+ if (mode === 'new') return on.compose;
222
+ return false;
223
+ }
224
+
225
+ /**
226
+ * Add the signature to a draft.
227
+ *
228
+ * Returns a new draft rather than mutating: the caller's copy is the one the
229
+ * compose window compares against to decide whether anything was typed, and
230
+ * that comparison is meaningless if the baseline moved.
231
+ *
232
+ * Both parts are written, not just the HTML one. The rich editor derives the
233
+ * text part from what it holds, so the text written here only reaches the
234
+ * wire on the plain-textarea fallback - but the compose window also compares
235
+ * the two to decide whether a message is worth asking about before discarding
236
+ * it, and a signature in one part and not the other reads as an edit.
237
+ *
238
+ * @param {object} draft
239
+ * @param {object|null} identity
240
+ * @param {'new'|'reply'|'replyAll'|'forward'|'edit'} [mode]
241
+ * @returns {object}
242
+ */
243
+ export function applyIdentity(draft, identity, mode = 'new') {
244
+ if (!signsOn(identity, mode)) return draft;
245
+
246
+ const id = normaliseIdentity(identity);
247
+ const sigHtml = id.signatureHtml || textToHtml(id.signatureText);
248
+ // No "-- " above the HTML block, and one above the text: the delimiter is
249
+ // a plain-text convention that clients use to fold a signature away, and
250
+ // a designed signature with a bare "--" floating over the logo is what
251
+ // every rich client stopped doing years ago.
252
+ const sigText = id.signatureText ? `-- \n${id.signatureText}` : '';
253
+
254
+ const block = `<div class="dm-mail-signature">${sigHtml}</div>`;
255
+ // An empty paragraph above everything, which is where the cursor lands
256
+ // and where the message gets written.
257
+ const lead = '<p><br></p>';
258
+ const quoted = String(draft.html ?? '');
259
+ const quotedText = String(draft.body ?? '');
260
+ const below = id.placement === 'below-quote' && quoted;
261
+
262
+ return {
263
+ ...draft,
264
+ html: below ? lead + quoted + block : lead + block + quoted,
265
+ body: below ? `${quotedText}\n\n${sigText}` : `\n\n${sigText}${quotedText}`
266
+ };
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // Several identities per mailbox
271
+ // ---------------------------------------------------------------------------
272
+
273
+ /**
274
+ * How many identities one mailbox may hold.
275
+ *
276
+ * A ceiling rather than a guess at what is reasonable: the picker is a
277
+ * dropdown, the list is stored in the credentials file, and neither wants to
278
+ * be unbounded because a script pushed a thousand.
279
+ */
280
+ export const MAX_IDENTITIES = 25;
281
+
282
+ /**
283
+ * A stable id for an identity.
284
+ *
285
+ * Not `randomUUID` - this module is imported by the browser as well as the
286
+ * server, and `node:crypto` is not there. `crypto.randomUUID` exists in both,
287
+ * but only over HTTPS or localhost in the browser, and the admin is served
288
+ * over both. So: a short random string, which is all an id in a list of at
289
+ * most twenty-five needs to be.
290
+ *
291
+ * @returns {string}
292
+ */
293
+ function newId() {
294
+ return `id-${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36).slice(-4)}`;
295
+ }
296
+
297
+ /**
298
+ * Put one entry of an identity list into shape.
299
+ *
300
+ * Everything `normaliseIdentity` decides, plus the two things that only mean
301
+ * something when there is more than one: which address it sends from, and
302
+ * what to call it in the picker.
303
+ *
304
+ * @param {object} input
305
+ * @param {number} index - used only to name an entry that has no label
306
+ * @returns {object}
307
+ */
308
+ function normaliseEntry(input, index) {
309
+ const source = input && typeof input === 'object' ? input : {};
310
+ const base = normaliseIdentity(source);
311
+
312
+ // An alias address, or '' meaning "the mailbox's own From address". Held
313
+ // to the same shape a recipient is, because it IS a From address - a
314
+ // typo here is mail that bounces rather than mail that looks odd.
315
+ const address = looksLikeAddress(source.address) ? bareAddress(source.address) : '';
316
+
317
+ const label = headerSafe(source.label)
318
+ || base.displayName
319
+ || address
320
+ || `Identity ${index + 1}`;
321
+
322
+ return {
323
+ ...base,
324
+ id: typeof source.id === 'string' && source.id.trim() ? source.id.trim() : newId(),
325
+ label,
326
+ address
327
+ };
328
+ }
329
+
330
+ /**
331
+ * Every identity a mailbox holds, in picker order.
332
+ *
333
+ * Takes the whole account rather than a field, because the migration is part
334
+ * of the answer: a mailbox configured before aliases existed has a single
335
+ * `identity` object and no list, and it must come back as a one-entry list
336
+ * rather than as nothing. Doing that here means no caller has to know there
337
+ * were ever two shapes.
338
+ *
339
+ * The first entry is the default - the one a new message uses and the one the
340
+ * From header carries when nothing chose otherwise. Order is therefore
341
+ * meaningful, not cosmetic.
342
+ *
343
+ * @param {object|null|undefined} account
344
+ * @returns {object[]}
345
+ */
346
+ export function normaliseIdentityList(account) {
347
+ const source = account && typeof account === 'object' ? account : {};
348
+
349
+ const raw = Array.isArray(source.identities)
350
+ ? source.identities
351
+ // The pre-alias shape. An empty identity is still an identity here:
352
+ // a mailbox that set a display name and nothing else should not lose
353
+ // it to a migration.
354
+ : (source.identity ? [source.identity] : []);
355
+
356
+ const list = raw.slice(0, MAX_IDENTITIES).map(normaliseEntry);
357
+
358
+ // Ids have to be unique or the picker cannot tell two entries apart and
359
+ // `findIdentity` answers the wrong one. Duplicates are re-issued rather
360
+ // than dropped, because the entry itself is still wanted.
361
+ const seen = new Set();
362
+ for (const entry of list) {
363
+ while (seen.has(entry.id)) entry.id = newId();
364
+ seen.add(entry.id);
365
+ }
366
+
367
+ return list;
368
+ }
369
+
370
+ /**
371
+ * The identity a mailbox uses when nothing has chosen one.
372
+ *
373
+ * Null rather than an empty identity when the list is empty: "there is no
374
+ * identity" and "there is one, and it is blank" lead to different From
375
+ * headers, and `formatFrom` already knows what to do with the first.
376
+ *
377
+ * @param {object[]} list
378
+ * @returns {object|null}
379
+ */
380
+ export function defaultIdentity(list) {
381
+ return (Array.isArray(list) && list.length) ? list[0] : null;
382
+ }
383
+
384
+ /**
385
+ * Look one up by id, falling back to the default.
386
+ *
387
+ * A missing id is the normal case, not an error: most messages do not choose,
388
+ * and an id that no longer exists means an identity was deleted while a
389
+ * compose window was open. Both should send as the default rather than fail.
390
+ *
391
+ * @param {object[]} list
392
+ * @param {string|null|undefined} id
393
+ * @returns {object|null}
394
+ */
395
+ export function findIdentity(list, id) {
396
+ if (!Array.isArray(list) || !list.length) return null;
397
+ if (!id) return list[0];
398
+ return list.find(entry => entry.id === id) ?? list[0];
399
+ }
400
+
401
+ /**
402
+ * The From header this identity produces.
403
+ *
404
+ * The identity's own address when it has one - that is what makes it an alias
405
+ * rather than just a second signature - and the mailbox's configured From
406
+ * address otherwise.
407
+ *
408
+ * @param {object|null} identity
409
+ * @param {string} mailboxAddress - the account's own From address
410
+ * @returns {string}
411
+ */
412
+ export function identityFrom(identity, mailboxAddress) {
413
+ if (!identity) return headerSafe(mailboxAddress);
414
+ return formatFrom(identity.address || mailboxAddress, identity.displayName);
415
+ }
416
+
417
+ /**
418
+ * The envelope address this identity sends with.
419
+ *
420
+ * An alias sends as itself. That is not a detail: SPF and DMARC are checked
421
+ * against the envelope, so an alias whose envelope says the main mailbox is
422
+ * an alias whose mail fails alignment at a good many receivers. A provider
423
+ * that refuses to let the alias be the envelope sender will say so at MAIL
424
+ * FROM, which is a clear error at send time rather than silent filtering a
425
+ * week later.
426
+ *
427
+ * @param {object|null} identity
428
+ * @param {string} mailboxAddress
429
+ * @returns {string}
430
+ */
431
+ export function identityEnvelopeFrom(identity, mailboxAddress) {
432
+ return bareAddress(identity?.address || mailboxAddress);
433
+ }
434
+
435
+ /** The wrapper `applyIdentity` puts a signature in, and the hook for replacing it. */
436
+ export const SIGNATURE_CLASS = 'dm-mail-signature';
437
+
438
+ /**
439
+ * Replace the signature already in a draft with a different identity's.
440
+ *
441
+ * Used when the From picker changes halfway through writing a message: the
442
+ * signature that is there belongs to the identity that is no longer sending.
443
+ *
444
+ * Best effort, and deliberately so. The block is found by the wrapper
445
+ * `applyIdentity` wrote, and a rich editor is entitled to reformat the markup
446
+ * it was handed - so if the marker is not there any more, the body is left
447
+ * exactly as it is rather than guessed at. Silently leaving a signature alone
448
+ * is a small surprise; silently rewriting the wrong part of somebody's
449
+ * half-written message is not.
450
+ *
451
+ * @param {string} html
452
+ * @param {object|null} identity - the identity now sending
453
+ * @param {'new'|'reply'|'replyAll'|'forward'|'edit'} [mode]
454
+ * @returns {{html: string, swapped: boolean}}
455
+ */
456
+ export function swapSignature(html, identity, mode = 'new') {
457
+ const source = String(html ?? '');
458
+ const pattern = new RegExp(
459
+ `<div class="${SIGNATURE_CLASS}"[^>]*>[\\s\\S]*?</div>`, 'i'
460
+ );
461
+
462
+ if (!pattern.test(source)) return {html: source, swapped: false};
463
+
464
+ // The new identity may not sign this kind of message at all, in which
465
+ // case the right answer is to take the old block out and put nothing
466
+ // back.
467
+ if (!signsOn(identity, mode)) {
468
+ return {html: source.replace(pattern, ''), swapped: true};
469
+ }
470
+
471
+ const id = normaliseIdentity(identity);
472
+ const sigHtml = id.signatureHtml || textToHtml(id.signatureText);
473
+ const block = `<div class="${SIGNATURE_CLASS}">${sigHtml}</div>`;
474
+ // A FUNCTION, not a string. `String.replace` expands `$&`, `` $` ``, `$'`
475
+ // and `$1` in a string replacement - and this replacement is somebody's
476
+ // signature, which may legitimately contain `$'` (a price list) or `$&`.
477
+ // A signature reading "Save $'s" would otherwise paste the entire rest of
478
+ // the draft into the body on every change of sender.
479
+ return {html: source.replace(pattern, () => block), swapped: true};
480
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The settings section for senders whose images always load.
3
+ *
4
+ * Shared by both editions: blocking remote images is not a Pro feature, and
5
+ * neither is choosing to stop blocking them for someone in particular.
6
+ *
7
+ * The list exists so the choice stays visible. A preference someone cannot
8
+ * find is one they cannot revoke, and this one decides whether a sender learns
9
+ * that their mail was opened.
10
+ *
11
+ * @module _lib/admin/mail/image-senders-section
12
+ */
13
+
14
+ /**
15
+ * Say something in a status line, in the right colour.
16
+ *
17
+ * A class rather than `style.color`: an inline colour cannot read a `--dm-*`
18
+ * token that a theme override has redefined, so a status set that way is the
19
+ * same shade in every theme.
20
+ *
21
+ * @param {HTMLElement} el
22
+ * @param {string} text
23
+ * @param {'ok'|'bad'|'quiet'} [tone]
24
+ * @param {string} [base] - the layout classes the line always carries
25
+ * @returns {void}
26
+ */
27
+ function say(el, text, tone = 'quiet', base = 'text-sm mail-status') {
28
+ el.textContent = text ?? '';
29
+ el.className = `${base} ` + ({ok: 'text-success', bad: 'text-danger'}[tone] ?? 'text-muted');
30
+ }
31
+
32
+ /**
33
+ * @param {HTMLElement} container
34
+ * @param {object} ctx
35
+ * @returns {Promise<void>}
36
+ */
37
+ async function render(container, ctx) {
38
+ if (!ctx.account) {
39
+ container.textContent = 'Add a mailbox first.';
40
+ return;
41
+ }
42
+
43
+ const params = new URLSearchParams({account: ctx.account.id});
44
+ const data = await ctx.api(`${ctx.base}/image-senders?${params}`);
45
+
46
+ const intro = document.createElement('p');
47
+ intro.className = 'text-sm text-muted mb-3';
48
+ intro.textContent = 'Remote images are blocked by default, because loading one tells the sender that the message was opened and who opened it. Senders listed here are trusted, and their images load without asking.';
49
+ container.appendChild(intro);
50
+
51
+ const toggleRow = document.createElement('label');
52
+ toggleRow.className = 'mail-toggle-row text-sm';
53
+ const toggle = document.createElement('input');
54
+ toggle.type = 'checkbox';
55
+ toggle.checked = data.remember !== false;
56
+ toggleRow.appendChild(toggle);
57
+ toggleRow.appendChild(document.createTextNode('Offer to remember senders'));
58
+ container.appendChild(toggleRow);
59
+
60
+ const list = document.createElement('div');
61
+ list.className = 'mail-sender-list';
62
+ container.appendChild(list);
63
+
64
+ const status = document.createElement('div');
65
+ status.className = 'text-sm mail-status text-muted mt-2';
66
+ container.appendChild(status);
67
+
68
+ /**
69
+ * @param {string[]} senders
70
+ * @returns {void}
71
+ */
72
+ function paint(senders) {
73
+ list.textContent = '';
74
+ if (!senders.length) {
75
+ const empty = document.createElement('p');
76
+ empty.className = 'text-sm text-muted mail-none';
77
+ empty.textContent = 'No senders trusted yet.';
78
+ list.appendChild(empty);
79
+ return;
80
+ }
81
+
82
+ for (const address of senders) {
83
+ const row = document.createElement('div');
84
+ row.className = 'mail-box-row';
85
+
86
+ const name = document.createElement('span');
87
+ name.className = 'flex-1 text-sm mail-ellipsis';
88
+ name.textContent = address;
89
+ row.appendChild(name);
90
+
91
+ const remove = document.createElement('button');
92
+ remove.className = 'btn btn-sm btn-ghost';
93
+ remove.classList.add('text-danger');
94
+ remove.textContent = 'Stop trusting';
95
+ remove.addEventListener('click', async () => {
96
+ remove.disabled = true;
97
+ try {
98
+ const query = new URLSearchParams({account: ctx.account.id, address});
99
+ const result = await ctx.api(`${ctx.base}/image-senders?${query}`, 'DELETE');
100
+ paint(result.senders);
101
+ say(status, `Images from ${address} will be blocked again.`,
102
+ 'quiet', 'text-sm mail-status mt-2');
103
+ } catch (err) {
104
+ say(status, err.message, 'bad', 'text-sm mail-status mt-2');
105
+ remove.disabled = false;
106
+ }
107
+ });
108
+ row.appendChild(remove);
109
+ list.appendChild(row);
110
+ }
111
+ }
112
+
113
+ paint(data.senders ?? []);
114
+
115
+ toggle.addEventListener('change', async () => {
116
+ try {
117
+ const result = await ctx.api(`${ctx.base}/image-senders/remember?${params}`, 'PUT',
118
+ {remember: toggle.checked});
119
+ status.className = 'text-sm mail-status mt-2 text-muted';
120
+ status.textContent = result.remember
121
+ ? 'Senders can be remembered.'
122
+ : 'Remembering is off. The list is kept but ignored.';
123
+ } catch (err) {
124
+ say(status, err.message, 'bad', 'text-sm mail-status mt-2');
125
+ toggle.checked = !toggle.checked;
126
+ }
127
+ });
128
+ }
129
+
130
+ /** The section both editions show. */
131
+ export const imageSendersSection = {id: 'images', label: 'Images', render};