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,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message rendering for the Mail Reader plugin.
|
|
3
|
+
*
|
|
4
|
+
* Email HTML is the most hostile content this CMS will ever display: it comes
|
|
5
|
+
* from strangers, it is full of tracking, and a good deal of it is actively
|
|
6
|
+
* trying to do something. Three layers stand between it and the admin:
|
|
7
|
+
*
|
|
8
|
+
* 1. sanitize-html strips scripts, styles, frames, forms and event handlers.
|
|
9
|
+
* 2. Remote images are neutered unless the reader explicitly asks for them,
|
|
10
|
+
* because loading one tells the sender the mail was opened, and by whom.
|
|
11
|
+
* 3. The result is handed to a sandboxed iframe (see admin/views/mail.js)
|
|
12
|
+
* with neither `allow-scripts` nor `allow-same-origin`, so even a hole in
|
|
13
|
+
* the first two layers has no origin to abuse.
|
|
14
|
+
*
|
|
15
|
+
* The CSP meta below is a fourth layer that costs nothing. None of these are
|
|
16
|
+
* individually sufficient, which is the point.
|
|
17
|
+
*
|
|
18
|
+
* @module mail-reader/services/render
|
|
19
|
+
*/
|
|
20
|
+
import sanitizeHtml from 'sanitize-html';
|
|
21
|
+
|
|
22
|
+
/** A 1x1 transparent GIF - stands in for a blocked remote image. */
|
|
23
|
+
const BLOCKED_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Tags worth keeping in a mail body. Everything absent from this list is
|
|
27
|
+
* unwrapped or dropped by sanitize-html.
|
|
28
|
+
*/
|
|
29
|
+
const ALLOWED_TAGS = [
|
|
30
|
+
'a', 'b', 'blockquote', 'br', 'caption', 'code', 'col', 'colgroup', 'dd', 'div', 'dl', 'dt',
|
|
31
|
+
'em', 'figcaption', 'figure', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li',
|
|
32
|
+
'ol', 'p', 'pre', 'q', 's', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'table',
|
|
33
|
+
'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'u', 'ul'
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build a cid → data URI map from a parsed message's inline attachments.
|
|
38
|
+
*
|
|
39
|
+
* Inline images are embedded rather than served from a route: they are already
|
|
40
|
+
* in memory, they are usually small, and a route would mean a second
|
|
41
|
+
* authenticated round trip per image.
|
|
42
|
+
*
|
|
43
|
+
* @param {object[]} attachments
|
|
44
|
+
* @param {number} maxBytes - per-image ceiling; larger ones are left blocked
|
|
45
|
+
* @returns {Map<string, string>}
|
|
46
|
+
*/
|
|
47
|
+
function inlineImageMap(attachments, maxBytes) {
|
|
48
|
+
const map = new Map();
|
|
49
|
+
for (const attachment of attachments ?? []) {
|
|
50
|
+
if (!attachment.cid || !attachment.content) continue;
|
|
51
|
+
if (!String(attachment.contentType ?? '').startsWith('image/')) continue;
|
|
52
|
+
if (attachment.content.length > maxBytes) continue;
|
|
53
|
+
map.set(attachment.cid, `data:${attachment.contentType};base64,${attachment.content.toString('base64')}`);
|
|
54
|
+
}
|
|
55
|
+
return map;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Sanitise a mail body and rewrite its images and links.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} html
|
|
62
|
+
* @param {{allowRemoteImages: boolean, inlineImages: Map<string, string>}} options
|
|
63
|
+
* @returns {{html: string, blockedImages: number}}
|
|
64
|
+
*/
|
|
65
|
+
function sanitiseBody(html, options) {
|
|
66
|
+
let blockedImages = 0;
|
|
67
|
+
|
|
68
|
+
const clean = sanitizeHtml(html, {
|
|
69
|
+
allowedTags: ALLOWED_TAGS,
|
|
70
|
+
allowedAttributes: {
|
|
71
|
+
// target/rel are added by transformTags below, and attribute
|
|
72
|
+
// filtering runs AFTER that - leave them off this list and the
|
|
73
|
+
// rel we just set is stripped straight back off again.
|
|
74
|
+
a: ['href', 'title', 'target', 'rel'],
|
|
75
|
+
img: ['src', 'alt', 'title', 'width', 'height', 'data-blocked-src'],
|
|
76
|
+
td: ['colspan', 'rowspan', 'align', 'valign'],
|
|
77
|
+
th: ['colspan', 'rowspan', 'align', 'valign'],
|
|
78
|
+
table: ['width', 'align'],
|
|
79
|
+
'*': ['style']
|
|
80
|
+
},
|
|
81
|
+
// Inline styles are what makes mail readable, so they stay - but only
|
|
82
|
+
// properties that cannot position, load or overlay anything.
|
|
83
|
+
allowedStyles: {
|
|
84
|
+
'*': {
|
|
85
|
+
'color': [/^.*$/],
|
|
86
|
+
'background-color': [/^.*$/],
|
|
87
|
+
'font-size': [/^.*$/],
|
|
88
|
+
'font-weight': [/^.*$/],
|
|
89
|
+
'font-style': [/^.*$/],
|
|
90
|
+
'font-family': [/^.*$/],
|
|
91
|
+
'text-align': [/^.*$/],
|
|
92
|
+
'text-decoration': [/^.*$/],
|
|
93
|
+
'padding': [/^.*$/],
|
|
94
|
+
'padding-top': [/^.*$/],
|
|
95
|
+
'padding-bottom': [/^.*$/],
|
|
96
|
+
'padding-left': [/^.*$/],
|
|
97
|
+
'padding-right': [/^.*$/],
|
|
98
|
+
'margin': [/^.*$/],
|
|
99
|
+
'border': [/^.*$/],
|
|
100
|
+
'border-collapse': [/^.*$/],
|
|
101
|
+
'width': [/^.*$/],
|
|
102
|
+
'max-width': [/^.*$/]
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
|
|
106
|
+
allowedSchemesByTag: {img: ['http', 'https', 'data', 'cid']},
|
|
107
|
+
// Anything not on the tag list loses its markup but keeps its text,
|
|
108
|
+
// except these, where the text is machinery rather than content.
|
|
109
|
+
nonTextTags: ['style', 'script', 'textarea', 'option', 'noscript', 'title', 'head'],
|
|
110
|
+
transformTags: {
|
|
111
|
+
a: (tagName, attribs) => ({
|
|
112
|
+
tagName: 'a',
|
|
113
|
+
attribs: {
|
|
114
|
+
...attribs,
|
|
115
|
+
target: '_blank',
|
|
116
|
+
rel: 'noopener noreferrer nofollow'
|
|
117
|
+
}
|
|
118
|
+
}),
|
|
119
|
+
img: (tagName, attribs) => {
|
|
120
|
+
const src = attribs.src ?? '';
|
|
121
|
+
|
|
122
|
+
if (src.startsWith('cid:')) {
|
|
123
|
+
const resolved = options.inlineImages.get(src.slice(4));
|
|
124
|
+
if (resolved) return {tagName: 'img', attribs: {...attribs, src: resolved}};
|
|
125
|
+
blockedImages += 1;
|
|
126
|
+
return {tagName: 'img', attribs: {...attribs, src: BLOCKED_PIXEL}};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (src.startsWith('data:image/')) {
|
|
130
|
+
return {tagName: 'img', attribs};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!options.allowRemoteImages) {
|
|
134
|
+
blockedImages += 1;
|
|
135
|
+
return {
|
|
136
|
+
tagName: 'img',
|
|
137
|
+
attribs: {...attribs, src: BLOCKED_PIXEL, 'data-blocked-src': src}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {tagName: 'img', attribs};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return {html: clean, blockedImages};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Escape text for safe interpolation into HTML.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} value
|
|
153
|
+
* @returns {string}
|
|
154
|
+
*/
|
|
155
|
+
function escapeHtml(value) {
|
|
156
|
+
return String(value)
|
|
157
|
+
.replace(/&/g, '&')
|
|
158
|
+
.replace(/</g, '<')
|
|
159
|
+
.replace(/>/g, '>')
|
|
160
|
+
.replace(/"/g, '"')
|
|
161
|
+
.replace(/'/g, ''');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Wrap a sanitised body in a self-contained document for iframe srcdoc.
|
|
166
|
+
*
|
|
167
|
+
* The stylesheet is ours, not the message's, and deliberately minimal: it sets
|
|
168
|
+
* a readable default and stops wide tables blowing out the pane. Mail that
|
|
169
|
+
* relies on its own layout still renders, because its inline styles survived.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} bodyHtml
|
|
172
|
+
* @returns {string}
|
|
173
|
+
*/
|
|
174
|
+
function wrapDocument(bodyHtml) {
|
|
175
|
+
return `<!DOCTYPE html>
|
|
176
|
+
<html><head>
|
|
177
|
+
<meta charset="utf-8">
|
|
178
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data: http: https:; style-src 'unsafe-inline'; font-src data:;">
|
|
179
|
+
<base target="_blank">
|
|
180
|
+
<style>
|
|
181
|
+
html, body { margin: 0; padding: 16px; }
|
|
182
|
+
body {
|
|
183
|
+
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
184
|
+
color: #1a1a1a; background: #ffffff; word-wrap: break-word;
|
|
185
|
+
}
|
|
186
|
+
img { max-width: 100%; height: auto; }
|
|
187
|
+
table { max-width: 100%; }
|
|
188
|
+
pre { white-space: pre-wrap; }
|
|
189
|
+
blockquote {
|
|
190
|
+
margin: 0 0 0 8px; padding-left: 12px;
|
|
191
|
+
border-left: 3px solid #d0d0d0; color: #555;
|
|
192
|
+
}
|
|
193
|
+
</style>
|
|
194
|
+
</head><body>${bodyHtml}</body></html>`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Sanitise a fragment of someone else's HTML for inclusion in our own.
|
|
199
|
+
*
|
|
200
|
+
* Used when quoting a message into a reply: the original's formatting is
|
|
201
|
+
* worth keeping, its scripts and styles are not. The same rules as the reader
|
|
202
|
+
* uses, minus the document wrapper, and with remote images dropped rather
|
|
203
|
+
* than blocked - a quote should not carry a tracking pixel onward to
|
|
204
|
+
* everybody on the reply.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} html
|
|
207
|
+
* @returns {string}
|
|
208
|
+
*/
|
|
209
|
+
export function sanitiseFragment(html) {
|
|
210
|
+
const {html: clean} = sanitiseBody(String(html ?? ''), {
|
|
211
|
+
allowRemoteImages: false,
|
|
212
|
+
inlineImages: new Map()
|
|
213
|
+
});
|
|
214
|
+
// The blocked-image placeholders are meaningful in a reader and just
|
|
215
|
+
// clutter in a quote.
|
|
216
|
+
return clean.replace(/<img[^>]*data-blocked-src[^>]*>/gi, '');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Sanitise a signature the mailbox owner wrote.
|
|
221
|
+
*
|
|
222
|
+
* The same allowlist the reader and the quote use, with remote images ALLOWED
|
|
223
|
+
* - which is the one thing that separates it from `sanitiseFragment`. A
|
|
224
|
+
* blocked image in an incoming message is a tracking pixel defused; a blocked
|
|
225
|
+
* image in your own signature is your company logo missing from every message
|
|
226
|
+
* you send.
|
|
227
|
+
*
|
|
228
|
+
* Applied on save rather than on send, so the stored copy is the safe one:
|
|
229
|
+
* this HTML is handed back to the settings editor as well as pushed out to
|
|
230
|
+
* recipients, and a sanitiser that only ran on the way out would leave the
|
|
231
|
+
* admin rendering whatever was stored.
|
|
232
|
+
*
|
|
233
|
+
* @param {string} html
|
|
234
|
+
* @returns {string}
|
|
235
|
+
*/
|
|
236
|
+
export function sanitiseSignature(html) {
|
|
237
|
+
const {html: clean} = sanitiseBody(String(html ?? ''), {
|
|
238
|
+
allowRemoteImages: true,
|
|
239
|
+
inlineImages: new Map()
|
|
240
|
+
});
|
|
241
|
+
return clean;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Turn a parsed message into everything the reader pane needs.
|
|
246
|
+
*
|
|
247
|
+
* @param {object} parsed - output of mailparser's simpleParser
|
|
248
|
+
* @param {{allowRemoteImages: boolean, inlineImageMaxBytes: number}} options
|
|
249
|
+
* @returns {{document: string, blockedImages: number, isHtml: boolean}}
|
|
250
|
+
*/
|
|
251
|
+
export function renderBody(parsed, options) {
|
|
252
|
+
const inlineImages = inlineImageMap(parsed.attachments, options.inlineImageMaxBytes);
|
|
253
|
+
|
|
254
|
+
if (parsed.html) {
|
|
255
|
+
const {html, blockedImages} = sanitiseBody(parsed.html, {
|
|
256
|
+
allowRemoteImages: options.allowRemoteImages,
|
|
257
|
+
inlineImages
|
|
258
|
+
});
|
|
259
|
+
return {document: wrapDocument(html), blockedImages, isHtml: true};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Plain-text mail: escape it and keep its line breaks. mailparser offers
|
|
263
|
+
// textAsHtml, but it is not sanitised, so it goes through the same path.
|
|
264
|
+
const text = parsed.text ?? '';
|
|
265
|
+
const {html} = sanitiseBody(`<pre>${escapeHtml(text)}</pre>`, {
|
|
266
|
+
allowRemoteImages: options.allowRemoteImages,
|
|
267
|
+
inlineImages
|
|
268
|
+
});
|
|
269
|
+
return {document: wrapDocument(html), blockedImages: 0, isHtml: false};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Summarise a message's attachments for the reader pane.
|
|
274
|
+
*
|
|
275
|
+
* Inline images that were embedded in the body are excluded - listing them
|
|
276
|
+
* as downloads when they are already visible is just noise.
|
|
277
|
+
*
|
|
278
|
+
* @param {object[]} attachments
|
|
279
|
+
* @returns {{index: number, filename: string, contentType: string, size: number}[]}
|
|
280
|
+
*/
|
|
281
|
+
export function listAttachments(attachments) {
|
|
282
|
+
return (attachments ?? [])
|
|
283
|
+
.map((attachment, index) => ({attachment, index}))
|
|
284
|
+
.filter(({attachment}) => attachment.contentDisposition !== 'inline' || !attachment.cid)
|
|
285
|
+
.map(({attachment, index}) => ({
|
|
286
|
+
index,
|
|
287
|
+
filename: attachment.filename || `attachment-${index + 1}`,
|
|
288
|
+
contentType: attachment.contentType || 'application/octet-stream',
|
|
289
|
+
size: attachment.size ?? attachment.content?.length ?? 0
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carrying out what the rules decided.
|
|
3
|
+
*
|
|
4
|
+
* The decision is `planActions()` in `_lib/admin/mail/rules.js`, which is pure
|
|
5
|
+
* and knows nothing about IMAP. This is the half that talks to the server.
|
|
6
|
+
* Splitting them is what makes a rule testable without a mailbox, and what
|
|
7
|
+
* lets the settings screen preview a rule against real messages using exactly
|
|
8
|
+
* the code that will run it.
|
|
9
|
+
*
|
|
10
|
+
* ## Only new mail, ever
|
|
11
|
+
*
|
|
12
|
+
* Two guards, both necessary, because getting this wrong means silently
|
|
13
|
+
* refiling somebody's archive:
|
|
14
|
+
*
|
|
15
|
+
* 1. Rules run only over messages a sync pass has just ADDED, and never
|
|
16
|
+
* during a full resync - a UIDVALIDITY change re-adds the entire folder,
|
|
17
|
+
* and that is not new mail arriving, it is the same mail being
|
|
18
|
+
* re-fetched.
|
|
19
|
+
* 2. Messages older than `rulesFrom` - stamped the first time rules were
|
|
20
|
+
* saved - are skipped even if they were just added, which covers a
|
|
21
|
+
* folder being mirrored for the first time.
|
|
22
|
+
*
|
|
23
|
+
* Neither guard alone is enough, and the cost of both is a comparison.
|
|
24
|
+
*
|
|
25
|
+
* @module _lib/mail/ruleRunner
|
|
26
|
+
*/
|
|
27
|
+
import {planActions} from '../admin/mail/rules.js';
|
|
28
|
+
import {
|
|
29
|
+
deleteMessage,
|
|
30
|
+
findJunkFolder,
|
|
31
|
+
JUNK_KEYWORD,
|
|
32
|
+
moveMessage
|
|
33
|
+
} from './write.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Which of a folder's newly added messages the rules may touch.
|
|
37
|
+
*
|
|
38
|
+
* @param {object[]} messages
|
|
39
|
+
* @param {{mode: string}} sync - the sync result for that folder
|
|
40
|
+
* @param {string|null} since - ISO timestamp, or null for no floor
|
|
41
|
+
* @returns {object[]}
|
|
42
|
+
*/
|
|
43
|
+
export function eligible(messages, sync, since) {
|
|
44
|
+
// A full resync is the whole folder arriving again, not new mail.
|
|
45
|
+
if (sync?.mode === 'full') return [];
|
|
46
|
+
|
|
47
|
+
const floor = since ? new Date(since).getTime() : null;
|
|
48
|
+
return (messages ?? []).filter(message => {
|
|
49
|
+
if (floor === null) return true;
|
|
50
|
+
const date = new Date(String(message?.date ?? '')).getTime();
|
|
51
|
+
// A message with no date at all is treated as new. The alternative is
|
|
52
|
+
// skipping it forever, and an undated message is far more often a
|
|
53
|
+
// quirk of the sending client than an archive.
|
|
54
|
+
return !Number.isFinite(date) || date >= floor;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Run the rules over one folder's new mail.
|
|
60
|
+
*
|
|
61
|
+
* Must be called with NO mailbox lock held. Moving a message opens its own
|
|
62
|
+
* mailbox on the same connection, and ImapFlow queues mailbox locks per
|
|
63
|
+
* client - so calling this from inside the sync's `withMailbox` would wait
|
|
64
|
+
* for a lock that is only released when the caller returns.
|
|
65
|
+
*
|
|
66
|
+
* Each message is handled independently and failures are collected rather
|
|
67
|
+
* than thrown: one message that has already been moved by another client must
|
|
68
|
+
* not stop the rest of the folder being filed.
|
|
69
|
+
*
|
|
70
|
+
* @param {{pool: object, store: object|null, account: object, poolKey: string,
|
|
71
|
+
* connection: object, folder: string, messages: object[], rules: object[]}} deps
|
|
72
|
+
* @returns {Promise<{considered: number, acted: number, moved: number, errors: string[]}>}
|
|
73
|
+
*/
|
|
74
|
+
export async function applyRules({
|
|
75
|
+
pool, store, account, poolKey, connection, folder, messages, rules
|
|
76
|
+
}) {
|
|
77
|
+
const report = {considered: messages?.length ?? 0, acted: 0, moved: 0, errors: []};
|
|
78
|
+
if (!report.considered || !(rules ?? []).length) return report;
|
|
79
|
+
|
|
80
|
+
// Resolved once for the whole pass rather than per message: it is a LIST
|
|
81
|
+
// round trip, and a folder of newsletters would otherwise make one per
|
|
82
|
+
// message.
|
|
83
|
+
let junk;
|
|
84
|
+
|
|
85
|
+
for (const message of messages) {
|
|
86
|
+
const {actions} = planActions(rules, message);
|
|
87
|
+
if (!actions.length) continue;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
// Flags first, destination last. A message that is moved has a new
|
|
91
|
+
// UID in its new home, so anything flagged afterwards would be
|
|
92
|
+
// flagging a UID that no longer exists here.
|
|
93
|
+
const add = [];
|
|
94
|
+
for (const action of actions) {
|
|
95
|
+
if (action.type === 'markRead') add.push('\\Seen');
|
|
96
|
+
if (action.type === 'flag') add.push('\\Flagged');
|
|
97
|
+
if (action.type === 'junk') add.push(JUNK_KEYWORD);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (add.length) {
|
|
101
|
+
await pool.withMailbox(poolKey, connection, folder, client =>
|
|
102
|
+
client.messageFlagsAdd({uid: String(message.uid)}, add, {uid: true}),
|
|
103
|
+
{writable: true});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const copy = actions.find(a => a.type === 'copy');
|
|
107
|
+
if (copy) {
|
|
108
|
+
await pool.withMailbox(poolKey, connection, folder, client =>
|
|
109
|
+
client.messageCopy({uid: String(message.uid)}, copy.folder, {uid: true}),
|
|
110
|
+
{writable: true});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const destination = actions.find(a => ['move', 'junk', 'delete'].includes(a.type));
|
|
114
|
+
if (destination) {
|
|
115
|
+
if (destination.type === 'delete') {
|
|
116
|
+
await deleteMessage({
|
|
117
|
+
pool, store, account, poolKey, connection, folder, uid: message.uid
|
|
118
|
+
});
|
|
119
|
+
} else {
|
|
120
|
+
if (destination.type === 'junk' && junk === undefined) {
|
|
121
|
+
junk = await findJunkFolder(pool, poolKey, connection);
|
|
122
|
+
}
|
|
123
|
+
const target = destination.type === 'junk' ? junk : destination.folder;
|
|
124
|
+
// A junk action on a server with no junk folder still
|
|
125
|
+
// marked the message; there is simply nowhere to put it,
|
|
126
|
+
// which is better than inventing a folder name.
|
|
127
|
+
if (target && target !== folder) {
|
|
128
|
+
await moveMessage({
|
|
129
|
+
pool, store, account, poolKey, connection,
|
|
130
|
+
folder, uid: message.uid, destination: target
|
|
131
|
+
});
|
|
132
|
+
report.moved += 1;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} else if (add.length && store) {
|
|
136
|
+
// Flagged in place: the mirror row is still ours to correct,
|
|
137
|
+
// and leaving it stale means the list shows unread mail that
|
|
138
|
+
// a rule has just read.
|
|
139
|
+
await store.setFlags(account.id, folder, [{
|
|
140
|
+
uid: message.uid,
|
|
141
|
+
flags: [...new Set([...(message.flags ?? []), ...add])]
|
|
142
|
+
}]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
report.acted += 1;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
report.errors.push(`uid ${message.uid}: ${err.message}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return report;
|
|
152
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worker behind "send later", "undo send" and "snooze".
|
|
3
|
+
*
|
|
4
|
+
* One ticker, two queues, for the same reason the poller is one ticker rather
|
|
5
|
+
* than a timer per mailbox: the work is small, the queues are short, and a
|
|
6
|
+
* timer per queued message is a timer to cancel when the message is cancelled.
|
|
7
|
+
*
|
|
8
|
+
* ## Why a queue at all, for a ten second undo
|
|
9
|
+
*
|
|
10
|
+
* Holding a message in memory for ten seconds would be simpler and would lose
|
|
11
|
+
* mail. A restart during the hold - a deploy, a crash, `pm2 reload` - takes
|
|
12
|
+
* the only copy with it, and the sender has already been told it is on its
|
|
13
|
+
* way. Everything that has been accepted from a person is durable before they
|
|
14
|
+
* are told it was accepted.
|
|
15
|
+
*
|
|
16
|
+
* ## Sent exactly once
|
|
17
|
+
*
|
|
18
|
+
* The claim is a conditional update in the store (`claimDueSends`), so a tick
|
|
19
|
+
* that overruns into the next one cannot pick the same message up twice.
|
|
20
|
+
* Everything after the claim is best-effort and reported; the claim itself is
|
|
21
|
+
* the part that must be exact, because a duplicate message cannot be recalled.
|
|
22
|
+
*
|
|
23
|
+
* @module _lib/mail/scheduler
|
|
24
|
+
*/
|
|
25
|
+
import * as accounts from './accounts.js';
|
|
26
|
+
import {sendMessage} from './send.js';
|
|
27
|
+
|
|
28
|
+
/** How often the ticker looks for work. */
|
|
29
|
+
const TICK_MS = 5_000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How many times a queued send is retried before it is given up on.
|
|
33
|
+
*
|
|
34
|
+
* A mail server that is briefly unreachable should not lose the message; one
|
|
35
|
+
* that refuses it should not be asked forever. Three is enough for the first
|
|
36
|
+
* and few enough for the second.
|
|
37
|
+
*/
|
|
38
|
+
const MAX_ATTEMPTS = 3;
|
|
39
|
+
|
|
40
|
+
/** How long to wait before trying a failed send again. */
|
|
41
|
+
const RETRY_MS = 120_000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create the scheduler.
|
|
45
|
+
*
|
|
46
|
+
* @param {{store: object, pool: object, config: object, logger: object,
|
|
47
|
+
* tickMs?: number}} deps
|
|
48
|
+
* @returns {{start: Function, stop: Function, runNow: Function}}
|
|
49
|
+
*/
|
|
50
|
+
export function createScheduler({store, pool, config, logger, tickMs = TICK_MS}) {
|
|
51
|
+
let timer = null;
|
|
52
|
+
let stopped = false;
|
|
53
|
+
let running = false;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Send one claimed message.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} item
|
|
59
|
+
* @returns {Promise<void>}
|
|
60
|
+
*/
|
|
61
|
+
async function runSend(item) {
|
|
62
|
+
const account = await accounts.getAccount(item.userId, item.accountId);
|
|
63
|
+
if (!account) {
|
|
64
|
+
// The mailbox was removed while this was queued. There is nothing
|
|
65
|
+
// to send it from and never will be.
|
|
66
|
+
await store.finishSend(item._id, {
|
|
67
|
+
status: 'failed',
|
|
68
|
+
error: 'That mailbox no longer exists.'
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const smtp = accounts.toSmtpConnection(account, item.identityId ?? null);
|
|
74
|
+
if (!smtp) {
|
|
75
|
+
await store.finishSend(item._id, {
|
|
76
|
+
status: 'failed',
|
|
77
|
+
error: 'No outgoing server is configured for this mailbox.'
|
|
78
|
+
});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const result = await sendMessage({
|
|
84
|
+
smtp,
|
|
85
|
+
draft: item.draft,
|
|
86
|
+
pool,
|
|
87
|
+
poolKey: `${item.userId}:${item.accountId}`,
|
|
88
|
+
connection: accounts.toConnection(account),
|
|
89
|
+
sentFolder: smtp.sentFolder,
|
|
90
|
+
allowInsecureTLS: config.allowInsecureTLS === true,
|
|
91
|
+
replaces: item.replaces ?? null
|
|
92
|
+
});
|
|
93
|
+
await store.finishSend(item._id, {status: 'sent', result, error: null});
|
|
94
|
+
} catch (err) {
|
|
95
|
+
const giveUp = (item.attempts ?? 1) >= MAX_ATTEMPTS;
|
|
96
|
+
await store.finishSend(item._id, giveUp
|
|
97
|
+
? {status: 'failed', error: err.message}
|
|
98
|
+
// Back to pending, a little later. `finishedAt` is set either
|
|
99
|
+
// way, which is harmless on a retry and is what tells a
|
|
100
|
+
// screen when the last attempt was.
|
|
101
|
+
: {status: 'pending', at: new Date(Date.now() + RETRY_MS), error: err.message});
|
|
102
|
+
|
|
103
|
+
logger.warn(
|
|
104
|
+
`[mail] queued send ${item._id} failed (attempt ${item.attempts}): ${err.message}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Bring one snoozed message back.
|
|
111
|
+
*
|
|
112
|
+
* Moved back where it came from and marked unread, because the whole
|
|
113
|
+
* point of putting it away was to be shown it again - a message that
|
|
114
|
+
* returns already read returns invisibly.
|
|
115
|
+
*
|
|
116
|
+
* @param {object} item
|
|
117
|
+
* @returns {Promise<void>}
|
|
118
|
+
*/
|
|
119
|
+
async function runWake(item) {
|
|
120
|
+
const account = await accounts.getAccount(item.userId, item.accountId);
|
|
121
|
+
if (!account) {
|
|
122
|
+
await store.finishSnooze(item._id, {status: 'failed', error: 'That mailbox no longer exists.'});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const poolKey = `${item.userId}:${item.accountId}`;
|
|
127
|
+
const connection = accounts.toConnection(account);
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
// The UID the move reported, when the server has UIDPLUS. Without
|
|
131
|
+
// it the message is found by its Message-ID instead - a snooze on
|
|
132
|
+
// such a server is still a snooze, and refusing to bring it back
|
|
133
|
+
// would be losing the message rather than delaying it.
|
|
134
|
+
const uid = item.uid ?? await findByMessageId(poolKey, connection, item);
|
|
135
|
+
if (uid === null) {
|
|
136
|
+
throw new Error('That message is no longer in the snooze folder.');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Unread first, then moved. The other order would be flagging a
|
|
140
|
+
// UID that has just stopped existing in this folder.
|
|
141
|
+
await pool.withMailbox(poolKey, connection, item.folder, client =>
|
|
142
|
+
client.messageFlagsRemove({uid: String(uid)}, ['\\Seen'], {uid: true}),
|
|
143
|
+
{writable: true});
|
|
144
|
+
|
|
145
|
+
const {moveMessage} = await import('./write.js');
|
|
146
|
+
const moved = await moveMessage({
|
|
147
|
+
pool, store, account, poolKey, connection,
|
|
148
|
+
folder: item.folder, uid, destination: item.returnTo
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await store.finishSnooze(item._id, {
|
|
152
|
+
status: 'woken', error: null, newUid: moved.newUid ?? null
|
|
153
|
+
});
|
|
154
|
+
} catch (err) {
|
|
155
|
+
// Retried, not given up on. A mail server that is briefly
|
|
156
|
+
// unreachable is an ordinary condition, and a snooze abandoned on
|
|
157
|
+
// the first blip leaves the message parked in a folder the user
|
|
158
|
+
// does not read, absent from the list that was supposed to bring
|
|
159
|
+
// it back.
|
|
160
|
+
const attempts = (item.attempts ?? 0) + 1;
|
|
161
|
+
const giveUp = attempts >= MAX_ATTEMPTS;
|
|
162
|
+
await store.finishSnooze(item._id, giveUp
|
|
163
|
+
? {status: 'failed', error: err.message, attempts}
|
|
164
|
+
: {status: 'pending', at: new Date(Date.now() + RETRY_MS), error: err.message, attempts});
|
|
165
|
+
logger.warn(
|
|
166
|
+
`[mail] could not wake snoozed message ${item._id} (attempt ${attempts}): ${err.message}`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Find a parked message by its Message-ID.
|
|
173
|
+
*
|
|
174
|
+
* The way back on a server with no UIDPLUS, which cannot report the UID a
|
|
175
|
+
* moved message was given. A HEADER search is not fast, but the snooze
|
|
176
|
+
* folder holds only what is waiting, and this runs once per message when
|
|
177
|
+
* it comes due.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} poolKey
|
|
180
|
+
* @param {object} connection
|
|
181
|
+
* @param {object} item
|
|
182
|
+
* @returns {Promise<number|null>}
|
|
183
|
+
*/
|
|
184
|
+
async function findByMessageId(poolKey, connection, item) {
|
|
185
|
+
if (!item.messageId) return null;
|
|
186
|
+
return pool.withMailbox(poolKey, connection, item.folder, async (client) => {
|
|
187
|
+
const found = await client.search({header: {'message-id': item.messageId}}, {uid: true});
|
|
188
|
+
// The newest match, in the vanishingly unlikely case a folder
|
|
189
|
+
// holds two messages with one id.
|
|
190
|
+
return found?.length ? Number(found[found.length - 1]) : null;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* One pass over both queues.
|
|
196
|
+
*
|
|
197
|
+
* Guarded against overlapping itself: a slow mail server can make a send
|
|
198
|
+
* take longer than the tick interval, and a second pass starting
|
|
199
|
+
* underneath the first would double the connections without doubling the
|
|
200
|
+
* throughput.
|
|
201
|
+
*
|
|
202
|
+
* @returns {Promise<void>}
|
|
203
|
+
*/
|
|
204
|
+
async function tick() {
|
|
205
|
+
if (stopped || running) return;
|
|
206
|
+
running = true;
|
|
207
|
+
try {
|
|
208
|
+
for (const item of await store.claimDueSends()) {
|
|
209
|
+
if (stopped) return;
|
|
210
|
+
await runSend(item);
|
|
211
|
+
}
|
|
212
|
+
for (const item of await store.claimDueSnoozes()) {
|
|
213
|
+
if (stopped) return;
|
|
214
|
+
await runWake(item);
|
|
215
|
+
}
|
|
216
|
+
} catch (err) {
|
|
217
|
+
// The store being unreachable is an ordinary condition here -
|
|
218
|
+
// Mongo restarting, say - and the next tick will try again.
|
|
219
|
+
logger.warn(`[mail] scheduler tick failed: ${err.message}`);
|
|
220
|
+
} finally {
|
|
221
|
+
running = false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
/**
|
|
227
|
+
* @returns {void}
|
|
228
|
+
*/
|
|
229
|
+
start() {
|
|
230
|
+
if (timer) return;
|
|
231
|
+
stopped = false;
|
|
232
|
+
timer = setInterval(() => {
|
|
233
|
+
tick().catch(err => logger.warn(`[mail] scheduler tick failed: ${err.message}`));
|
|
234
|
+
}, tickMs);
|
|
235
|
+
timer.unref?.();
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* @returns {void}
|
|
240
|
+
*/
|
|
241
|
+
stop() {
|
|
242
|
+
stopped = true;
|
|
243
|
+
if (timer) clearInterval(timer);
|
|
244
|
+
timer = null;
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Run a pass immediately - used by "send it now" on a queued message.
|
|
249
|
+
*
|
|
250
|
+
* @returns {Promise<void>}
|
|
251
|
+
*/
|
|
252
|
+
runNow: tick
|
|
253
|
+
};
|
|
254
|
+
}
|