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.
- package/CLAUDE.md +151 -77
- package/README.md +4 -4
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +3 -3
- package/admin/js/lib/page-picker.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/slideover-resizable.js +1 -0
- package/admin/js/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/menu-editor.html +19 -25
- package/admin/js/templates/plugin-code.html +1 -1
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +27 -97
- package/admin/js/templates/theme.html +173 -0
- package/admin/js/views/context-menu-editor.js +55 -0
- package/admin/js/views/context-menus.js +5 -0
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/menu-editor.js +13 -13
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +25 -23
- package/admin/js/views/search.js +1 -0
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -0
- package/bin/cli.js +11 -2
- package/bin/lib/smtp-defaults.js +53 -0
- package/bin/update.js +13 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/theme.json +18 -0
- package/package.json +12 -4
- package/plugins/_lib/admin/mail/compose-window.js +914 -0
- package/plugins/_lib/admin/mail/contacts.js +301 -0
- package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
- package/plugins/_lib/admin/mail/folder-tree.js +254 -0
- package/plugins/_lib/admin/mail/identity.js +480 -0
- package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
- package/plugins/_lib/admin/mail/keyboard.js +136 -0
- package/plugins/_lib/admin/mail/mail.css +1 -0
- package/plugins/_lib/admin/mail/mail.html +71 -0
- package/plugins/_lib/admin/mail/panes.js +253 -0
- package/plugins/_lib/admin/mail/reader-view.js +4453 -0
- package/plugins/_lib/admin/mail/resizable.js +26 -0
- package/plugins/_lib/admin/mail/rules.js +343 -0
- package/plugins/_lib/admin/mail/scheduling.js +203 -0
- package/plugins/_lib/admin/mail/section-kit.js +277 -0
- package/plugins/_lib/admin/mail/templates.js +238 -0
- package/plugins/_lib/admin/mail/threads.js +200 -0
- package/plugins/_lib/admin/mail/vacation.js +269 -0
- package/plugins/_lib/admin/ui/help.css +1 -0
- package/plugins/_lib/admin/ui/help.js +174 -0
- package/plugins/_lib/admin/ui/resizable.js +151 -0
- package/plugins/_lib/dataStore.js +117 -0
- package/plugins/_lib/mail/accounts.js +919 -0
- package/plugins/_lib/mail/bodyTokens.js +101 -0
- package/plugins/_lib/mail/compose.js +256 -0
- package/plugins/_lib/mail/defaults.js +57 -0
- package/plugins/_lib/mail/diagnostics.js +154 -0
- package/plugins/_lib/mail/envelope.js +161 -0
- package/plugins/_lib/mail/folders.js +192 -0
- package/plugins/_lib/mail/handoff.js +274 -0
- package/plugins/_lib/mail/imapPool.js +293 -0
- package/plugins/_lib/mail/mbox.js +74 -0
- package/plugins/_lib/mail/pollSchedule.js +79 -0
- package/plugins/_lib/mail/poller.js +135 -0
- package/plugins/_lib/mail/priority.js +138 -0
- package/plugins/_lib/mail/readRoutes.js +680 -0
- package/plugins/_lib/mail/render.js +291 -0
- package/plugins/_lib/mail/ruleRunner.js +152 -0
- package/plugins/_lib/mail/scheduler.js +254 -0
- package/plugins/_lib/mail/secretbox.js +229 -0
- package/plugins/_lib/mail/send.js +396 -0
- package/plugins/_lib/mail/store.js +1002 -0
- package/plugins/_lib/mail/sync.js +292 -0
- package/plugins/_lib/mail/syncPlan.js +126 -0
- package/plugins/_lib/mail/syncSelection.js +82 -0
- package/plugins/_lib/mail/unsubscribe.js +183 -0
- package/plugins/_lib/mail/vacationRunner.js +114 -0
- package/plugins/_lib/mail/write.js +473 -0
- package/plugins/_lib/schemaSync.js +83 -0
- package/plugins/_template/admin/css/index.css +0 -0
- package/plugins/_template/admin/templates/index.html +4 -4
- package/plugins/_template/admin/views/index.js +7 -0
- package/plugins/analytics/admin/css/index.css +1 -0
- package/plugins/analytics/admin/templates/analytics.html +22 -13
- package/plugins/analytics/plugin.json +3 -0
- package/plugins/blog/admin/css/index.css +1 -0
- package/plugins/blog/admin/templates/blog.html +30 -18
- package/plugins/blog/admin/templates/categories.html +2 -2
- package/plugins/blog/admin/templates/comments.html +2 -2
- package/plugins/blog/admin/templates/post-editor.html +34 -34
- package/plugins/blog/admin/templates/settings.html +6 -3
- package/plugins/blog/admin/views/blog.js +8 -5
- package/plugins/blog/admin/views/categories.js +5 -10
- package/plugins/blog/admin/views/comments.js +5 -5
- package/plugins/blog/admin/views/post-editor.js +39 -20
- package/plugins/blog/admin/views/settings.js +52 -50
- package/plugins/blog/collections/categories/schema.json +7 -6
- package/plugins/blog/collections/comments/schema.json +11 -10
- package/plugins/blog/collections/posts/schema.json +14 -13
- package/plugins/blog/plugin.js +36 -13
- package/plugins/blog/plugin.json +13 -5
- package/plugins/blog/plugin.public.js +312 -0
- package/plugins/contacts/admin/css/index.css +1 -0
- package/plugins/contacts/admin/templates/contacts.html +128 -0
- package/plugins/contacts/admin/views/contacts.js +237 -4
- package/plugins/contacts/collections/user-contacts/schema.json +108 -0
- package/plugins/contacts/plugin.js +214 -27
- package/plugins/contacts/plugin.json +4 -1
- package/plugins/invoice/admin/css/index.css +1 -0
- package/plugins/invoice/admin/templates/editor.html +140 -49
- package/plugins/invoice/admin/templates/index.html +153 -23
- package/plugins/invoice/admin/templates/issuers.html +2 -5
- package/plugins/invoice/admin/templates/receivers.html +2 -5
- package/plugins/invoice/admin/views/contacts-source.js +266 -0
- package/plugins/invoice/admin/views/editor.js +366 -199
- package/plugins/invoice/admin/views/export.js +199 -0
- package/plugins/invoice/admin/views/help-content.js +61 -0
- package/plugins/invoice/admin/views/index.js +582 -94
- package/plugins/invoice/admin/views/issuers.js +24 -17
- package/plugins/invoice/admin/views/media.js +172 -0
- package/plugins/invoice/admin/views/party-view.js +305 -67
- package/plugins/invoice/admin/views/payments.js +127 -0
- package/plugins/invoice/admin/views/print.js +130 -0
- package/plugins/invoice/admin/views/receivers.js +49 -16
- package/plugins/invoice/admin/views/send.js +212 -0
- package/plugins/invoice/admin/views/settings.js +594 -0
- package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
- package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
- package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
- package/plugins/invoice/collections/invoices/schema.json +19 -13
- package/plugins/invoice/config.js +27 -6
- package/plugins/invoice/pdf.js +164 -0
- package/plugins/invoice/plugin.js +1217 -44
- package/plugins/invoice/plugin.json +10 -9
- package/plugins/invoice/templates/_base.css +1 -0
- package/plugins/invoice/templates/classic-nologo.html +100 -0
- package/plugins/invoice/templates/classic.html +91 -0
- package/plugins/invoice/templates/invoice-print.html +24 -0
- package/plugins/invoice/templates/minimal.html +99 -0
- package/plugins/invoice/templates/modern-nologo.html +114 -0
- package/plugins/invoice/templates/modern.html +113 -0
- package/plugins/invoice/templates/templates.json +11 -0
- package/plugins/mail-reader/admin/views/mail.js +19 -0
- package/plugins/mail-reader/config.js +7 -0
- package/plugins/mail-reader/plugin.js +48 -0
- package/plugins/mail-reader/plugin.json +33 -0
- package/plugins/notes/admin/views/notes.js +1 -1
- package/plugins/notes/plugin.json +2 -2
- package/plugins/surveys/lib/audience.js +37 -0
- package/plugins/surveys/lib/campaigns.js +43 -0
- package/plugins/surveys/lib/ledger.js +110 -0
- package/plugins/surveys/lib/sending.js +106 -0
- package/plugins/surveys/lib/stats.js +62 -0
- package/plugins/surveys/lib/submit.js +95 -0
- package/plugins/surveys/lib/tokens.js +28 -0
- package/plugins/surveys/plugin.public.js +149 -0
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
- package/public/css/forms.css +1 -1
- package/public/css/menu-highlight.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/menu-decor.mjs +1 -1
- package/public/js/search.js +1 -0
- package/public/js/site.js +1 -1
- package/scripts/build.js +37 -3
- package/scripts/copy-domma.js +48 -0
- package/scripts/seed.js +1996 -0
- package/scripts/setup.js +8 -0
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/forms.js +42 -3
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugins.js +50 -6
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +74 -0
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/email.js +29 -3
- package/server/services/health.js +23 -2
- package/server/services/markdown.js +70 -9
- package/server/services/menuRender.js +28 -4
- package/server/services/menus.js +25 -1
- package/server/services/permissionRegistry.js +24 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/plugins.js +229 -6
- package/server/services/renderer.js +148 -22
- package/server/services/roles.js +1 -1
- package/server/services/search-migration.js +82 -0
- package/server/services/search.js +413 -0
- package/server/services/sidebar-migration.js +1 -0
- package/server/services/themeSettings.js +541 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/notes/data/notes.json +0 -1
- package/plugins/site-search/admin/views/site-search.js +0 -116
- package/plugins/site-search/config.js +0 -15
- package/plugins/site-search/plugin.js +0 -188
- package/plugins/site-search/plugin.json +0 -40
- package/plugins/site-search/public/inject-body.html +0 -17
- package/plugins/site-search/public/inject-head.html +0 -1
- package/plugins/site-search/public/search.css +0 -1
- package/plugins/site-search/public/search.js +0 -1
- 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(/ /gi, ' ')
|
|
120
|
+
.replace(/&/gi, '&')
|
|
121
|
+
.replace(/</gi, '<')
|
|
122
|
+
.replace(/>/gi, '>')
|
|
123
|
+
.replace(/"/gi, '"')
|
|
124
|
+
.replace(/'|'/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, '&')
|
|
139
|
+
.replace(/</g, '<')
|
|
140
|
+
.replace(/>/g, '>')
|
|
141
|
+
.replace(/"/g, '"');
|
|
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};
|