domma-cms 0.55.1 → 0.69.3
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 +194 -3
- package/README.md +18 -14
- package/admin/css/admin.css +1 -1
- package/admin/css/contacts.css +1 -0
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/index.html +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/analytics-shape.js +1 -0
- package/admin/js/lib/contacts-arrange.js +1 -0
- package/admin/js/lib/help-popover.js +1 -0
- package/admin/js/lib/notes-arrange.js +1 -0
- 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/lib/todo-arrange.js +1 -0
- package/admin/js/lib/tool-kit.js +1 -0
- package/admin/js/templates/analytics.html +138 -0
- package/admin/js/templates/contacts.html +156 -0
- package/admin/js/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/notes.html +137 -0
- package/admin/js/templates/plugin-code.html +1 -1
- package/admin/js/templates/plugin-marketplace.html +17 -4
- package/admin/js/templates/plugins.html +13 -7
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +16 -97
- package/admin/js/templates/theme.html +211 -0
- package/admin/js/templates/todo.html +113 -0
- package/admin/js/views/analytics.js +62 -0
- package/admin/js/views/contacts.js +174 -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/notes.js +104 -0
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +28 -24
- 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/admin/js/views/todo.js +96 -0
- package/bin/cli.js +6 -10
- package/bin/lib/plugin-version.js +28 -0
- package/bin/update.js +27 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/server.json +3 -1
- package/package.json +14 -10
- 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 +4473 -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 +1022 -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/blog/CLAUDE.md +237 -0
- package/plugins/blog/admin/css/index.css +178 -0
- package/plugins/blog/admin/templates/blog.html +35 -41
- package/plugins/blog/admin/templates/categories.html +19 -6
- package/plugins/blog/admin/templates/comments.html +26 -9
- package/plugins/blog/admin/templates/post-editor.html +63 -40
- package/plugins/blog/admin/templates/settings.html +3 -9
- package/plugins/blog/admin/views/blog.js +288 -159
- package/plugins/blog/admin/views/categories.js +212 -206
- package/plugins/blog/admin/views/comments.js +207 -158
- package/plugins/blog/admin/views/kit.js +372 -0
- package/plugins/blog/admin/views/post-editor.js +306 -39
- package/plugins/blog/admin/views/settings-cog.js +56 -0
- package/plugins/blog/admin/views/settings.js +16 -84
- package/plugins/blog/blocks/blog-card-compact.css +17 -0
- package/plugins/blog/blocks/blog-card-compact.html +8 -0
- package/plugins/blog/blocks/blog-card-row.css +22 -0
- package/plugins/blog/blocks/blog-card-row.html +11 -0
- package/plugins/blog/blocks/blog-card.css +35 -0
- package/plugins/blog/blocks/blog-card.html +16 -0
- package/plugins/blog/blocks/blog-post-classic.css +15 -0
- package/plugins/blog/blocks/blog-post-classic.html +28 -0
- package/plugins/blog/blocks/blog-post-feature.css +20 -0
- package/plugins/blog/blocks/blog-post-feature.html +30 -0
- package/plugins/blog/blocks/blog-post-minimal.css +10 -0
- package/plugins/blog/blocks/blog-post-minimal.html +23 -0
- package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
- package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
- 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 +16 -13
- package/plugins/blog/config.js +6 -0
- package/plugins/blog/lib/layouts.js +304 -0
- package/plugins/blog/lib/page.js +158 -0
- package/plugins/blog/lib/render.js +119 -0
- package/plugins/blog/lib/samples.js +234 -0
- package/plugins/blog/plugin.js +259 -26
- package/plugins/blog/plugin.json +21 -73
- package/plugins/blog/plugin.public.js +217 -0
- package/plugins/blog/public/blog.css +177 -0
- package/plugins/blog/public/blog.js +586 -0
- package/plugins/blog/public/samples/aurora.svg +7 -0
- package/plugins/blog/public/samples/dusk.svg +7 -0
- package/plugins/blog/public/samples/ember.svg +7 -0
- package/plugins/blog/public/samples/harbour.svg +7 -0
- package/plugins/blog/public/samples/meadow.svg +7 -0
- package/plugins/blog/public/samples/rosewood.svg +7 -0
- package/plugins/blog/templates/index.html +9 -8
- package/plugins/blog/templates/post.html +4 -17
- package/plugins/blog/tests/layouts.test.js +97 -0
- package/plugins/blog/tests/public.test.js +71 -0
- package/plugins/free-tier.lock.json +74 -0
- package/plugins/mail-reader/CLAUDE.md +78 -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 +34 -0
- package/public/css/forms.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/css/theme-switcher.css +1 -0
- package/public/js/analytics.js +1 -0
- 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/search.js +1 -0
- package/public/js/site.js +1 -1
- package/public/js/theme-switcher.js +1 -0
- package/scripts/build.js +77 -4
- package/scripts/copy-domma.js +48 -0
- package/scripts/run-tests.mjs +92 -0
- package/scripts/seed.js +1996 -0
- package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/contacts.js +506 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/dashboard.js +8 -2
- package/server/routes/api/forms.js +42 -3
- package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugin-marketplace.js +78 -23
- package/server/routes/api/plugins.js +198 -9
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/api/todo.js +178 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +135 -1
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/collections.js +13 -3
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/managerClient.js +72 -136
- package/server/services/markdown.js +124 -10
- package/server/services/permissionRegistry.js +74 -0
- package/server/services/pluginEntitlement.js +171 -0
- package/server/services/pluginEnvelope.js +242 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/pluginInstaller.js +246 -26
- package/server/services/pluginScaffold.js +5 -0
- package/server/services/plugins.js +340 -7
- package/server/services/presetCollections.js +344 -0
- package/server/services/promoted-plugins-migration.js +254 -0
- package/server/services/publicCors.js +46 -0
- package/server/services/renderer.js +224 -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/sidebarBadges.js +37 -0
- package/server/services/siteGitignore.js +307 -0
- package/server/services/themeSettings.js +580 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/analytics/admin/templates/analytics.html +0 -61
- package/plugins/analytics/admin/views/analytics.js +0 -176
- package/plugins/analytics/config.js +0 -14
- package/plugins/analytics/plugin.json +0 -57
- package/plugins/analytics/public/inject-body.html +0 -60
- package/plugins/analytics/public/inject-head.html +0 -1
- package/plugins/blog/templates/author.html +0 -9
- package/plugins/blog/templates/category.html +0 -9
- package/plugins/blog/templates/tag.html +0 -9
- package/plugins/contacts/admin/templates/contacts.html +0 -126
- package/plugins/contacts/admin/views/contacts.js +0 -710
- package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
- package/plugins/contacts/collections/user-contacts/schema.json +0 -71
- package/plugins/contacts/config.js +0 -6
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/contacts/plugin.js +0 -291
- package/plugins/contacts/plugin.json +0 -33
- package/plugins/demo-viewer/config.js +0 -4
- package/plugins/demo-viewer/plugin.js +0 -36
- package/plugins/demo-viewer/plugin.json +0 -9
- package/plugins/invoice/admin/templates/editor.html +0 -129
- package/plugins/invoice/admin/templates/index.html +0 -43
- package/plugins/invoice/admin/templates/issuers.html +0 -5
- package/plugins/invoice/admin/templates/receivers.html +0 -5
- package/plugins/invoice/admin/views/editor.js +0 -267
- package/plugins/invoice/admin/views/index.js +0 -155
- package/plugins/invoice/admin/views/issuers.js +0 -23
- package/plugins/invoice/admin/views/party-view.js +0 -148
- package/plugins/invoice/admin/views/receivers.js +0 -22
- package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
- package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
- package/plugins/invoice/collections/invoices/schema.json +0 -27
- package/plugins/invoice/config.js +0 -16
- package/plugins/invoice/plugin.js +0 -283
- package/plugins/invoice/plugin.json +0 -85
- package/plugins/invoice/templates/invoice-print.html +0 -213
- package/plugins/notes/admin/templates/notes.html +0 -83
- package/plugins/notes/admin/views/notes.js +0 -281
- package/plugins/notes/collections/user-notes/schema.json +0 -54
- package/plugins/notes/config.js +0 -6
- package/plugins/notes/data/notes.json +0 -1
- package/plugins/notes/plugin.json +0 -33
- 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/surveys/admin/templates/audience.html +0 -47
- package/plugins/surveys/admin/templates/results.html +0 -58
- package/plugins/surveys/admin/templates/survey-editor.html +0 -141
- package/plugins/surveys/admin/templates/surveys.html +0 -25
- package/plugins/surveys/admin/views/audience.js +0 -301
- package/plugins/surveys/admin/views/results.js +0 -172
- package/plugins/surveys/admin/views/survey-editor.js +0 -211
- package/plugins/surveys/admin/views/surveys.js +0 -161
- package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
- package/plugins/surveys/collections/survey-groups/schema.json +0 -11
- package/plugins/surveys/collections/survey-invites/schema.json +0 -16
- package/plugins/surveys/collections/surveys/schema.json +0 -23
- package/plugins/surveys/config.js +0 -8
- package/plugins/surveys/plugin.js +0 -174
- package/plugins/surveys/plugin.json +0 -65
- package/plugins/surveys/public/dist-shared.mjs +0 -1
- package/plugins/surveys/public/survey.css +0 -1
- package/plugins/surveys/public/survey.mjs +0 -1
- package/plugins/surveys/templates/survey-page.html +0 -63
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
- package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
- package/plugins/theme-switcher/config.js +0 -10
- package/plugins/theme-switcher/plugin.js +0 -26
- package/plugins/theme-switcher/plugin.json +0 -74
- package/plugins/theme-switcher/public/inject-body.html +0 -153
- package/plugins/theme-switcher/public/inject-head.html +0 -260
- package/plugins/todo/admin/templates/todo.html +0 -158
- package/plugins/todo/admin/views/todo.js +0 -343
- package/plugins/todo/collections/todos/schema.json +0 -60
- package/plugins/todo/config.js +0 -7
- package/plugins/todo/data/todos.json +0 -1
- package/plugins/todo/plugin.js +0 -102
- package/plugins/todo/plugin.json +0 -33
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read-only mail surface, shared by every edition.
|
|
3
|
+
*
|
|
4
|
+
* `mail-reader` (free) is exactly these routes and nothing else. `email-pro`
|
|
5
|
+
* registers them too and adds writing, sending and a synchronised store on
|
|
6
|
+
* top - forking them instead would mean every IMAP quirk, every sanitiser
|
|
7
|
+
* decision and every ownership check being fixed twice.
|
|
8
|
+
*
|
|
9
|
+
* Routes are relative, so they mount under whichever plugin registers them;
|
|
10
|
+
* `pluginName` is needed only for the body URL and the log tags.
|
|
11
|
+
*
|
|
12
|
+
* ## Access control
|
|
13
|
+
*
|
|
14
|
+
* Every route resolves the mailbox through `accounts.getAccount(userId,
|
|
15
|
+
* accountId)`, which takes the owner's id as well as the account's. There is
|
|
16
|
+
* no lookup by account id alone, so one user's mailbox cannot be reached with
|
|
17
|
+
* another user's token, and an id that is not the caller's answers 404 rather
|
|
18
|
+
* than 403 - a wrong id and someone else's id should be indistinguishable.
|
|
19
|
+
*
|
|
20
|
+
* ## Read-only
|
|
21
|
+
*
|
|
22
|
+
* Nothing here writes to a mailbox. Mailboxes open with EXAMINE and fetches
|
|
23
|
+
* use BODY.PEEK[], so reading never sets \Seen. `email-pro` must take its own
|
|
24
|
+
* writable handle rather than relax this one: the free reader depends on it.
|
|
25
|
+
*
|
|
26
|
+
* @module _lib/mail/readRoutes
|
|
27
|
+
*/
|
|
28
|
+
import {simpleParser} from 'mailparser';
|
|
29
|
+
|
|
30
|
+
import {listUsers} from '../../../server/services/users.js';
|
|
31
|
+
import * as accounts from './accounts.js';
|
|
32
|
+
import * as bodyTokens from './bodyTokens.js';
|
|
33
|
+
import {buildDiagnostics} from './diagnostics.js';
|
|
34
|
+
import {ENVELOPE_QUERY, hasAttachment, parseHeaderBlock} from './envelope.js';
|
|
35
|
+
import {describeError} from './imapPool.js';
|
|
36
|
+
import {resolveSubscriptions} from './folders.js';
|
|
37
|
+
import {readPriority} from './priority.js';
|
|
38
|
+
import {listAttachments, renderBody} from './render.js';
|
|
39
|
+
import {readUnsubscribe} from './unsubscribe.js';
|
|
40
|
+
import {MailKeyError} from './secretbox.js';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Flatten an envelope address list into something the browser can render.
|
|
44
|
+
*
|
|
45
|
+
* @param {Array<{name?: string, address?: string}>} list
|
|
46
|
+
* @returns {{name: string, address: string}[]}
|
|
47
|
+
*/
|
|
48
|
+
function addresses(list) {
|
|
49
|
+
return (list ?? []).map(entry => ({
|
|
50
|
+
name: entry.name ?? '',
|
|
51
|
+
address: entry.address ?? ''
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginName, cachedList = null, diagnostics = null}) {
|
|
56
|
+
const {authenticate} = auth;
|
|
57
|
+
|
|
58
|
+
// A deleted user's stored mailbox passwords must not outlive their
|
|
59
|
+
// account. The hook is the fast path; the sweep below is what catches a
|
|
60
|
+
// user deleted while this plugin happened to be disabled.
|
|
61
|
+
hooks?.on?.('user:deleted', async ({id} = {}) => {
|
|
62
|
+
if (!id) return;
|
|
63
|
+
try {
|
|
64
|
+
pool.releaseUser(id);
|
|
65
|
+
const removed = await accounts.deleteAccountsForUser(id);
|
|
66
|
+
if (removed) {
|
|
67
|
+
fastify.log.info(`[${pluginName}] removed ${removed} stored mailbox(es) for deleted user ${id}`);
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
fastify.log.error({err}, `[${pluginName}] failed to clean up mailboxes for a deleted user`);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Fire-and-forget: a slow or failing sweep must never delay boot, and
|
|
75
|
+
// there is nothing a user can do about it if it does fail.
|
|
76
|
+
(async () => {
|
|
77
|
+
try {
|
|
78
|
+
const removed = await accounts.pruneOrphans(new Set((await listUsers()).map(u => u.id)));
|
|
79
|
+
if (removed) {
|
|
80
|
+
fastify.log.info(`[${pluginName}] pruned ${removed} stored mailbox(es) with no matching user`);
|
|
81
|
+
}
|
|
82
|
+
} catch (err) {
|
|
83
|
+
fastify.log.warn({err}, `[${pluginName}] orphan mailbox sweep failed`);
|
|
84
|
+
}
|
|
85
|
+
})();
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolve the caller's user id.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} request
|
|
91
|
+
* @returns {string|null}
|
|
92
|
+
*/
|
|
93
|
+
function userId(request) {
|
|
94
|
+
return request.user?.id ?? request.user?.sub ?? null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Load the mailbox named by ?account= and open its stored password.
|
|
99
|
+
*
|
|
100
|
+
* Returns null after replying, so handlers can `if (!ctx) return;`.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} request
|
|
103
|
+
* @param {object} reply
|
|
104
|
+
* @returns {Promise<{uid: string, account: object, connection: object, poolKey: string}|null>}
|
|
105
|
+
*/
|
|
106
|
+
async function context(request, reply) {
|
|
107
|
+
const uid = userId(request);
|
|
108
|
+
if (!uid) {
|
|
109
|
+
reply.code(401).send({error: 'Authentication required.'});
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const requested = request.query.account ? String(request.query.account) : null;
|
|
114
|
+
const owned = await accounts.listAccounts(uid);
|
|
115
|
+
|
|
116
|
+
if (!owned.length) {
|
|
117
|
+
reply.code(404).send({error: 'No mailbox is set up yet.', needsSetup: true});
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// No ?account= means "the first one" - the reader opens on a mailbox
|
|
122
|
+
// rather than asking which before it has shown anything.
|
|
123
|
+
const account = requested ? owned.find(a => a.id === requested) : owned[0];
|
|
124
|
+
if (!account) {
|
|
125
|
+
reply.code(404).send({error: 'That mailbox does not exist.'});
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
return {
|
|
131
|
+
uid,
|
|
132
|
+
account,
|
|
133
|
+
connection: accounts.toConnection(account),
|
|
134
|
+
// Keyed per account, not per user: one user may hold several
|
|
135
|
+
// mailboxes and each needs its own connection.
|
|
136
|
+
poolKey: `${uid}:${account.id}`
|
|
137
|
+
};
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (err instanceof MailKeyError) {
|
|
140
|
+
reply.code(409).send({error: err.message, needsPassword: true, accountId: account.id});
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Run an IMAP operation, turning transport failures into tidy 502s.
|
|
149
|
+
*
|
|
150
|
+
* @param {object} reply
|
|
151
|
+
* @param {() => Promise<any>} fn
|
|
152
|
+
* @returns {Promise<any>}
|
|
153
|
+
*/
|
|
154
|
+
async function imap(reply, fn) {
|
|
155
|
+
try {
|
|
156
|
+
return await fn();
|
|
157
|
+
} catch (err) {
|
|
158
|
+
fastify.log.error({err}, `[${pluginName}] IMAP operation failed`);
|
|
159
|
+
reply.code(502).send({error: describeError(err)});
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// -------------------------------------------------------------------------
|
|
165
|
+
// Accounts
|
|
166
|
+
// -------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
/** GET /accounts - every mailbox this user owns, without passwords. */
|
|
169
|
+
fastify.get('/accounts', {preHandler: [authenticate]}, async (request, reply) => {
|
|
170
|
+
const uid = userId(request);
|
|
171
|
+
if (!uid) return reply.code(401).send({error: 'Authentication required.'});
|
|
172
|
+
return {accounts: (await accounts.listAccounts(uid)).map(accounts.toPublic)};
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/** POST /accounts - add a mailbox. */
|
|
176
|
+
fastify.post('/accounts', {preHandler: [authenticate]}, async (request, reply) => {
|
|
177
|
+
const uid = userId(request);
|
|
178
|
+
if (!uid) return reply.code(401).send({error: 'Authentication required.'});
|
|
179
|
+
|
|
180
|
+
const result = accounts.validate(request.body ?? {}, null);
|
|
181
|
+
if (!result.ok) return reply.code(400).send({error: result.error});
|
|
182
|
+
|
|
183
|
+
const saved = await accounts.saveAccount(uid, null, result.value);
|
|
184
|
+
return {account: accounts.toPublic(saved)};
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
/** PUT /accounts/:id - update one. A blank password keeps the stored one. */
|
|
188
|
+
fastify.put('/accounts/:id', {preHandler: [authenticate]}, async (request, reply) => {
|
|
189
|
+
const uid = userId(request);
|
|
190
|
+
if (!uid) return reply.code(401).send({error: 'Authentication required.'});
|
|
191
|
+
|
|
192
|
+
const existing = await accounts.getAccount(uid, request.params.id);
|
|
193
|
+
if (!existing) return reply.code(404).send({error: 'That mailbox does not exist.'});
|
|
194
|
+
|
|
195
|
+
const result = accounts.validate(request.body ?? {}, existing);
|
|
196
|
+
if (!result.ok) return reply.code(400).send({error: result.error});
|
|
197
|
+
|
|
198
|
+
const saved = await accounts.saveAccount(uid, request.params.id, result.value);
|
|
199
|
+
// The pooled connection was opened with the old details.
|
|
200
|
+
pool.release(`${uid}:${request.params.id}`);
|
|
201
|
+
return {account: accounts.toPublic(saved)};
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
/** DELETE /accounts/:id - forget a mailbox and its password. */
|
|
205
|
+
fastify.delete('/accounts/:id', {preHandler: [authenticate]}, async (request, reply) => {
|
|
206
|
+
const uid = userId(request);
|
|
207
|
+
if (!uid) return reply.code(401).send({error: 'Authentication required.'});
|
|
208
|
+
|
|
209
|
+
pool.release(`${uid}:${request.params.id}`);
|
|
210
|
+
const deleted = await accounts.deleteAccount(uid, request.params.id);
|
|
211
|
+
if (!deleted) return reply.code(404).send({error: 'That mailbox does not exist.'});
|
|
212
|
+
return {deleted: true};
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* POST /accounts/test - try details without saving them.
|
|
217
|
+
*
|
|
218
|
+
* Accepts a body so a mailbox can be tested before it is saved. When the
|
|
219
|
+
* password field is blank and `id` names an existing mailbox, the stored
|
|
220
|
+
* password is used - otherwise editing a hostname would mean retyping it.
|
|
221
|
+
*/
|
|
222
|
+
fastify.post('/accounts/test', {preHandler: [authenticate]}, async (request, reply) => {
|
|
223
|
+
const uid = userId(request);
|
|
224
|
+
if (!uid) return reply.code(401).send({error: 'Authentication required.'});
|
|
225
|
+
|
|
226
|
+
const body = request.body ?? {};
|
|
227
|
+
const existing = body.id ? await accounts.getAccount(uid, body.id) : null;
|
|
228
|
+
if (body.id && !existing) return reply.code(404).send({error: 'That mailbox does not exist.'});
|
|
229
|
+
|
|
230
|
+
const result = accounts.validate(body, existing);
|
|
231
|
+
if (!result.ok) return reply.code(400).send({error: result.error});
|
|
232
|
+
|
|
233
|
+
let pass = result.value.password;
|
|
234
|
+
if (!pass) {
|
|
235
|
+
try {
|
|
236
|
+
pass = accounts.toConnection(existing).auth.pass;
|
|
237
|
+
} catch (err) {
|
|
238
|
+
if (err instanceof MailKeyError) return reply.code(409).send({error: err.message});
|
|
239
|
+
throw err;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return pool.test({
|
|
244
|
+
host: result.value.host,
|
|
245
|
+
port: result.value.port,
|
|
246
|
+
secure: result.value.secure,
|
|
247
|
+
auth: {user: result.value.username, pass}
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// -------------------------------------------------------------------------
|
|
252
|
+
// Reading
|
|
253
|
+
// -------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
/** GET /folders - the mailbox tree, flattened. */
|
|
256
|
+
fastify.get('/folders', {preHandler: [authenticate]}, async (request, reply) => {
|
|
257
|
+
const ctx = await context(request, reply);
|
|
258
|
+
if (!ctx) return;
|
|
259
|
+
|
|
260
|
+
return imap(reply, async () => {
|
|
261
|
+
const list = await pool.withClient(ctx.poolKey, ctx.connection, client => client.list());
|
|
262
|
+
// `subscribed !== false` was wrong: imapflow reports an
|
|
263
|
+
// unsubscribed mailbox as `undefined`, so every hidden folder read
|
|
264
|
+
// as visible. See resolveSubscriptions for why it is not simply
|
|
265
|
+
// `=== true` either.
|
|
266
|
+
const visible = resolveSubscriptions(list);
|
|
267
|
+
const folders = list
|
|
268
|
+
.filter(box => !box.flags?.has('\\Noselect'))
|
|
269
|
+
.map(box => ({
|
|
270
|
+
path: box.path,
|
|
271
|
+
name: box.name,
|
|
272
|
+
delimiter: box.delimiter,
|
|
273
|
+
specialUse: box.specialUse ?? null,
|
|
274
|
+
subscribed: visible.get(box.path) !== false
|
|
275
|
+
}));
|
|
276
|
+
|
|
277
|
+
// Unread counts are opt-in. STATUS is a round trip per folder and
|
|
278
|
+
// a real mailbox here has over a hundred of them, so asking for
|
|
279
|
+
// them is a choice the caller makes rather than a cost every
|
|
280
|
+
// folder listing pays.
|
|
281
|
+
if (request.query.counts === '1') {
|
|
282
|
+
await withUnreadCounts(ctx, folders);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {accountId: ctx.account.id, folders};
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Fill in each folder's unread count.
|
|
291
|
+
*
|
|
292
|
+
* STATUS asks without selecting, so it does not disturb the open mailbox
|
|
293
|
+
* and cannot mark anything read. Run with a small concurrency: a hundred
|
|
294
|
+
* simultaneous commands on one connection is not faster, and on several
|
|
295
|
+
* connections it is how a server starts refusing them.
|
|
296
|
+
*
|
|
297
|
+
* A folder that refuses STATUS is left without a count rather than
|
|
298
|
+
* failing the listing - some servers will not report on some folders, and
|
|
299
|
+
* a missing badge is better than an empty folder pane.
|
|
300
|
+
*
|
|
301
|
+
* @param {object} ctx
|
|
302
|
+
* @param {object[]} folders
|
|
303
|
+
* @returns {Promise<void>}
|
|
304
|
+
*/
|
|
305
|
+
async function withUnreadCounts(ctx, folders) {
|
|
306
|
+
const queue = [...folders];
|
|
307
|
+
const worker = async () => {
|
|
308
|
+
while (queue.length) {
|
|
309
|
+
const folder = queue.shift();
|
|
310
|
+
try {
|
|
311
|
+
const status = await pool.withClient(ctx.poolKey, ctx.connection,
|
|
312
|
+
client => client.status(folder.path, {unseen: true, messages: true}));
|
|
313
|
+
folder.unseen = status?.unseen ?? 0;
|
|
314
|
+
folder.messages = status?.messages ?? 0;
|
|
315
|
+
} catch {
|
|
316
|
+
folder.unseen = null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
// One connection, so the commands serialise anyway - this just avoids
|
|
321
|
+
// building a hundred pending promises.
|
|
322
|
+
await Promise.all([worker(), worker()]);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* GET /messages - one page of envelopes, newest first.
|
|
327
|
+
*
|
|
328
|
+
* Paged by sequence number from the end of the mailbox rather than by
|
|
329
|
+
* search: it is one round trip, and "newest first" is what a reader wants.
|
|
330
|
+
*/
|
|
331
|
+
fastify.get('/messages', {preHandler: [authenticate]}, async (request, reply) => {
|
|
332
|
+
const ctx = await context(request, reply);
|
|
333
|
+
if (!ctx) return;
|
|
334
|
+
|
|
335
|
+
const folder = String(request.query.folder || 'INBOX');
|
|
336
|
+
const page = Math.max(1, Number(request.query.page) || 1);
|
|
337
|
+
const limit = Math.min(
|
|
338
|
+
config.maxListLimit,
|
|
339
|
+
Math.max(1, Number(request.query.limit) || config.listLimit)
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
// The Pro edition mirrors chosen folders, and a mirrored folder is
|
|
343
|
+
// answered from the store - instant, and no connection needed. It
|
|
344
|
+
// returns null for anything not mirrored, which falls through to the
|
|
345
|
+
// wire exactly as the free edition always does.
|
|
346
|
+
if (cachedList) {
|
|
347
|
+
try {
|
|
348
|
+
const cached = await cachedList({
|
|
349
|
+
account: ctx.account, userId: ctx.uid, folder, page, limit
|
|
350
|
+
});
|
|
351
|
+
if (cached) {
|
|
352
|
+
return {...cached, page, limit, folder, accountId: ctx.account.id, source: 'cache'};
|
|
353
|
+
}
|
|
354
|
+
} catch (err) {
|
|
355
|
+
// A broken mirror must never make mail unreadable; fall back.
|
|
356
|
+
fastify.log.warn({err}, `[${pluginName}] cached list failed - falling back to IMAP`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return imap(reply, () => pool.withMailbox(ctx.poolKey, ctx.connection, folder, async (client, mailbox) => {
|
|
361
|
+
const total = mailbox.exists;
|
|
362
|
+
if (!total) return {messages: [], total: 0, page, limit, folder, accountId: ctx.account.id};
|
|
363
|
+
|
|
364
|
+
const end = total - (page - 1) * limit;
|
|
365
|
+
if (end < 1) return {messages: [], total, page, limit, folder, accountId: ctx.account.id};
|
|
366
|
+
const start = Math.max(1, end - limit + 1);
|
|
367
|
+
|
|
368
|
+
const messages = [];
|
|
369
|
+
// The same query the mirror uses, so a folder listed live and one
|
|
370
|
+
// listed from the mirror produce identical rows - including the
|
|
371
|
+
// priority headers, which are not part of the envelope.
|
|
372
|
+
for await (const message of client.fetch(`${start}:${end}`, ENVELOPE_QUERY)) {
|
|
373
|
+
messages.push({
|
|
374
|
+
uid: message.uid,
|
|
375
|
+
seq: message.seq,
|
|
376
|
+
subject: message.envelope?.subject ?? '',
|
|
377
|
+
from: addresses(message.envelope?.from),
|
|
378
|
+
to: addresses(message.envelope?.to),
|
|
379
|
+
date: message.envelope?.date ?? null,
|
|
380
|
+
size: message.size ?? 0,
|
|
381
|
+
// The raw flag list, not just the booleans derived from it.
|
|
382
|
+
// The list view keeps an observable of this per row, so
|
|
383
|
+
// omitting it left every message on a non-mirrored folder
|
|
384
|
+
// initialised with no flags - and therefore drawn unread,
|
|
385
|
+
// whatever the server said.
|
|
386
|
+
flags: [...(message.flags ?? [])],
|
|
387
|
+
seen: message.flags?.has('\\Seen') ?? false,
|
|
388
|
+
flagged: message.flags?.has('\\Flagged') ?? false,
|
|
389
|
+
answered: message.flags?.has('\\Answered') ?? false,
|
|
390
|
+
hasAttachment: hasAttachment(message.bodyStructure),
|
|
391
|
+
priority: readPriority(parseHeaderBlock(message.headers))
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// The server returns ascending sequence order regardless of range.
|
|
396
|
+
messages.reverse();
|
|
397
|
+
return {messages, total, page, limit, folder, accountId: ctx.account.id};
|
|
398
|
+
}));
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
/** GET /messages/:uid - one message, rendered for the reader pane. */
|
|
402
|
+
fastify.get('/messages/:uid', {preHandler: [authenticate]}, async (request, reply) => {
|
|
403
|
+
const ctx = await context(request, reply);
|
|
404
|
+
if (!ctx) return;
|
|
405
|
+
|
|
406
|
+
const folder = String(request.query.folder || 'INBOX');
|
|
407
|
+
const uid = Number(request.params.uid);
|
|
408
|
+
if (!Number.isInteger(uid) || uid < 1) {
|
|
409
|
+
return reply.code(400).send({error: 'Invalid message id.'});
|
|
410
|
+
}
|
|
411
|
+
// Three ways images may load, and only one of them is remembered.
|
|
412
|
+
// The per-message override is deliberately not stored - it is a
|
|
413
|
+
// decision about one message. A sender the user has explicitly chosen
|
|
414
|
+
// to trust is stored, listed in settings and revocable, which is a
|
|
415
|
+
// different act from silently remembering a click.
|
|
416
|
+
const requestedNow = request.query.images === 'load';
|
|
417
|
+
|
|
418
|
+
return imap(reply, () => pool.withMailbox(ctx.poolKey, ctx.connection, folder, async (client) => {
|
|
419
|
+
const fetched = await client.fetchOne(uid, {source: true, flags: true}, {uid: true});
|
|
420
|
+
if (!fetched || !fetched.source) {
|
|
421
|
+
reply.code(404).send({error: 'That message is no longer in this folder.'});
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
if (fetched.source.length > config.maxMessageBytes) {
|
|
425
|
+
reply.code(413).send({error: 'That message is too large to display.'});
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const parsed = await simpleParser(fetched.source);
|
|
430
|
+
const from = addresses(parsed.from?.value);
|
|
431
|
+
const senderTrusted = accounts.sendersImagesTrusted(ctx.account, from);
|
|
432
|
+
const allowRemoteImages = requestedNow || config.allowRemoteImages || senderTrusted;
|
|
433
|
+
|
|
434
|
+
const rendered = renderBody(parsed, {
|
|
435
|
+
allowRemoteImages,
|
|
436
|
+
inlineImageMaxBytes: config.inlineImageMaxBytes
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
uid,
|
|
441
|
+
folder,
|
|
442
|
+
accountId: ctx.account.id,
|
|
443
|
+
senderTrusted,
|
|
444
|
+
// Offered only when remembering is switched on for this mailbox.
|
|
445
|
+
canTrustSender: ctx.account.rememberImageSenders !== false
|
|
446
|
+
&& !senderTrusted
|
|
447
|
+
&& !!from[0]?.address,
|
|
448
|
+
trustableSender: from[0]?.address ?? null,
|
|
449
|
+
subject: parsed.subject ?? '',
|
|
450
|
+
from,
|
|
451
|
+
to: addresses(parsed.to?.value),
|
|
452
|
+
cc: addresses(parsed.cc?.value),
|
|
453
|
+
// Carried so the expanded header can show them. Reply-To
|
|
454
|
+
// especially: a sender who sets it is asking for replies
|
|
455
|
+
// somewhere other than where the message came from, and until
|
|
456
|
+
// now the reader hid that entirely. Bcc only ever appears on a
|
|
457
|
+
// copy in Sent - a received message never carries one, which
|
|
458
|
+
// is the point of it.
|
|
459
|
+
replyTo: addresses(parsed.replyTo?.value),
|
|
460
|
+
bcc: addresses(parsed.bcc?.value),
|
|
461
|
+
date: parsed.date ?? null,
|
|
462
|
+
messageId: parsed.messageId ?? null,
|
|
463
|
+
// From the parsed message here rather than a header fetch:
|
|
464
|
+
// the whole source is already in hand at this point.
|
|
465
|
+
priority: readPriority(parsed.headers),
|
|
466
|
+
// A URL, not the markup: an iframe filled with srcdoc inherits
|
|
467
|
+
// the admin's CSP (img-src 'self' data: blob:), which silently
|
|
468
|
+
// blocks every remote image no matter what the reader asks for.
|
|
469
|
+
// Served from a route, the document carries its own policy.
|
|
470
|
+
bodyUrl: `/api/plugins/${pluginName}/body?token=${encodeURIComponent(bodyTokens.sign({
|
|
471
|
+
userId: ctx.uid,
|
|
472
|
+
accountId: ctx.account.id,
|
|
473
|
+
folder,
|
|
474
|
+
uid,
|
|
475
|
+
images: allowRemoteImages
|
|
476
|
+
}))}`,
|
|
477
|
+
isHtml: rendered.isHtml,
|
|
478
|
+
blockedImages: rendered.blockedImages,
|
|
479
|
+
remoteImagesLoaded: allowRemoteImages,
|
|
480
|
+
// What this message offers by way of getting off the list it
|
|
481
|
+
// came from. Read in both editions, because knowing there is
|
|
482
|
+
// an unsubscribe link is reading; acting on it is a write and
|
|
483
|
+
// lives in the Pro edition.
|
|
484
|
+
unsubscribe: readUnsubscribe(parsed.headers),
|
|
485
|
+
attachments: listAttachments(parsed.attachments)
|
|
486
|
+
};
|
|
487
|
+
}));
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* GET /body - the rendered message, as its own document.
|
|
492
|
+
*
|
|
493
|
+
* Authorised by a short-lived signed token rather than a bearer header,
|
|
494
|
+
* because an `<iframe src>` cannot send one. The token names a single
|
|
495
|
+
* message for a single user and expires in minutes.
|
|
496
|
+
*
|
|
497
|
+
* The CSP here is the point of the whole route: served as its own
|
|
498
|
+
* document, this policy applies instead of the admin's, so a message the
|
|
499
|
+
* reader has chosen to load images for can actually load them - while
|
|
500
|
+
* scripts, frames and everything else stay refused.
|
|
501
|
+
*/
|
|
502
|
+
fastify.get('/body', {
|
|
503
|
+
// Helmet adds X-Frame-Options in an onSend hook, which runs after
|
|
504
|
+
// the handler - so removing it in there was too early and the
|
|
505
|
+
// browser kept warning, once per message, that it was ignoring the
|
|
506
|
+
// header in favour of the frame-ancestors this route already sets.
|
|
507
|
+
// Helmet writes onto the raw response rather than Fastify's header
|
|
508
|
+
// store, so reply.removeHeader() never sees it - it has to come off
|
|
509
|
+
// the raw response, and only for this route.
|
|
510
|
+
onSend: async (request, reply, payload) => {
|
|
511
|
+
reply.removeHeader('X-Frame-Options');
|
|
512
|
+
reply.raw.removeHeader?.('X-Frame-Options');
|
|
513
|
+
return payload;
|
|
514
|
+
}
|
|
515
|
+
}, async (request, reply) => {
|
|
516
|
+
const grant = bodyTokens.verify(String(request.query.token ?? ''));
|
|
517
|
+
if (!grant) {
|
|
518
|
+
return reply.code(403).type('text/plain').send('This message link has expired. Reopen the message.');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const account = await accounts.getAccount(grant.userId, grant.accountId);
|
|
522
|
+
if (!account) return reply.code(404).type('text/plain').send('That mailbox no longer exists.');
|
|
523
|
+
|
|
524
|
+
let connection;
|
|
525
|
+
try {
|
|
526
|
+
connection = accounts.toConnection(account);
|
|
527
|
+
} catch (err) {
|
|
528
|
+
if (err instanceof MailKeyError) return reply.code(409).type('text/plain').send(err.message);
|
|
529
|
+
throw err;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const poolKey = `${grant.userId}:${account.id}`;
|
|
533
|
+
try {
|
|
534
|
+
const document = await pool.withMailbox(poolKey, connection, grant.folder, async (client) => {
|
|
535
|
+
const fetched = await client.fetchOne(grant.uid, {source: true}, {uid: true});
|
|
536
|
+
if (!fetched || !fetched.source) return null;
|
|
537
|
+
if (fetched.source.length > config.maxMessageBytes) return null;
|
|
538
|
+
|
|
539
|
+
const parsed = await simpleParser(fetched.source);
|
|
540
|
+
return renderBody(parsed, {
|
|
541
|
+
allowRemoteImages: grant.images,
|
|
542
|
+
inlineImageMaxBytes: config.inlineImageMaxBytes
|
|
543
|
+
}).document;
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
if (!document) return reply.code(404).type('text/plain').send('That message is no longer available.');
|
|
547
|
+
|
|
548
|
+
return reply
|
|
549
|
+
.type('text/html; charset=utf-8')
|
|
550
|
+
.header('Content-Security-Policy',
|
|
551
|
+
"default-src 'none'; " +
|
|
552
|
+
(grant.images ? "img-src data: http: https:; " : "img-src data:; ") +
|
|
553
|
+
"style-src 'unsafe-inline'; font-src data:; frame-ancestors 'self'")
|
|
554
|
+
.header('X-Content-Type-Options', 'nosniff')
|
|
555
|
+
.header('Referrer-Policy', 'no-referrer')
|
|
556
|
+
// Someone else's mail has no business in a cache.
|
|
557
|
+
.header('Cache-Control', 'no-store, no-cache, must-revalidate, private')
|
|
558
|
+
.send(document);
|
|
559
|
+
} catch (err) {
|
|
560
|
+
fastify.log.error({err}, `[${pluginName}] failed to render a message body`);
|
|
561
|
+
return reply.code(502).type('text/plain').send(describeError(err));
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* GET /diagnostics - what this build is, and what it is configured with.
|
|
567
|
+
*
|
|
568
|
+
* Exists so a bug report can start from evidence. No secrets: hostnames
|
|
569
|
+
* and usernames identify a mailbox, the key SOURCE is reported without the
|
|
570
|
+
* key, and no password appears sealed or otherwise.
|
|
571
|
+
*/
|
|
572
|
+
fastify.get('/diagnostics', {preHandler: [authenticate]}, async (request, reply) => {
|
|
573
|
+
const uid = userId(request);
|
|
574
|
+
if (!uid) return reply.code(401).send({error: 'Authentication required.'});
|
|
575
|
+
return buildDiagnostics({pluginName, userId: uid, config, extra: diagnostics});
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* GET /image-senders - the senders whose images always load.
|
|
580
|
+
*/
|
|
581
|
+
fastify.get('/image-senders', {preHandler: [authenticate]}, async (request, reply) => {
|
|
582
|
+
const ctx = await context(request, reply);
|
|
583
|
+
if (!ctx) return;
|
|
584
|
+
return {
|
|
585
|
+
accountId: ctx.account.id,
|
|
586
|
+
remember: ctx.account.rememberImageSenders !== false,
|
|
587
|
+
senders: Array.isArray(ctx.account.imageSenders) ? ctx.account.imageSenders : []
|
|
588
|
+
};
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* POST /image-senders - trust a sender's images from now on.
|
|
593
|
+
*/
|
|
594
|
+
fastify.post('/image-senders', {preHandler: [authenticate]}, async (request, reply) => {
|
|
595
|
+
const ctx = await context(request, reply);
|
|
596
|
+
if (!ctx) return;
|
|
597
|
+
|
|
598
|
+
if (ctx.account.rememberImageSenders === false) {
|
|
599
|
+
return reply.code(409).send({error: 'Remembering senders is switched off for this mailbox.'});
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const address = String(request.body?.address ?? '').trim();
|
|
603
|
+
if (!address) return reply.code(400).send({error: 'A sender address is required.'});
|
|
604
|
+
|
|
605
|
+
const saved = await accounts.trustImageSender(ctx.uid, ctx.account.id, address);
|
|
606
|
+
if (!saved) return reply.code(404).send({error: 'That mailbox does not exist.'});
|
|
607
|
+
return {senders: saved.imageSenders ?? []};
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* DELETE /image-senders - stop trusting one.
|
|
612
|
+
*/
|
|
613
|
+
fastify.delete('/image-senders', {preHandler: [authenticate]}, async (request, reply) => {
|
|
614
|
+
const ctx = await context(request, reply);
|
|
615
|
+
if (!ctx) return;
|
|
616
|
+
const address = String(request.query.address ?? '').trim();
|
|
617
|
+
if (!address) return reply.code(400).send({error: 'A sender address is required.'});
|
|
618
|
+
|
|
619
|
+
const saved = await accounts.untrustImageSender(ctx.uid, ctx.account.id, address);
|
|
620
|
+
if (!saved) return reply.code(404).send({error: 'That mailbox does not exist.'});
|
|
621
|
+
return {senders: saved.imageSenders ?? []};
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* PUT /image-senders/remember - turn the whole feature on or off.
|
|
626
|
+
*
|
|
627
|
+
* Switching it off ignores the list without discarding it: turning it back
|
|
628
|
+
* on should not mean rebuilding something someone curated.
|
|
629
|
+
*/
|
|
630
|
+
fastify.put('/image-senders/remember', {preHandler: [authenticate]}, async (request, reply) => {
|
|
631
|
+
const ctx = await context(request, reply);
|
|
632
|
+
if (!ctx) return;
|
|
633
|
+
const saved = await accounts.setRememberImageSenders(ctx.uid, ctx.account.id, request.body?.remember !== false);
|
|
634
|
+
if (!saved) return reply.code(404).send({error: 'That mailbox does not exist.'});
|
|
635
|
+
return {remember: saved.rememberImageSenders !== false, senders: saved.imageSenders ?? []};
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* GET /messages/:uid/attachments/:index - download one part.
|
|
640
|
+
*
|
|
641
|
+
* Re-fetches and re-parses the message rather than caching it. Phase 1
|
|
642
|
+
* keeps no message store at all, and correctness beats a second copy of
|
|
643
|
+
* someone's mail sitting in memory.
|
|
644
|
+
*/
|
|
645
|
+
fastify.get('/messages/:uid/attachments/:index', {preHandler: [authenticate]}, async (request, reply) => {
|
|
646
|
+
const ctx = await context(request, reply);
|
|
647
|
+
if (!ctx) return;
|
|
648
|
+
|
|
649
|
+
const folder = String(request.query.folder || 'INBOX');
|
|
650
|
+
const uid = Number(request.params.uid);
|
|
651
|
+
const index = Number(request.params.index);
|
|
652
|
+
if (!Number.isInteger(uid) || !Number.isInteger(index) || index < 0) {
|
|
653
|
+
return reply.code(400).send({error: 'Invalid attachment reference.'});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return imap(reply, () => pool.withMailbox(ctx.poolKey, ctx.connection, folder, async (client) => {
|
|
657
|
+
const fetched = await client.fetchOne(uid, {source: true}, {uid: true});
|
|
658
|
+
if (!fetched || !fetched.source) {
|
|
659
|
+
reply.code(404).send({error: 'That message is no longer in this folder.'});
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const parsed = await simpleParser(fetched.source);
|
|
664
|
+
const attachment = parsed.attachments?.[index];
|
|
665
|
+
if (!attachment) {
|
|
666
|
+
reply.code(404).send({error: 'That attachment is not on this message.'});
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const filename = (attachment.filename || `attachment-${index + 1}`).replace(/["\\\r\n]/g, '_');
|
|
671
|
+
reply
|
|
672
|
+
.header('Content-Type', attachment.contentType || 'application/octet-stream')
|
|
673
|
+
// Always an attachment, never inline: nothing from a stranger's
|
|
674
|
+
// mail should render in the admin's own origin.
|
|
675
|
+
.header('Content-Disposition', `attachment; filename="${filename}"`)
|
|
676
|
+
.header('X-Content-Type-Options', 'nosniff');
|
|
677
|
+
return reply.send(attachment.content);
|
|
678
|
+
}));
|
|
679
|
+
});
|
|
680
|
+
}
|