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,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Short-lived tokens authorising one message body fetch.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* A message renders inside an iframe. The obvious way to fill that iframe is
|
|
7
|
+
* `srcdoc`, and that is what the reader did first - but a `srcdoc` document
|
|
8
|
+
* **inherits the embedding page's Content-Security-Policy**, and the admin is
|
|
9
|
+
* served with `img-src 'self' data: blob:`. The consequence was that "Load
|
|
10
|
+
* images" could flip the `src` to the real URL and the browser would then
|
|
11
|
+
* refuse to fetch it, silently, with no request ever made. The feature could
|
|
12
|
+
* not work, however correct the server was.
|
|
13
|
+
*
|
|
14
|
+
* A document loaded over the network carries its own CSP instead of
|
|
15
|
+
* inheriting, so the body is served from a real route. That route cannot read
|
|
16
|
+
* an Authorization header - an `<iframe src>` sends none - so the URL carries
|
|
17
|
+
* a signed token that says exactly which message, for which user, and for how
|
|
18
|
+
* long. Same shape as the CMS's own preview links.
|
|
19
|
+
*
|
|
20
|
+
* The token is deliberately narrow: it names one mailbox, one folder and one
|
|
21
|
+
* message, it expires in minutes, and it grants nothing else.
|
|
22
|
+
*
|
|
23
|
+
* @module _lib/mail/bodyTokens
|
|
24
|
+
*/
|
|
25
|
+
import {createHmac, timingSafeEqual} from 'node:crypto';
|
|
26
|
+
|
|
27
|
+
import {resolveKeySource} from './secretbox.js';
|
|
28
|
+
|
|
29
|
+
/** Domain separation, so this key can never collide with the sealing key. */
|
|
30
|
+
const DOMAIN = 'domma-mail-body-v1';
|
|
31
|
+
|
|
32
|
+
/** How long a token stays valid. Long enough to load a page, short enough not to matter. */
|
|
33
|
+
const TTL_MS = 120_000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Sign a payload.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} body - base64url payload
|
|
39
|
+
* @returns {string} base64url signature
|
|
40
|
+
*/
|
|
41
|
+
function signature(body) {
|
|
42
|
+
const {secret} = resolveKeySource();
|
|
43
|
+
return createHmac('sha256', DOMAIN + ':' + secret).update(body).digest('base64url');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Mint a token authorising one message body fetch.
|
|
48
|
+
*
|
|
49
|
+
* @param {{userId: string, accountId: string, folder: string, uid: number, images: boolean}} grant
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function sign(grant) {
|
|
53
|
+
const payload = {
|
|
54
|
+
u: grant.userId,
|
|
55
|
+
a: grant.accountId,
|
|
56
|
+
f: grant.folder,
|
|
57
|
+
m: grant.uid,
|
|
58
|
+
i: grant.images ? 1 : 0,
|
|
59
|
+
e: Date.now() + TTL_MS
|
|
60
|
+
};
|
|
61
|
+
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
62
|
+
return `${body}.${signature(body)}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check a token and return what it authorises.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} token
|
|
69
|
+
* @returns {{userId: string, accountId: string, folder: string, uid: number, images: boolean}|null}
|
|
70
|
+
*/
|
|
71
|
+
export function verify(token) {
|
|
72
|
+
if (typeof token !== 'string' || !token.includes('.')) return null;
|
|
73
|
+
|
|
74
|
+
const [body, provided] = token.split('.');
|
|
75
|
+
if (!body || !provided) return null;
|
|
76
|
+
|
|
77
|
+
const expected = signature(body);
|
|
78
|
+
// Compare in constant time, and only when the lengths already match -
|
|
79
|
+
// timingSafeEqual throws on a length mismatch.
|
|
80
|
+
const a = Buffer.from(provided);
|
|
81
|
+
const b = Buffer.from(expected);
|
|
82
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
|
83
|
+
|
|
84
|
+
let payload;
|
|
85
|
+
try {
|
|
86
|
+
payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (typeof payload?.e !== 'number' || Date.now() > payload.e) return null;
|
|
92
|
+
if (!payload.u || !payload.a || typeof payload.m !== 'number') return null;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
userId: payload.u,
|
|
96
|
+
accountId: payload.a,
|
|
97
|
+
folder: typeof payload.f === 'string' ? payload.f : 'INBOX',
|
|
98
|
+
uid: payload.m,
|
|
99
|
+
images: payload.i === 1
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a message into a reply, and a reply into something sendable.
|
|
3
|
+
*
|
|
4
|
+
* Pure, and tested on its own, because the parts that go wrong here are not
|
|
5
|
+
* the sending - nodemailer does that - but the conventions around it. A reply
|
|
6
|
+
* that loses its threading headers starts a new conversation in every client
|
|
7
|
+
* that receives it, a reply-all that includes you talks to yourself, and a
|
|
8
|
+
* quote that drops the attribution line reads as if you wrote it.
|
|
9
|
+
*
|
|
10
|
+
* @module _lib/mail/compose
|
|
11
|
+
*/
|
|
12
|
+
import {readPriority} from './priority.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Addresses to reply to.
|
|
16
|
+
*
|
|
17
|
+
* Reply-To wins over From when present: a sender who sets it is asking for
|
|
18
|
+
* replies somewhere else, and ignoring that is how replies to a mailing list
|
|
19
|
+
* end up in one person's inbox.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} message
|
|
22
|
+
* @returns {{name: string, address: string}[]}
|
|
23
|
+
*/
|
|
24
|
+
export function replyRecipients(message) {
|
|
25
|
+
const replyTo = message.replyTo ?? [];
|
|
26
|
+
if (replyTo.length) return replyTo;
|
|
27
|
+
return message.from ?? [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Everyone a reply-all should reach.
|
|
32
|
+
*
|
|
33
|
+
* The sender plus the original recipients, minus you - a reply-all that
|
|
34
|
+
* includes your own address sends you a copy of your own mail, which every
|
|
35
|
+
* client then has to file.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} message
|
|
38
|
+
* @param {string} self - the address replying
|
|
39
|
+
* @returns {{to: object[], cc: object[]}}
|
|
40
|
+
*/
|
|
41
|
+
export function replyAllRecipients(message, self) {
|
|
42
|
+
const mine = String(self ?? '').trim().toLowerCase();
|
|
43
|
+
const notMine = list => (list ?? []).filter(a => String(a.address ?? '').toLowerCase() !== mine);
|
|
44
|
+
|
|
45
|
+
const to = replyRecipients(message);
|
|
46
|
+
const seen = new Set(to.map(a => String(a.address ?? '').toLowerCase()));
|
|
47
|
+
|
|
48
|
+
const cc = [...notMine(message.to), ...notMine(message.cc)]
|
|
49
|
+
.filter(a => {
|
|
50
|
+
const key = String(a.address ?? '').toLowerCase();
|
|
51
|
+
if (!key || seen.has(key)) return false;
|
|
52
|
+
seen.add(key);
|
|
53
|
+
return true;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return {to: notMine(to), cc};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The subject a reply carries.
|
|
61
|
+
*
|
|
62
|
+
* Only one "Re:" however many times a thread goes round, and the existing
|
|
63
|
+
* prefix is recognised case-insensitively because clients disagree about it.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} subject
|
|
66
|
+
* @param {string} [prefix]
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
export function replySubject(subject, prefix = 'Re') {
|
|
70
|
+
const clean = String(subject ?? '').trim();
|
|
71
|
+
const pattern = new RegExp(`^${prefix}:\\s*`, 'i');
|
|
72
|
+
return pattern.test(clean) ? clean : `${prefix}: ${clean}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The References header for a reply.
|
|
77
|
+
*
|
|
78
|
+
* A thread is reconstructed from this chain, so it carries everything the
|
|
79
|
+
* parent carried plus the parent itself. Trimmed from the front when it grows
|
|
80
|
+
* long, keeping the root - which is what identifies the thread - and the most
|
|
81
|
+
* recent, which is what identifies the position in it.
|
|
82
|
+
*
|
|
83
|
+
* @param {{messageId?: string|null, references?: string|string[]|null}} parent
|
|
84
|
+
* @param {number} [limit]
|
|
85
|
+
* @returns {string[]}
|
|
86
|
+
*/
|
|
87
|
+
export function buildReferences(parent, limit = 20) {
|
|
88
|
+
const existing = Array.isArray(parent?.references)
|
|
89
|
+
? parent.references
|
|
90
|
+
: String(parent?.references ?? '').split(/\s+/).filter(Boolean);
|
|
91
|
+
|
|
92
|
+
const chain = [...existing];
|
|
93
|
+
if (parent?.messageId) chain.push(parent.messageId);
|
|
94
|
+
|
|
95
|
+
const unique = [...new Set(chain)];
|
|
96
|
+
if (unique.length <= limit) return unique;
|
|
97
|
+
return [unique[0], ...unique.slice(-(limit - 1))];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The attribution line above a quote.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} message
|
|
104
|
+
* @returns {string}
|
|
105
|
+
*/
|
|
106
|
+
export function attributionLine(message) {
|
|
107
|
+
const who = (message.from ?? [])[0];
|
|
108
|
+
const name = who?.name || who?.address || 'someone';
|
|
109
|
+
const when = message.date ? new Date(message.date).toUTCString() : 'an earlier message';
|
|
110
|
+
return `On ${when}, ${name} wrote:`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Quote a message as plain text.
|
|
115
|
+
*
|
|
116
|
+
* Quoting the text part, never the HTML: quoting HTML means either embedding
|
|
117
|
+
* someone else's markup in your message or stripping it badly, and every mail
|
|
118
|
+
* client in existence understands "> ".
|
|
119
|
+
*
|
|
120
|
+
* @param {object} message
|
|
121
|
+
* @param {string} body
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
export function quoteText(message, body) {
|
|
125
|
+
const quoted = String(body ?? '')
|
|
126
|
+
.replace(/\r\n/g, '\n')
|
|
127
|
+
.split('\n')
|
|
128
|
+
.map(line => (line.startsWith('>') ? `>${line}` : `> ${line}`))
|
|
129
|
+
.join('\n');
|
|
130
|
+
return `\n\n${attributionLine(message)}\n${quoted}\n`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Quote a message as HTML.
|
|
135
|
+
*
|
|
136
|
+
* A blockquote, which is what every client renders as a quote, around the
|
|
137
|
+
* original's own markup once it has been through the same sanitiser the
|
|
138
|
+
* reader uses. Remote images are dropped rather than blocked: a quote should
|
|
139
|
+
* not carry a tracking pixel onward to everyone on the reply.
|
|
140
|
+
*
|
|
141
|
+
* @param {object} message
|
|
142
|
+
* @param {string} html - the original's HTML, or null
|
|
143
|
+
* @param {string} text - the original's text, used when there is no HTML
|
|
144
|
+
* @param {(fragment: string) => string} sanitise
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
export function quoteHtml(message, html, text, sanitise) {
|
|
148
|
+
const escape = value => String(value ?? '')
|
|
149
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
150
|
+
|
|
151
|
+
const inner = html
|
|
152
|
+
? sanitise(html)
|
|
153
|
+
: `<pre style="white-space:pre-wrap;font-family:inherit;margin:0">${escape(text)}</pre>`;
|
|
154
|
+
|
|
155
|
+
return `<p><br></p><p>${escape(attributionLine(message))}</p>`
|
|
156
|
+
+ `<blockquote style="margin:0 0 0 .8em;padding-left:.8em;border-left:3px solid #d0d0d0">${inner}</blockquote>`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Build a complete draft from a message being answered.
|
|
161
|
+
*
|
|
162
|
+
* @param {{message: object, self: string, mode: 'reply'|'replyAll'|'forward', body: string}} options
|
|
163
|
+
* @returns {object} a draft the compose window can open with
|
|
164
|
+
*/
|
|
165
|
+
export function buildDraft({message, self, mode, body, html = null, sanitise = null}) {
|
|
166
|
+
const htmlQuote = sanitise ? quoteHtml(message, html, body, sanitise) : null;
|
|
167
|
+
|
|
168
|
+
if (mode === 'forward') {
|
|
169
|
+
return {
|
|
170
|
+
to: [],
|
|
171
|
+
cc: [],
|
|
172
|
+
subject: replySubject(message.subject, 'Fwd'),
|
|
173
|
+
// A forward carries no In-Reply-To: it is not an answer to the
|
|
174
|
+
// message, and claiming otherwise files it into a thread the new
|
|
175
|
+
// recipient has never seen.
|
|
176
|
+
inReplyTo: null,
|
|
177
|
+
references: [],
|
|
178
|
+
body: quoteText(message, body),
|
|
179
|
+
html: htmlQuote
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const {to, cc} = mode === 'replyAll'
|
|
184
|
+
? replyAllRecipients(message, self)
|
|
185
|
+
: {to: replyRecipients(message), cc: []};
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
to,
|
|
189
|
+
cc,
|
|
190
|
+
subject: replySubject(message.subject),
|
|
191
|
+
inReplyTo: message.messageId ?? null,
|
|
192
|
+
references: buildReferences(message),
|
|
193
|
+
body: quoteText(message, body),
|
|
194
|
+
html: htmlQuote
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Turn a saved draft back into something the compose window can open.
|
|
200
|
+
*
|
|
201
|
+
* Not `buildDraft`, and deliberately not built on it: a reply derives its
|
|
202
|
+
* recipients, subject and quoted body FROM a message, whereas reopening a
|
|
203
|
+
* draft means taking the message verbatim - it already is the thing being
|
|
204
|
+
* written. Routing it through the reply arithmetic would quote the draft
|
|
205
|
+
* inside itself and address it to whoever it was already addressed to.
|
|
206
|
+
*
|
|
207
|
+
* `savedUid` is what makes the round trip work: saving again supersedes this
|
|
208
|
+
* version rather than adding a second one, and sending tidies it away. Without
|
|
209
|
+
* it, editing a draft four times leaves four drafts.
|
|
210
|
+
*
|
|
211
|
+
* @param {object} message - a parsed draft (mailparser shape, flattened)
|
|
212
|
+
* @param {number|null} uid - the draft's UID in the Drafts folder
|
|
213
|
+
* @returns {object}
|
|
214
|
+
*/
|
|
215
|
+
export function editableDraft(message, uid = null) {
|
|
216
|
+
const addresses = list => (list ?? []).map(a => ({
|
|
217
|
+
name: a.name ?? '', address: a.address ?? ''
|
|
218
|
+
}));
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
to: addresses(message.to),
|
|
222
|
+
cc: addresses(message.cc),
|
|
223
|
+
// A draft can have one; a received message never does. Dropping it on
|
|
224
|
+
// reopen would silently change who a half-written message goes to.
|
|
225
|
+
bcc: addresses(message.bcc),
|
|
226
|
+
subject: message.subject ?? '',
|
|
227
|
+
// Kept, so replying to the thread the draft belongs to survives a save
|
|
228
|
+
// and reopen. A draft written as a reply is still a reply.
|
|
229
|
+
inReplyTo: message.inReplyTo ?? null,
|
|
230
|
+
references: normaliseReferences(message.references),
|
|
231
|
+
body: message.text ?? '',
|
|
232
|
+
html: message.html || null,
|
|
233
|
+
// Kept, or a draft marked urgent would quietly become ordinary the
|
|
234
|
+
// moment it was reopened.
|
|
235
|
+
priority: readPriority(message.headers),
|
|
236
|
+
attachments: (message.attachments ?? []).map(file => ({
|
|
237
|
+
filename: file.filename ?? 'attachment',
|
|
238
|
+
contentType: file.contentType ?? 'application/octet-stream',
|
|
239
|
+
content: file.content ?? '',
|
|
240
|
+
size: file.size ?? 0
|
|
241
|
+
})),
|
|
242
|
+
savedUid: uid
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* References arrive as a string on one server and an array on the next.
|
|
248
|
+
*
|
|
249
|
+
* @param {string|string[]|null|undefined} value
|
|
250
|
+
* @returns {string[]}
|
|
251
|
+
*/
|
|
252
|
+
function normaliseReferences(value) {
|
|
253
|
+
if (Array.isArray(value)) return value.filter(Boolean);
|
|
254
|
+
if (typeof value === 'string') return value.split(/\s+/).filter(Boolean);
|
|
255
|
+
return [];
|
|
256
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading defaults, shared by every edition.
|
|
3
|
+
*
|
|
4
|
+
* `mail-reader` uses these as-is; `email-pro` spreads them and adds what its
|
|
5
|
+
* synchronised store needs. Merged with user overrides from
|
|
6
|
+
* config/plugins.json by whichever plugin is loading.
|
|
7
|
+
*
|
|
8
|
+
* @module _lib/mail/defaults
|
|
9
|
+
*/
|
|
10
|
+
export default {
|
|
11
|
+
/** Messages per page in the list pane. */
|
|
12
|
+
listLimit: 50,
|
|
13
|
+
|
|
14
|
+
/** Hard ceiling on ?limit=, so a crafted request cannot ask for a whole mailbox. */
|
|
15
|
+
maxListLimit: 200,
|
|
16
|
+
|
|
17
|
+
/** How long an unused IMAP connection is kept before it is closed. */
|
|
18
|
+
connectionIdleMs: 300_000,
|
|
19
|
+
|
|
20
|
+
/** Most simultaneous pooled connections. The least recently used is dropped beyond this. */
|
|
21
|
+
maxConnections: 20,
|
|
22
|
+
|
|
23
|
+
/** Connect, greeting and socket timeout. A dead server should fail fast, not hang the admin. */
|
|
24
|
+
connectionTimeoutMs: 20_000,
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Load remote images in message bodies by default.
|
|
28
|
+
*
|
|
29
|
+
* Off, deliberately: a remote image in an email is usually a tracking
|
|
30
|
+
* pixel, and loading it reports the open back to the sender. The reader
|
|
31
|
+
* offers a per-message override that is never persisted.
|
|
32
|
+
*/
|
|
33
|
+
allowRemoteImages: false,
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Accept TLS certificates that do not verify.
|
|
37
|
+
*
|
|
38
|
+
* Only for a mail server with a self-signed certificate that you control.
|
|
39
|
+
* It disables the check that the server is who it says it is.
|
|
40
|
+
*/
|
|
41
|
+
allowInsecureTLS: false,
|
|
42
|
+
|
|
43
|
+
/** Largest inline image embedded into a rendered body, in bytes. */
|
|
44
|
+
inlineImageMaxBytes: 2_000_000,
|
|
45
|
+
|
|
46
|
+
/** Largest message source fetched for rendering or attachment extraction, in bytes. */
|
|
47
|
+
maxMessageBytes: 25_000_000,
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Largest total attachment payload on an outgoing message, in bytes.
|
|
51
|
+
*
|
|
52
|
+
* Twenty megabytes, because most providers refuse somewhere around
|
|
53
|
+
* twenty-five and it is better to be told here than to have the message
|
|
54
|
+
* accepted and then bounced an hour later.
|
|
55
|
+
*/
|
|
56
|
+
maxAttachmentBytes: 20_000_000
|
|
57
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the server knows about itself, for a bug report.
|
|
3
|
+
*
|
|
4
|
+
* Built because the expensive failures here were never "what does the code
|
|
5
|
+
* do" - they were "which build is that browser actually running", which no
|
|
6
|
+
* amount of reading the source answers. This reports the facts that settle
|
|
7
|
+
* that question, so a report can start from evidence instead of a description.
|
|
8
|
+
*
|
|
9
|
+
* Nothing secret goes in. Hostnames and usernames are needed to tell one
|
|
10
|
+
* mailbox from another; passwords, sealed or otherwise, are not, and the key
|
|
11
|
+
* SOURCE is reported without the key.
|
|
12
|
+
*
|
|
13
|
+
* @module _lib/mail/diagnostics
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs/promises';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import {fileURLToPath} from 'node:url';
|
|
18
|
+
|
|
19
|
+
import * as accounts from './accounts.js';
|
|
20
|
+
import {resolveKeySource} from './secretbox.js';
|
|
21
|
+
|
|
22
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
|
|
24
|
+
/** The browser-facing files whose versions decide what a page is running. */
|
|
25
|
+
const SHARED_ASSETS = [
|
|
26
|
+
'reader-view.js', 'folder-tree.js', 'panes.js',
|
|
27
|
+
'image-senders-section.js', 'diagnostics-section.js',
|
|
28
|
+
'mail.html', 'mail.css'
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The cache-busting token each shared asset would be served with.
|
|
33
|
+
*
|
|
34
|
+
* Compared against what the browser actually loaded, this is the difference
|
|
35
|
+
* between "stale cache" and "real bug" - a distinction that cost most of a
|
|
36
|
+
* day to make by hand.
|
|
37
|
+
*
|
|
38
|
+
* @returns {Promise<Object.<string, number|null>>}
|
|
39
|
+
*/
|
|
40
|
+
async function sharedAssetVersions() {
|
|
41
|
+
const dir = path.join(__dirname, '..', 'admin', 'mail');
|
|
42
|
+
const out = {};
|
|
43
|
+
await Promise.all(SHARED_ASSETS.map(async name => {
|
|
44
|
+
try {
|
|
45
|
+
out[name] = Math.floor((await fs.stat(path.join(dir, name))).mtimeMs);
|
|
46
|
+
} catch {
|
|
47
|
+
out[name] = null;
|
|
48
|
+
}
|
|
49
|
+
}));
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The plugin's declared version.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} pluginName
|
|
57
|
+
* @returns {Promise<string|null>}
|
|
58
|
+
*/
|
|
59
|
+
async function pluginVersion(pluginName) {
|
|
60
|
+
try {
|
|
61
|
+
const raw = await fs.readFile(path.join(__dirname, '..', '..', pluginName, 'plugin.json'), 'utf8');
|
|
62
|
+
return JSON.parse(raw).version ?? null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The stamp the loader would put on this plugin's view entry.
|
|
70
|
+
*
|
|
71
|
+
* The newest of the entry itself and the shared admin library, which is what
|
|
72
|
+
* makes a change to shared code reach a browser. Reported so a page can
|
|
73
|
+
* compare what it loaded against what it should have.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} pluginName
|
|
76
|
+
* @returns {Promise<number>}
|
|
77
|
+
*/
|
|
78
|
+
async function expectedStamp(pluginName) {
|
|
79
|
+
const dir = path.join(__dirname, '..', 'admin');
|
|
80
|
+
let newest = 0;
|
|
81
|
+
|
|
82
|
+
const walk = async current => {
|
|
83
|
+
let entries;
|
|
84
|
+
try {
|
|
85
|
+
entries = await fs.readdir(current, {withFileTypes: true});
|
|
86
|
+
} catch {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
const full = path.join(current, entry.name);
|
|
91
|
+
if (entry.isDirectory()) await walk(full);
|
|
92
|
+
else {
|
|
93
|
+
try {
|
|
94
|
+
newest = Math.max(newest, Math.floor((await fs.stat(full)).mtimeMs));
|
|
95
|
+
} catch { /* ignore */ }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
await walk(dir);
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const entry = path.join(__dirname, '..', '..', pluginName, 'admin', 'views', 'mail.js');
|
|
103
|
+
newest = Math.max(newest, Math.floor((await fs.stat(entry)).mtimeMs));
|
|
104
|
+
} catch { /* the plugin may name its view differently */ }
|
|
105
|
+
|
|
106
|
+
return newest;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Assemble the report for one user.
|
|
111
|
+
*
|
|
112
|
+
* @param {{pluginName: string, userId: string, config: object,
|
|
113
|
+
* extra?: () => Promise<object>|object}} options
|
|
114
|
+
* @returns {Promise<object>}
|
|
115
|
+
*/
|
|
116
|
+
export async function buildDiagnostics({pluginName, userId, config, extra = null}) {
|
|
117
|
+
const owned = await accounts.listAccounts(userId);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
generatedAt: new Date().toISOString(),
|
|
121
|
+
plugin: {
|
|
122
|
+
name: pluginName,
|
|
123
|
+
version: await pluginVersion(pluginName)
|
|
124
|
+
},
|
|
125
|
+
runtime: {
|
|
126
|
+
node: process.version,
|
|
127
|
+
uptimeSeconds: Math.round(process.uptime())
|
|
128
|
+
},
|
|
129
|
+
assets: {...(await sharedAssetVersions()), expectedStamp: await expectedStamp(pluginName)},
|
|
130
|
+
credentials: {
|
|
131
|
+
// Which key is sealing passwords, never the key itself. A mailbox
|
|
132
|
+
// that stops opening after a deploy is usually this having moved.
|
|
133
|
+
keySource: resolveKeySource().source
|
|
134
|
+
},
|
|
135
|
+
config: {
|
|
136
|
+
listLimit: config.listLimit,
|
|
137
|
+
allowRemoteImages: config.allowRemoteImages,
|
|
138
|
+
allowInsecureTLS: config.allowInsecureTLS,
|
|
139
|
+
connectionIdleMs: config.connectionIdleMs
|
|
140
|
+
},
|
|
141
|
+
mailboxes: owned.map(account => ({
|
|
142
|
+
id: account.id,
|
|
143
|
+
label: account.label,
|
|
144
|
+
host: `${account.host}:${account.port}`,
|
|
145
|
+
secure: account.secure !== false,
|
|
146
|
+
username: account.username,
|
|
147
|
+
syncFolders: account.syncFolders ?? '(default)',
|
|
148
|
+
pollIntervalMinutes: account.pollIntervalMinutes ?? '(default)',
|
|
149
|
+
trustedImageSenders: (account.imageSenders ?? []).length,
|
|
150
|
+
rememberImageSenders: account.rememberImageSenders !== false
|
|
151
|
+
})),
|
|
152
|
+
...(extra ? {edition: await extra()} : {})
|
|
153
|
+
};
|
|
154
|
+
}
|