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,200 @@
1
+ /**
2
+ * Grouping messages into conversations.
3
+ *
4
+ * A thread is reconstructed from `Message-ID`, `In-Reply-To` and
5
+ * `References` - never from the subject. Subject grouping is what puts every
6
+ * "Re: hello" anyone ever sent into one conversation, and what splits a
7
+ * thread in half the moment somebody edits the subject line.
8
+ *
9
+ * ## Two halves, on purpose
10
+ *
11
+ * `threadRoot()` is what the mirror stores on each row at write time, so a
12
+ * folder can be listed and sorted by conversation in the database rather
13
+ * than in memory. `groupThreads()` is the repair pass over the page actually
14
+ * being shown: it unions rows whose chains only connect through each other,
15
+ * which catches the clients that put only the immediate parent in
16
+ * `References` and so hand every reply a different root.
17
+ *
18
+ * The consequence worth knowing is that the repair only sees the page in
19
+ * hand. A conversation split across a page boundary shows as two groups, and
20
+ * that is the honest trade against holding every message in memory to list
21
+ * fifty.
22
+ *
23
+ * @module _lib/admin/mail/threads
24
+ */
25
+
26
+ /**
27
+ * Message ids as a clean list.
28
+ *
29
+ * Servers disagree about whether `References` is a string or an array, and
30
+ * whether the angle brackets are part of it. Normalised here so nothing
31
+ * downstream has to care.
32
+ *
33
+ * @param {string|string[]|null|undefined} value
34
+ * @returns {string[]}
35
+ */
36
+ export function messageIds(value) {
37
+ const raw = Array.isArray(value)
38
+ ? value
39
+ : String(value ?? '').split(/\s+/);
40
+
41
+ return raw
42
+ .map(id => String(id ?? '').trim())
43
+ .filter(Boolean)
44
+ // Kept WITH their brackets, consistently. Stripping them would be
45
+ // just as good a rule, but only if everything agreed - and
46
+ // `messageId` comes off the envelope bracketed while a hand-built
47
+ // reference may not.
48
+ .map(id => (id.startsWith('<') ? id : `<${id}>`));
49
+ }
50
+
51
+ /**
52
+ * The id that identifies the conversation a message belongs to.
53
+ *
54
+ * The root of the chain: the first thing it references, failing that whatever
55
+ * it is a reply to, failing that itself - a message that starts a thread is
56
+ * its own root.
57
+ *
58
+ * @param {object} message
59
+ * @returns {string|null}
60
+ */
61
+ export function threadRoot(message) {
62
+ const references = messageIds(message?.references);
63
+ if (references.length) return references[0];
64
+
65
+ const parent = messageIds(message?.inReplyTo);
66
+ if (parent.length) return parent[0];
67
+
68
+ const own = messageIds(message?.messageId);
69
+ return own[0] ?? null;
70
+ }
71
+
72
+ /**
73
+ * Every id a message claims to be connected to, including its own.
74
+ *
75
+ * @param {object} message
76
+ * @returns {string[]}
77
+ */
78
+ function chainOf(message) {
79
+ return [
80
+ ...messageIds(message?.messageId),
81
+ ...messageIds(message?.inReplyTo),
82
+ ...messageIds(message?.references)
83
+ ];
84
+ }
85
+
86
+ /**
87
+ * Group a page of messages into conversations.
88
+ *
89
+ * Union-find over the ids each message claims, so two rows land in the same
90
+ * conversation if anything connects them - directly or through a third row.
91
+ * Rows with no usable ids at all are their own conversation rather than being
92
+ * swept together, because "has no Message-ID" is not a thing two messages
93
+ * have in common.
94
+ *
95
+ * Order is preserved: the conversations come back in the order their newest
96
+ * member appeared in the input, so a list sorted newest-first stays
97
+ * newest-first once it is grouped.
98
+ *
99
+ * @param {object[]} messages
100
+ * @returns {{root: string, messages: object[], latest: object, count: number,
101
+ * unseen: number, flagged: boolean, hasAttachment: boolean}[]}
102
+ */
103
+ export function groupThreads(messages) {
104
+ const parent = new Map();
105
+
106
+ /**
107
+ * @param {string} id
108
+ * @returns {string}
109
+ */
110
+ const find = (id) => {
111
+ let root = id;
112
+ while (parent.get(root) !== root) root = parent.get(root);
113
+ // Path compression, so a long thread does not walk its whole chain
114
+ // on every lookup.
115
+ let walk = id;
116
+ while (parent.get(walk) !== root) {
117
+ const next = parent.get(walk);
118
+ parent.set(walk, root);
119
+ walk = next;
120
+ }
121
+ return root;
122
+ };
123
+
124
+ /**
125
+ * @param {string} a
126
+ * @param {string} b
127
+ * @returns {void}
128
+ */
129
+ const union = (a, b) => {
130
+ if (!parent.has(a)) parent.set(a, a);
131
+ if (!parent.has(b)) parent.set(b, b);
132
+ const rootA = find(a);
133
+ const rootB = find(b);
134
+ if (rootA !== rootB) parent.set(rootB, rootA);
135
+ };
136
+
137
+ const keyed = (messages ?? []).map((message, index) => {
138
+ const chain = chainOf(message);
139
+ // A row with nothing to thread by gets a key nothing else can share.
140
+ const key = chain.length ? chain[0] : `\u0000row-${index}`;
141
+ if (!parent.has(key)) parent.set(key, key);
142
+ for (const id of chain) union(key, id);
143
+ return {message, key};
144
+ });
145
+
146
+ /** @type {Map<string, object[]>} */
147
+ const groups = new Map();
148
+ for (const {message, key} of keyed) {
149
+ const root = find(key);
150
+ if (!groups.has(root)) groups.set(root, []);
151
+ groups.get(root).push(message);
152
+ }
153
+
154
+ return [...groups.entries()].map(([root, list]) => {
155
+ // Newest first within the conversation, so `latest` is the one the
156
+ // collapsed row represents.
157
+ const ordered = [...list].sort((a, b) => dateOf(b) - dateOf(a));
158
+ return {
159
+ root,
160
+ messages: ordered,
161
+ latest: ordered[0],
162
+ count: ordered.length,
163
+ // A conversation is unread if anything in it is. Counting them
164
+ // rather than flagging it, because "3 unread of 11" is the useful
165
+ // thing to show on a collapsed row.
166
+ unseen: ordered.filter(m => m.seen === false).length,
167
+ flagged: ordered.some(m => m.flagged === true),
168
+ hasAttachment: ordered.some(m => m.hasAttachment === true)
169
+ };
170
+ });
171
+ }
172
+
173
+ /**
174
+ * A message's date as a number, for sorting.
175
+ *
176
+ * @param {object} message
177
+ * @returns {number}
178
+ */
179
+ function dateOf(message) {
180
+ const value = new Date(String(message?.date ?? '')).getTime();
181
+ // Undated messages sort to the bottom rather than to 1970, which would
182
+ // put them above everything on a descending sort.
183
+ return Number.isFinite(value) ? value : 0;
184
+ }
185
+
186
+ /**
187
+ * The subject a conversation is filed under.
188
+ *
189
+ * The oldest member's, with the reply and forward prefixes taken off - the
190
+ * first message is the one that named the thread, and the newest is quite
191
+ * often "Re: Re: Fwd: Re:" of it.
192
+ *
193
+ * @param {{messages: object[]}} thread
194
+ * @returns {string}
195
+ */
196
+ export function threadSubject(thread) {
197
+ const oldest = (thread?.messages ?? []).at(-1);
198
+ const subject = String(oldest?.subject ?? '').trim();
199
+ return subject.replace(/^((re|fwd|fw|aw|sv|vs)\s*(\[\d+\])?\s*:\s*)+/i, '').trim() || subject;
200
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * The automatic reply, and everything that must stop it.
3
+ *
4
+ * ## This feature is its own refusals
5
+ *
6
+ * Writing "I am away until Monday" into a box is ten minutes' work. The rest
7
+ * of this module is the reason an auto-responder is not a ten minute feature:
8
+ * every one of the checks below exists because without it the thing does real
9
+ * damage to somebody else's mailbox, and almost none of that damage is
10
+ * visible from this end.
11
+ *
12
+ * The failure everybody knows is the mail loop - two responders answering
13
+ * each other until an administrator notices. The ones people forget are worse
14
+ * behaved: replying to a mailing list posts your holiday plans to five
15
+ * hundred subscribers, replying to a bounce writes to a null sender, and
16
+ * replying to a newsletter tells a list broker the address is live and read
17
+ * by a person.
18
+ *
19
+ * So the rules, each with the case it prevents:
20
+ *
21
+ * 1. **Never to an automated message.** `Auto-Submitted` other than `no`
22
+ * (RFC 3834), `Precedence: bulk|list|junk`, or any `List-*` header.
23
+ * This alone stops the classic loop, because our own replies carry
24
+ * `Auto-Submitted: auto-replied` - see `autoReplyHeaders`.
25
+ * 2. **Never to a bounce.** An empty `Return-Path`, or a sender that is
26
+ * MAILER-DAEMON, postmaster, or an obvious no-reply.
27
+ * 3. **Never to ourselves.** A mailbox that answers its own mail loops
28
+ * without needing a second party at all.
29
+ * 4. **Only when actually addressed.** If our address is in neither To nor
30
+ * Cc we were Bcc'd on a blast, and a blast is exactly what must not be
31
+ * answered five hundred times.
32
+ * 5. **Once per sender per interval.** Somebody who writes four times in an
33
+ * afternoon gets told once.
34
+ *
35
+ * Pure, and browser-side too, so the settings screen can say precisely why a
36
+ * message would not have been answered.
37
+ *
38
+ * @module _lib/admin/mail/vacation
39
+ */
40
+
41
+ /** How often one sender may be told, by default. */
42
+ export const DEFAULT_INTERVAL_DAYS = 4;
43
+
44
+ /** The longest an away message may be. Longer is a document, not a notice. */
45
+ const MAX_BODY = 5000;
46
+
47
+ /**
48
+ * Strip anything that would end a header line.
49
+ *
50
+ * @param {*} value
51
+ * @returns {string}
52
+ */
53
+ function headerSafe(value) {
54
+ return String(value ?? '').replace(/[\r\n]+/g, ' ').trim();
55
+ }
56
+
57
+ /**
58
+ * An ISO timestamp, or null when it is not one.
59
+ *
60
+ * @param {*} value
61
+ * @returns {string|null}
62
+ */
63
+ function isoOrNull(value) {
64
+ if (!value) return null;
65
+ const when = new Date(String(value));
66
+ return Number.isNaN(when.getTime()) ? null : when.toISOString();
67
+ }
68
+
69
+ /**
70
+ * Put a stored away message into shape.
71
+ *
72
+ * @param {object|null|undefined} account
73
+ * @returns {object}
74
+ */
75
+ export function normaliseVacation(account) {
76
+ const source = account?.vacation && typeof account.vacation === 'object' ? account.vacation : {};
77
+ const days = Number(source.intervalDays);
78
+
79
+ return {
80
+ enabled: source.enabled === true,
81
+ subject: headerSafe(source.subject) || 'Out of office',
82
+ bodyHtml: String(source.bodyHtml ?? '').trim().slice(0, MAX_BODY),
83
+ bodyText: String(source.bodyText ?? '').trim().slice(0, MAX_BODY),
84
+ // Both optional. No start means "from now"; no end means "until I
85
+ // turn it off", which is the honest reading of leaving it blank.
86
+ from: isoOrNull(source.from),
87
+ to: isoOrNull(source.to),
88
+ intervalDays: Number.isFinite(days) && days >= 1 && days <= 30
89
+ ? Math.round(days)
90
+ : DEFAULT_INTERVAL_DAYS,
91
+ // Off by default. A responder that only answers people already in
92
+ // your address book is much safer, and much less useful - so it is a
93
+ // choice rather than a default either way.
94
+ knownSendersOnly: source.knownSendersOnly === true
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Is the away message switched on and inside its window?
100
+ *
101
+ * @param {object} vacation
102
+ * @param {Date} [now]
103
+ * @returns {boolean}
104
+ */
105
+ export function isActive(vacation, now = new Date()) {
106
+ const settings = normaliseVacation({vacation});
107
+ if (!settings.enabled) return false;
108
+ if (!settings.bodyHtml && !settings.bodyText) return false;
109
+
110
+ const at = now.getTime();
111
+ if (settings.from && at < new Date(settings.from).getTime()) return false;
112
+ if (settings.to && at > new Date(settings.to).getTime()) return false;
113
+ return true;
114
+ }
115
+
116
+ /** Local parts that are never a person waiting for a reply. */
117
+ const NEVER_REPLY_TO = [
118
+ 'mailer-daemon', 'postmaster', 'no-reply', 'noreply', 'donotreply',
119
+ 'do-not-reply', 'bounce', 'bounces', 'notifications', 'notification'
120
+ ];
121
+
122
+ /**
123
+ * Was this message sent by a machine rather than a person?
124
+ *
125
+ * Computed from the headers at fetch time and stored on the mirror row, so
126
+ * the responder does not need a second round trip to find out. Exported and
127
+ * tested on its own because it is the single check that stops a mail loop.
128
+ *
129
+ * @param {Map|object} headers - parsed or raw-parsed header map
130
+ * @returns {boolean}
131
+ */
132
+ export function looksAutomated(headers) {
133
+ const read = (name) => {
134
+ const value = typeof headers?.get === 'function' ? headers.get(name) : headers?.[name];
135
+ if (value && typeof value === 'object' && !Array.isArray(value)) return String(value.value ?? '');
136
+ return String(Array.isArray(value) ? value[0] ?? '' : value ?? '');
137
+ };
138
+
139
+ // RFC 3834. Anything other than an explicit "no" means a machine sent it,
140
+ // INCLUDING our own replies - which is what closes the loop.
141
+ const submitted = read('auto-submitted').trim().toLowerCase();
142
+ if (submitted && submitted !== 'no') return true;
143
+
144
+ if (/^(bulk|list|junk|auto_reply)$/i.test(read('precedence').trim())) return true;
145
+
146
+ // Any List-* header at all. A mailing list is never expecting a personal
147
+ // reply, and answering one posts to every subscriber.
148
+ for (const name of ['list-id', 'list-unsubscribe', 'list-post', 'list-help']) {
149
+ if (read(name).trim()) return true;
150
+ }
151
+
152
+ // An empty Return-Path is the null sender, which is how a bounce is
153
+ // addressed. There is nothing there to reply to.
154
+ const returnPath = read('return-path').trim();
155
+ if (returnPath === '<>' || returnPath === '') {
156
+ // Absent is not the same as empty - plenty of fetches simply do not
157
+ // include it - so only the explicit null sender counts.
158
+ if (returnPath === '<>') return true;
159
+ }
160
+
161
+ return false;
162
+ }
163
+
164
+ /**
165
+ * Is this address one that never wants an automatic reply?
166
+ *
167
+ * @param {string} address
168
+ * @returns {boolean}
169
+ */
170
+ export function isNoReplyAddress(address) {
171
+ const local = String(address ?? '').split('@')[0].toLowerCase().replace(/[._-]/g, '-');
172
+ return NEVER_REPLY_TO.some(name => local === name || local.startsWith(`${name}-`));
173
+ }
174
+
175
+ /**
176
+ * Should this message be answered automatically?
177
+ *
178
+ * Returns the reason either way. The settings screen shows it, which is the
179
+ * only way anybody ever finds out why their responder stayed quiet.
180
+ *
181
+ * @param {{message: object, vacation: object, addresses: string[],
182
+ * lastRepliedAt?: string|null, knownSender?: boolean, now?: Date}} options
183
+ * @returns {{reply: boolean, reason: string}}
184
+ */
185
+ export function shouldAutoReply({
186
+ message, vacation, addresses = [], lastRepliedAt = null, knownSender = false, now = new Date()
187
+ }) {
188
+ const settings = normaliseVacation({vacation});
189
+
190
+ if (!isActive(vacation, now)) return {reply: false, reason: 'The away message is not active.'};
191
+
192
+ const sender = (message?.from ?? [])[0]?.address ?? '';
193
+ if (!sender) return {reply: false, reason: 'The message has no sender address.'};
194
+
195
+ // 1. Machines, lists and anything that announced itself as automatic.
196
+ if (message?.automated === true) {
197
+ return {reply: false, reason: 'It was sent automatically, or to a mailing list.'};
198
+ }
199
+
200
+ // 2. Bounces and addresses that exist to send rather than receive.
201
+ if (isNoReplyAddress(sender)) {
202
+ return {reply: false, reason: `${sender} is not an address that accepts replies.`};
203
+ }
204
+
205
+ // 3. Ourselves. A mailbox answering its own mail loops with no second party.
206
+ const mine = new Set(addresses.map(a => String(a).trim().toLowerCase()).filter(Boolean));
207
+ if (mine.has(sender.toLowerCase())) {
208
+ return {reply: false, reason: 'It came from this mailbox.'};
209
+ }
210
+
211
+ // 4. Addressed to us, not merely delivered to us. Anything else is a blast
212
+ // we were Bcc'd on, and a blast must not be answered.
213
+ const recipients = [...(message?.to ?? []), ...(message?.cc ?? [])]
214
+ .map(entry => String(entry?.address ?? '').toLowerCase());
215
+ if (mine.size && !recipients.some(address => mine.has(address))) {
216
+ return {reply: false, reason: 'This mailbox was not in the To or Cc - it looks like a bulk send.'};
217
+ }
218
+
219
+ // 5. Once per sender per interval.
220
+ if (lastRepliedAt) {
221
+ const since = now.getTime() - new Date(lastRepliedAt).getTime();
222
+ if (since < settings.intervalDays * 86_400_000) {
223
+ return {reply: false, reason: `${sender} has already been told in the last ${settings.intervalDays} days.`};
224
+ }
225
+ }
226
+
227
+ if (settings.knownSendersOnly && !knownSender) {
228
+ return {reply: false, reason: `${sender} is not in your contacts.`};
229
+ }
230
+
231
+ return {reply: true, reason: `Replying to ${sender}.`};
232
+ }
233
+
234
+ /**
235
+ * The headers an automatic reply must carry.
236
+ *
237
+ * This is the other half of the loop prevention, and the half that protects
238
+ * everybody else: our reply announces itself as automatic so the responder at
239
+ * the far end refuses to answer it. Leaving these off is what turns two
240
+ * polite away messages into a mail loop.
241
+ *
242
+ * @returns {Object.<string, string>}
243
+ */
244
+ export function autoReplyHeaders() {
245
+ return {
246
+ 'Auto-Submitted': 'auto-replied',
247
+ // Understood by older software that predates RFC 3834.
248
+ 'Precedence': 'bulk',
249
+ 'X-Auto-Response-Suppress': 'All'
250
+ };
251
+ }
252
+
253
+ /**
254
+ * The subject an automatic reply carries.
255
+ *
256
+ * @param {object} vacation
257
+ * @param {string} originalSubject
258
+ * @returns {string}
259
+ */
260
+ export function replySubject(vacation, originalSubject) {
261
+ const settings = normaliseVacation({vacation});
262
+ const original = headerSafe(originalSubject);
263
+ if (!original) return settings.subject;
264
+ // The original subject, so it threads with what they sent, prefixed with
265
+ // the notice rather than replaced by it.
266
+ return /^re:\s*/i.test(original)
267
+ ? `${settings.subject}: ${original}`
268
+ : `${settings.subject}: Re: ${original}`;
269
+ }
@@ -0,0 +1 @@
1
+ .dm-help-marker{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;margin-left:.35rem;padding:0;flex:0 0 auto;vertical-align:middle;font-size:10px;font-weight:700;line-height:1;font-family:inherit;color:var(--dm-text-muted);background:transparent;border:1px solid var(--dm-border);border-radius:50%;cursor:help}.dm-help-marker:hover,.dm-help-marker:focus-visible{color:var(--dm-primary);border-color:var(--dm-primary)}label.form-label:has(>.dm-help-marker),legend:has(>.dm-help-marker){display:inline-flex;align-items:center}.dm-help-panel{padding:.25rem .25rem 1rem;display:flex;flex-direction:column;gap:.5rem}.dm-help-heading{margin:.75rem 0 0;font-size:.8rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--dm-text-muted)}.dm-help-panel p{margin:0}
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Inline help, as a thing every admin screen gets for free.
3
+ *
4
+ * A screen is not finished when it works; it is finished when somebody who has
5
+ * never seen it can work out what each control does without asking. So help is
6
+ * declarative and lives in the markup next to the thing it explains:
7
+ *
8
+ * <label class="form-label" data-help="Fills the due date on a new
9
+ * invoice. 0 leaves it blank.">Payment terms</label>
10
+ *
11
+ * and one `scanHelp(container)` after render wires every one of them. A hint
12
+ * written three lines from the field it describes is a hint that goes stale;
13
+ * written on the field, it cannot.
14
+ *
15
+ * ## Why tooltips
16
+ *
17
+ * Domma has no popover component - the E facade is card/modal/tabs/accordion/
18
+ * tooltip/badge/dropdown/carousel/slideover/contextMenu and friends - so a
19
+ * short hint is `E.tooltip` and anything longer is a slideover. `E.tooltip`
20
+ * physically WRAPS its element in `<domma-tooltip>`, so wiring one twice used
21
+ * to nest the wrappers and lose the tooltip entirely; it is idempotent from
22
+ * 0.29.2, and `scanHelp` marks what it has done anyway, because a view that is
23
+ * re-rendered calls it again.
24
+ *
25
+ * There is also no content fallback from `data-tooltip`, so the text is always
26
+ * passed explicitly.
27
+ *
28
+ * @module _lib/admin/ui/help
29
+ */
30
+
31
+ /** Marks an element whose help has already been wired. */
32
+ const DONE = 'dmHelpWired';
33
+
34
+ /**
35
+ * Attach a hint to one element.
36
+ *
37
+ * @param {HTMLElement} el
38
+ * @param {string} text
39
+ * @param {{position?: string}} [options]
40
+ * @returns {void}
41
+ */
42
+ export function helpTip(el, text, {position = 'top'} = {}) {
43
+ if (!el || !text || el.dataset[DONE]) return;
44
+ el.dataset[DONE] = '1';
45
+
46
+ if (typeof E?.tooltip === 'function') {
47
+ try {
48
+ E.tooltip(el, {content: text, position, delay: {show: 250, hide: 0}});
49
+ } catch {
50
+ // A tooltip that cannot be built is not worth losing the screen for.
51
+ }
52
+ }
53
+ // `title` as well, always: it is what a screen reader and a keyboard user
54
+ // get, and it is the whole hint on any host where the component is absent.
55
+ if (!el.title) el.title = text;
56
+ }
57
+
58
+ /**
59
+ * Append a small `?` marker carrying a hint.
60
+ *
61
+ * Its own element rather than a hint on the label, because a whole label that
62
+ * shows a tooltip on hover is a surprise - a marker is a thing you aim at.
63
+ *
64
+ * @param {HTMLElement} parent
65
+ * @param {string} text
66
+ * @param {{position?: string, label?: string}} [options]
67
+ * @returns {HTMLElement}
68
+ */
69
+ export function helpIcon(parent, text, {position = 'top', label = 'Help'} = {}) {
70
+ const marker = document.createElement('button');
71
+ marker.type = 'button';
72
+ marker.className = 'dm-help-marker';
73
+ marker.setAttribute('aria-label', label);
74
+ marker.textContent = '?';
75
+ // A help marker is not a form control and must never submit or steal a
76
+ // click from the field it sits beside.
77
+ marker.addEventListener('click', (e) => e.preventDefault());
78
+ parent.appendChild(marker);
79
+ helpTip(marker, text, {position});
80
+ return marker;
81
+ }
82
+
83
+ /**
84
+ * Wire every `data-help` under a root.
85
+ *
86
+ * On a `<label>`, a `<legend>` or anything carrying `data-help-marker`, the
87
+ * hint is hung off an appended `?`. On anything else - a button, an input, a
88
+ * whole card - it is hung off the element itself, because those are already
89
+ * things you point at.
90
+ *
91
+ * @param {HTMLElement|object} root - element or Domma collection
92
+ * @returns {number} how many were wired
93
+ */
94
+ export function scanHelp(root) {
95
+ const host = root && typeof root.get === 'function' ? root.get(0) : root;
96
+ if (!host || host.nodeType !== 1) return 0;
97
+
98
+ let wired = 0;
99
+ const targets = [...host.querySelectorAll('[data-help]')];
100
+ if (host.matches?.('[data-help]')) targets.unshift(host);
101
+
102
+ for (const el of targets) {
103
+ if (el.dataset[DONE]) continue;
104
+ const text = el.getAttribute('data-help');
105
+ if (!text) continue;
106
+ const position = el.getAttribute('data-help-position') || 'top';
107
+
108
+ const wantsMarker = el.hasAttribute('data-help-marker')
109
+ || el.tagName === 'LABEL' || el.tagName === 'LEGEND'
110
+ || /^H[1-6]$/.test(el.tagName);
111
+
112
+ if (wantsMarker) {
113
+ el.dataset[DONE] = '1';
114
+ helpIcon(el, text, {position, label: `Help: ${el.textContent.trim() || 'this field'}`});
115
+ } else {
116
+ helpTip(el, text, {position});
117
+ }
118
+ wired++;
119
+ }
120
+ return wired;
121
+ }
122
+
123
+ /**
124
+ * A longer explanation, in a slideover.
125
+ *
126
+ * For the things a one-line hint cannot carry - how numbering works, what a
127
+ * template does. `sections` is `[{title, body}]`, body being plain text or a
128
+ * list of lines.
129
+ *
130
+ * @param {{title: string, intro?: string, sections?: Array}} options
131
+ * @returns {object} the slideover, already open
132
+ */
133
+ export function openHelpPanel({title, intro = '', sections = []}) {
134
+ const slideover = E.slideover({
135
+ title,
136
+ size: 'md',
137
+ position: 'right',
138
+ // close() only hides and destroy() does not detach, so both, once the
139
+ // slide-out has run - otherwise every open leaves a panel behind.
140
+ onClose: () => setTimeout(() => {
141
+ slideover.destroy();
142
+ slideover.element?.remove();
143
+ }, 350)
144
+ });
145
+
146
+ const wrap = document.createElement('div');
147
+ wrap.className = 'dm-help-panel';
148
+
149
+ if (intro) {
150
+ const p = document.createElement('p');
151
+ p.className = 'text-sm text-muted';
152
+ p.textContent = intro;
153
+ wrap.appendChild(p);
154
+ }
155
+
156
+ for (const section of sections) {
157
+ if (section.title) {
158
+ const h = document.createElement('h4');
159
+ h.className = 'dm-help-heading';
160
+ h.textContent = section.title;
161
+ wrap.appendChild(h);
162
+ }
163
+ for (const line of [].concat(section.body ?? [])) {
164
+ const p = document.createElement('p');
165
+ p.className = 'text-sm';
166
+ p.textContent = line;
167
+ wrap.appendChild(p);
168
+ }
169
+ }
170
+
171
+ slideover.element.appendChild(wrap);
172
+ slideover.open();
173
+ return slideover;
174
+ }