domma-cms 0.55.1 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +130 -2
- package/README.md +4 -4
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +3 -3
- package/admin/js/lib/page-picker.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/slideover-resizable.js +1 -0
- package/admin/js/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/plugin-code.html +1 -1
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +16 -97
- package/admin/js/templates/theme.html +173 -0
- package/admin/js/views/context-menu-editor.js +55 -0
- package/admin/js/views/context-menus.js +5 -0
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +25 -23
- package/admin/js/views/search.js +1 -0
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -0
- package/bin/cli.js +2 -0
- package/bin/update.js +13 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/theme.json +18 -0
- package/package.json +12 -4
- package/plugins/_lib/admin/mail/compose-window.js +914 -0
- package/plugins/_lib/admin/mail/contacts.js +301 -0
- package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
- package/plugins/_lib/admin/mail/folder-tree.js +254 -0
- package/plugins/_lib/admin/mail/identity.js +480 -0
- package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
- package/plugins/_lib/admin/mail/keyboard.js +136 -0
- package/plugins/_lib/admin/mail/mail.css +1 -0
- package/plugins/_lib/admin/mail/mail.html +71 -0
- package/plugins/_lib/admin/mail/panes.js +253 -0
- package/plugins/_lib/admin/mail/reader-view.js +4453 -0
- package/plugins/_lib/admin/mail/resizable.js +26 -0
- package/plugins/_lib/admin/mail/rules.js +343 -0
- package/plugins/_lib/admin/mail/scheduling.js +203 -0
- package/plugins/_lib/admin/mail/section-kit.js +277 -0
- package/plugins/_lib/admin/mail/templates.js +238 -0
- package/plugins/_lib/admin/mail/threads.js +200 -0
- package/plugins/_lib/admin/mail/vacation.js +269 -0
- package/plugins/_lib/admin/ui/help.css +1 -0
- package/plugins/_lib/admin/ui/help.js +174 -0
- package/plugins/_lib/admin/ui/resizable.js +151 -0
- package/plugins/_lib/dataStore.js +117 -0
- package/plugins/_lib/mail/accounts.js +919 -0
- package/plugins/_lib/mail/bodyTokens.js +101 -0
- package/plugins/_lib/mail/compose.js +256 -0
- package/plugins/_lib/mail/defaults.js +57 -0
- package/plugins/_lib/mail/diagnostics.js +154 -0
- package/plugins/_lib/mail/envelope.js +161 -0
- package/plugins/_lib/mail/folders.js +192 -0
- package/plugins/_lib/mail/handoff.js +274 -0
- package/plugins/_lib/mail/imapPool.js +293 -0
- package/plugins/_lib/mail/mbox.js +74 -0
- package/plugins/_lib/mail/pollSchedule.js +79 -0
- package/plugins/_lib/mail/poller.js +135 -0
- package/plugins/_lib/mail/priority.js +138 -0
- package/plugins/_lib/mail/readRoutes.js +680 -0
- package/plugins/_lib/mail/render.js +291 -0
- package/plugins/_lib/mail/ruleRunner.js +152 -0
- package/plugins/_lib/mail/scheduler.js +254 -0
- package/plugins/_lib/mail/secretbox.js +229 -0
- package/plugins/_lib/mail/send.js +396 -0
- package/plugins/_lib/mail/store.js +1002 -0
- package/plugins/_lib/mail/sync.js +292 -0
- package/plugins/_lib/mail/syncPlan.js +126 -0
- package/plugins/_lib/mail/syncSelection.js +82 -0
- package/plugins/_lib/mail/unsubscribe.js +183 -0
- package/plugins/_lib/mail/vacationRunner.js +114 -0
- package/plugins/_lib/mail/write.js +473 -0
- package/plugins/_lib/schemaSync.js +83 -0
- package/plugins/_template/admin/css/index.css +0 -0
- package/plugins/_template/admin/templates/index.html +4 -4
- package/plugins/_template/admin/views/index.js +7 -0
- package/plugins/analytics/admin/css/index.css +1 -0
- package/plugins/analytics/admin/templates/analytics.html +22 -13
- package/plugins/analytics/plugin.json +3 -0
- package/plugins/blog/admin/css/index.css +1 -0
- package/plugins/blog/admin/templates/blog.html +30 -18
- package/plugins/blog/admin/templates/categories.html +2 -2
- package/plugins/blog/admin/templates/comments.html +2 -2
- package/plugins/blog/admin/templates/post-editor.html +34 -34
- package/plugins/blog/admin/templates/settings.html +6 -3
- package/plugins/blog/admin/views/blog.js +8 -5
- package/plugins/blog/admin/views/categories.js +5 -10
- package/plugins/blog/admin/views/comments.js +5 -5
- package/plugins/blog/admin/views/post-editor.js +39 -20
- package/plugins/blog/admin/views/settings.js +52 -50
- package/plugins/blog/collections/categories/schema.json +7 -6
- package/plugins/blog/collections/comments/schema.json +11 -10
- package/plugins/blog/collections/posts/schema.json +14 -13
- package/plugins/blog/plugin.js +36 -13
- package/plugins/blog/plugin.json +13 -5
- package/plugins/blog/plugin.public.js +312 -0
- package/plugins/contacts/admin/css/index.css +1 -0
- package/plugins/contacts/admin/templates/contacts.html +128 -0
- package/plugins/contacts/admin/views/contacts.js +237 -4
- package/plugins/contacts/collections/user-contacts/schema.json +108 -0
- package/plugins/contacts/plugin.js +214 -27
- package/plugins/contacts/plugin.json +4 -1
- package/plugins/invoice/admin/css/index.css +1 -0
- package/plugins/invoice/admin/templates/editor.html +140 -49
- package/plugins/invoice/admin/templates/index.html +153 -23
- package/plugins/invoice/admin/templates/issuers.html +2 -5
- package/plugins/invoice/admin/templates/receivers.html +2 -5
- package/plugins/invoice/admin/views/contacts-source.js +266 -0
- package/plugins/invoice/admin/views/editor.js +366 -199
- package/plugins/invoice/admin/views/export.js +199 -0
- package/plugins/invoice/admin/views/help-content.js +61 -0
- package/plugins/invoice/admin/views/index.js +582 -94
- package/plugins/invoice/admin/views/issuers.js +24 -17
- package/plugins/invoice/admin/views/media.js +172 -0
- package/plugins/invoice/admin/views/party-view.js +305 -67
- package/plugins/invoice/admin/views/payments.js +127 -0
- package/plugins/invoice/admin/views/print.js +130 -0
- package/plugins/invoice/admin/views/receivers.js +49 -16
- package/plugins/invoice/admin/views/send.js +212 -0
- package/plugins/invoice/admin/views/settings.js +594 -0
- package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
- package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
- package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
- package/plugins/invoice/collections/invoices/schema.json +19 -13
- package/plugins/invoice/config.js +27 -6
- package/plugins/invoice/pdf.js +164 -0
- package/plugins/invoice/plugin.js +1217 -44
- package/plugins/invoice/plugin.json +10 -9
- package/plugins/invoice/templates/_base.css +1 -0
- package/plugins/invoice/templates/classic-nologo.html +100 -0
- package/plugins/invoice/templates/classic.html +91 -0
- package/plugins/invoice/templates/invoice-print.html +24 -0
- package/plugins/invoice/templates/minimal.html +99 -0
- package/plugins/invoice/templates/modern-nologo.html +114 -0
- package/plugins/invoice/templates/modern.html +113 -0
- package/plugins/invoice/templates/templates.json +11 -0
- package/plugins/mail-reader/admin/views/mail.js +19 -0
- package/plugins/mail-reader/config.js +7 -0
- package/plugins/mail-reader/plugin.js +48 -0
- package/plugins/mail-reader/plugin.json +33 -0
- package/plugins/notes/admin/views/notes.js +1 -1
- package/plugins/notes/plugin.json +2 -2
- package/plugins/surveys/lib/audience.js +37 -0
- package/plugins/surveys/lib/campaigns.js +43 -0
- package/plugins/surveys/lib/ledger.js +110 -0
- package/plugins/surveys/lib/sending.js +106 -0
- package/plugins/surveys/lib/stats.js +62 -0
- package/plugins/surveys/lib/submit.js +95 -0
- package/plugins/surveys/lib/tokens.js +28 -0
- package/plugins/surveys/plugin.public.js +149 -0
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
- package/public/css/forms.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/search.js +1 -0
- package/public/js/site.js +1 -1
- package/scripts/build.js +37 -3
- package/scripts/copy-domma.js +48 -0
- package/scripts/seed.js +1996 -0
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/forms.js +42 -3
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugins.js +50 -6
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +74 -0
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/markdown.js +70 -9
- package/server/services/permissionRegistry.js +24 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/plugins.js +229 -6
- package/server/services/renderer.js +144 -22
- package/server/services/roles.js +1 -1
- package/server/services/search-migration.js +82 -0
- package/server/services/search.js +413 -0
- package/server/services/sidebar-migration.js +1 -0
- package/server/services/themeSettings.js +541 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/notes/data/notes.json +0 -1
- package/plugins/site-search/admin/views/site-search.js +0 -116
- package/plugins/site-search/config.js +0 -15
- package/plugins/site-search/plugin.js +0 -188
- package/plugins/site-search/plugin.json +0 -40
- package/plugins/site-search/public/inject-body.html +0 -17
- package/plugins/site-search/public/inject-head.html +0 -1
- package/plugins/site-search/public/search.css +0 -1
- package/plugins/site-search/public/search.js +0 -1
- package/plugins/todo/data/todos.json +0 -1
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IMAP connection pool for the Mail Reader plugin.
|
|
3
|
+
*
|
|
4
|
+
* IMAP is unlike anything else the CMS talks to: the connection is long-lived,
|
|
5
|
+
* stateful, and handles exactly one command at a time. Opening a fresh
|
|
6
|
+
* connection per request would mean a TLS handshake and a LOGIN for every
|
|
7
|
+
* click, and most mail servers rate-limit that fairly aggressively.
|
|
8
|
+
*
|
|
9
|
+
* So connections are cached per user and reaped when idle. All mailbox access
|
|
10
|
+
* goes through `withMailbox()`, which takes ImapFlow's own mailbox lock, so
|
|
11
|
+
* two overlapping requests from the same user queue rather than corrupting
|
|
12
|
+
* each other's command stream.
|
|
13
|
+
*
|
|
14
|
+
* ## Read-only, twice over
|
|
15
|
+
*
|
|
16
|
+
* Every mailbox is opened `{readOnly: true}` and ImapFlow fetches with
|
|
17
|
+
* `BODY.PEEK[]` (see its commands/fetch.js). Either alone would stop the
|
|
18
|
+
* server setting `\Seen` as a side effect of reading a message; phase 1 has
|
|
19
|
+
* both, because a reader that silently marks a user's mail as read on their
|
|
20
|
+
* phone is worse than no reader at all. Do not relax this without making
|
|
21
|
+
* mark-as-read a deliberate, user-visible action.
|
|
22
|
+
*
|
|
23
|
+
* **Single-process only.** The pool is module state, exactly like the messages
|
|
24
|
+
* plugin's SSE registry. Running the site across workers would give each
|
|
25
|
+
* worker its own connections and blow through per-user connection limits.
|
|
26
|
+
*
|
|
27
|
+
* @module mail-reader/services/imapPool
|
|
28
|
+
*/
|
|
29
|
+
import {ImapFlow} from 'imapflow';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Identify a connection's target. A change to any of these means the cached
|
|
33
|
+
* connection is for a different account and must be thrown away.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} connection
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
function fingerprint(connection) {
|
|
39
|
+
return [connection.host, connection.port, connection.secure, connection.auth.user].join('|');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create an IMAP connection pool.
|
|
44
|
+
*
|
|
45
|
+
* @param {{connectionIdleMs: number, maxConnections: number, connectionTimeoutMs: number, allowInsecureTLS: boolean}} config
|
|
46
|
+
* @returns {object} pool API
|
|
47
|
+
*/
|
|
48
|
+
export function createPool(config) {
|
|
49
|
+
/** @type {Map<string, {client: ImapFlow, fingerprint: string, lastUsed: number}>} */
|
|
50
|
+
const pool = new Map();
|
|
51
|
+
|
|
52
|
+
/** In-flight connects, so two parallel requests do not open two connections. */
|
|
53
|
+
const pending = new Map();
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build the ImapFlow options for a connection.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} connection
|
|
59
|
+
* @returns {object}
|
|
60
|
+
*/
|
|
61
|
+
function clientOptions(connection) {
|
|
62
|
+
return {
|
|
63
|
+
host: connection.host,
|
|
64
|
+
port: connection.port,
|
|
65
|
+
secure: connection.secure,
|
|
66
|
+
auth: connection.auth,
|
|
67
|
+
logger: false,
|
|
68
|
+
// A dead mail server should fail the request, not hang the admin.
|
|
69
|
+
greetingTimeout: config.connectionTimeoutMs,
|
|
70
|
+
socketTimeout: config.connectionTimeoutMs,
|
|
71
|
+
connectionTimeout: config.connectionTimeoutMs,
|
|
72
|
+
tls: {rejectUnauthorized: !config.allowInsecureTLS}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Drop a pooled connection, closing it best-effort.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} userId
|
|
80
|
+
* @returns {void}
|
|
81
|
+
*/
|
|
82
|
+
function evict(userId) {
|
|
83
|
+
const entry = pool.get(userId);
|
|
84
|
+
if (!entry) return;
|
|
85
|
+
pool.delete(userId);
|
|
86
|
+
entry.client.logout().catch(() => entry.client.close());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get a live, authenticated client for a user.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} userId
|
|
93
|
+
* @param {object} connection
|
|
94
|
+
* @returns {Promise<ImapFlow>}
|
|
95
|
+
*/
|
|
96
|
+
async function acquire(userId, connection) {
|
|
97
|
+
const fp = fingerprint(connection);
|
|
98
|
+
const existing = pool.get(userId);
|
|
99
|
+
|
|
100
|
+
if (existing && existing.fingerprint === fp && existing.client.usable) {
|
|
101
|
+
existing.lastUsed = Date.now();
|
|
102
|
+
return existing.client;
|
|
103
|
+
}
|
|
104
|
+
// Stale, or the account details changed under it.
|
|
105
|
+
if (existing) evict(userId);
|
|
106
|
+
|
|
107
|
+
const inFlight = pending.get(userId);
|
|
108
|
+
if (inFlight) return inFlight;
|
|
109
|
+
|
|
110
|
+
if (pool.size >= config.maxConnections) {
|
|
111
|
+
// Evict the least recently used rather than refusing the request -
|
|
112
|
+
// the cap exists to bound resource use, not to lock people out.
|
|
113
|
+
const oldest = [...pool.entries()].sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0];
|
|
114
|
+
if (oldest) evict(oldest[0]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const connect = (async () => {
|
|
118
|
+
const client = new ImapFlow(clientOptions(connection));
|
|
119
|
+
// ImapFlow emits 'error' on an already-failed connection; without a
|
|
120
|
+
// listener that becomes an unhandled event and takes the process out.
|
|
121
|
+
client.on('error', () => evict(userId));
|
|
122
|
+
client.on('close', () => {
|
|
123
|
+
if (pool.get(userId)?.client === client) pool.delete(userId);
|
|
124
|
+
});
|
|
125
|
+
await client.connect();
|
|
126
|
+
pool.set(userId, {client, fingerprint: fp, lastUsed: Date.now()});
|
|
127
|
+
return client;
|
|
128
|
+
})();
|
|
129
|
+
|
|
130
|
+
pending.set(userId, connect);
|
|
131
|
+
try {
|
|
132
|
+
return await connect;
|
|
133
|
+
} finally {
|
|
134
|
+
pending.delete(userId);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Run a function against a user's connection, no mailbox selected.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} userId
|
|
142
|
+
* @param {object} connection
|
|
143
|
+
* @param {(client: ImapFlow) => Promise<any>} fn
|
|
144
|
+
* @returns {Promise<any>}
|
|
145
|
+
*/
|
|
146
|
+
async function withClient(userId, connection, fn) {
|
|
147
|
+
const client = await acquire(userId, connection);
|
|
148
|
+
try {
|
|
149
|
+
const result = await fn(client);
|
|
150
|
+
const entry = pool.get(userId);
|
|
151
|
+
if (entry) entry.lastUsed = Date.now();
|
|
152
|
+
return result;
|
|
153
|
+
} catch (err) {
|
|
154
|
+
evict(userId);
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Run a function with a mailbox open and locked.
|
|
161
|
+
*
|
|
162
|
+
* Read-only unless asked otherwise, and the asking is deliberate: the free
|
|
163
|
+
* reader's entire guarantee is that looking at mail never changes it, and
|
|
164
|
+
* a default of read-write would quietly break that the first time a shared
|
|
165
|
+
* helper forgot the option. Only the Pro edition's write operations pass
|
|
166
|
+
* `writable`, and only for the one call that needs it.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} userId
|
|
169
|
+
* @param {object} connection
|
|
170
|
+
* @param {string} mailbox
|
|
171
|
+
* @param {(client: ImapFlow, mailbox: object) => Promise<any>} fn
|
|
172
|
+
* @param {{writable?: boolean}} [options]
|
|
173
|
+
* @returns {Promise<any>}
|
|
174
|
+
*/
|
|
175
|
+
async function withMailbox(userId, connection, mailbox, fn, {writable = false} = {}) {
|
|
176
|
+
return withClient(userId, connection, async (client) => {
|
|
177
|
+
const lock = await client.getMailboxLock(mailbox, {readOnly: !writable});
|
|
178
|
+
try {
|
|
179
|
+
return await fn(client, client.mailbox);
|
|
180
|
+
} finally {
|
|
181
|
+
lock.release();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* One-shot connect used by the "Test connection" button.
|
|
188
|
+
*
|
|
189
|
+
* Deliberately not pooled: it runs against details that have not been
|
|
190
|
+
* saved yet, and a failed attempt should leave nothing behind.
|
|
191
|
+
*
|
|
192
|
+
* @param {object} connection
|
|
193
|
+
* @returns {Promise<{ok: true, greeting: string|null} | {ok: false, error: string}>}
|
|
194
|
+
*/
|
|
195
|
+
async function test(connection) {
|
|
196
|
+
const client = new ImapFlow(clientOptions(connection));
|
|
197
|
+
client.on('error', () => {});
|
|
198
|
+
try {
|
|
199
|
+
await client.connect();
|
|
200
|
+
const greeting = client.serverInfo?.greeting ?? null;
|
|
201
|
+
await client.logout();
|
|
202
|
+
return {ok: true, greeting};
|
|
203
|
+
} catch (err) {
|
|
204
|
+
try {
|
|
205
|
+
client.close();
|
|
206
|
+
} catch {
|
|
207
|
+
// Already down - nothing to close.
|
|
208
|
+
}
|
|
209
|
+
return {ok: false, error: describeError(err)};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Close a specific user's connection - used when their account changes or
|
|
215
|
+
* is deleted, so the next request does not reuse the old credentials.
|
|
216
|
+
*
|
|
217
|
+
* @param {string} userId
|
|
218
|
+
* @returns {void}
|
|
219
|
+
*/
|
|
220
|
+
function release(userId) {
|
|
221
|
+
evict(userId);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Close every connection a user holds, whatever mailbox it belongs to.
|
|
226
|
+
*
|
|
227
|
+
* Keys are `userId:accountId`, so this is a prefix sweep. Used when a user
|
|
228
|
+
* is deleted - their sockets should not outlive their account either.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} userId
|
|
231
|
+
* @returns {void}
|
|
232
|
+
*/
|
|
233
|
+
function releaseUser(userId) {
|
|
234
|
+
const prefix = `${userId}:`;
|
|
235
|
+
for (const key of [...pool.keys()]) {
|
|
236
|
+
if (key === userId || key.startsWith(prefix)) evict(key);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Close everything. Called on plugin disable and server shutdown.
|
|
242
|
+
*
|
|
243
|
+
* @returns {void}
|
|
244
|
+
*/
|
|
245
|
+
function closeAll() {
|
|
246
|
+
for (const userId of [...pool.keys()]) evict(userId);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const reaper = setInterval(() => {
|
|
250
|
+
const cutoff = Date.now() - config.connectionIdleMs;
|
|
251
|
+
for (const [userId, entry] of pool) {
|
|
252
|
+
if (entry.lastUsed < cutoff) evict(userId);
|
|
253
|
+
}
|
|
254
|
+
}, Math.max(30_000, Math.floor(config.connectionIdleMs / 2)));
|
|
255
|
+
// Never hold the process open on account of an idle mail connection.
|
|
256
|
+
reaper.unref?.();
|
|
257
|
+
|
|
258
|
+
return {withClient, withMailbox, test, release, releaseUser, closeAll, get size() {
|
|
259
|
+
return pool.size;
|
|
260
|
+
}};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Turn an IMAP/network error into something worth showing a user.
|
|
265
|
+
*
|
|
266
|
+
* ImapFlow's own messages are decent but its auth failures surface as raw
|
|
267
|
+
* server responses, which vary by server and read like a stack trace.
|
|
268
|
+
*
|
|
269
|
+
* @param {Error & {code?: string, authenticationFailed?: boolean}} err
|
|
270
|
+
* @returns {string}
|
|
271
|
+
*/
|
|
272
|
+
export function describeError(err) {
|
|
273
|
+
if (!err) return 'Unknown error.';
|
|
274
|
+
if (err.authenticationFailed) return 'The mail server rejected that username and password.';
|
|
275
|
+
|
|
276
|
+
switch (err.code) {
|
|
277
|
+
case 'ENOTFOUND':
|
|
278
|
+
case 'EAI_AGAIN':
|
|
279
|
+
return 'That mail server hostname could not be resolved.';
|
|
280
|
+
case 'ECONNREFUSED':
|
|
281
|
+
return 'The mail server refused the connection on that port.';
|
|
282
|
+
case 'ETIMEDOUT':
|
|
283
|
+
case 'ECONNRESET':
|
|
284
|
+
return 'The mail server did not respond in time.';
|
|
285
|
+
case 'CERT_HAS_EXPIRED':
|
|
286
|
+
case 'DEPTH_ZERO_SELF_SIGNED_CERT':
|
|
287
|
+
case 'SELF_SIGNED_CERT_IN_CHAIN':
|
|
288
|
+
case 'ERR_TLS_CERT_ALTNAME_INVALID':
|
|
289
|
+
return 'The mail server’s TLS certificate could not be verified.';
|
|
290
|
+
default:
|
|
291
|
+
return err.message || 'The mail server connection failed.';
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exporting a folder as mbox.
|
|
3
|
+
*
|
|
4
|
+
* The point of this feature is that it is not a feature of this plugin: an
|
|
5
|
+
* mbox file opens in Thunderbird, in mutt, in Apple Mail and in anything else
|
|
6
|
+
* that has ever handled mail, so a mailbox mirrored here is not a mailbox
|
|
7
|
+
* held hostage here. That matters more for a CMS plugin than for a mail
|
|
8
|
+
* client, because the plugin can be disabled.
|
|
9
|
+
*
|
|
10
|
+
* ## The "From " line, and why escaping it matters
|
|
11
|
+
*
|
|
12
|
+
* mbox separates messages with a line beginning `From `. Any line in a message
|
|
13
|
+
* body that also begins `From ` would therefore split it in two - so those
|
|
14
|
+
* lines get a `>` in front, which is the mboxrd convention. Readers undo it on
|
|
15
|
+
* the way back in. Getting this wrong does not corrupt the file visibly; it
|
|
16
|
+
* silently turns one message into two, one of which has no headers.
|
|
17
|
+
*
|
|
18
|
+
* @module _lib/mail/mbox
|
|
19
|
+
*/
|
|
20
|
+
import {bareAddress} from '../admin/mail/identity.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The separator line that introduces a message.
|
|
24
|
+
*
|
|
25
|
+
* The address is the envelope sender, and the date format is asctime - not
|
|
26
|
+
* RFC 5322, and not ISO. This line is not a header; it predates all of them,
|
|
27
|
+
* and readers parse it by shape.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} address
|
|
30
|
+
* @param {Date} date
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function fromLine(address, date = new Date()) {
|
|
34
|
+
// Through `bareAddress`, not by stripping the punctuation: removing every
|
|
35
|
+
// space and angle bracket from `Sales <s@e.com>` gives `Saless@e.com`,
|
|
36
|
+
// which is a separator line naming an address that does not exist. The
|
|
37
|
+
// remaining strip is a backstop for anything `bareAddress` leaves that
|
|
38
|
+
// would still break the line.
|
|
39
|
+
const clean = bareAddress(address).replace(/\s+/g, '') || 'MAILER-DAEMON';
|
|
40
|
+
const when = Number.isNaN(date?.getTime?.()) ? new Date() : date;
|
|
41
|
+
// asctime, in C locale, always: "Sun Sep 20 14:30:00 2026".
|
|
42
|
+
const asctime = when.toUTCString()
|
|
43
|
+
.replace(/^(\w{3}), (\d{2}) (\w{3}) (\d{4}) (\d{2}:\d{2}:\d{2}) GMT$/, '$1 $3 $2 $5 $4');
|
|
44
|
+
return `From ${clean} ${asctime}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Escape the body lines that would otherwise look like a separator.
|
|
49
|
+
*
|
|
50
|
+
* mboxrd: `From ` becomes `>From `, and an already-escaped `>From ` becomes
|
|
51
|
+
* `>>From `, so the transformation can be undone exactly.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} text
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
export function escapeFromLines(text) {
|
|
57
|
+
return String(text ?? '').replace(/^(>*From )/gm, '>$1');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One message, as it appears in an mbox file.
|
|
62
|
+
*
|
|
63
|
+
* @param {Buffer|string} source - the raw RFC822 message
|
|
64
|
+
* @param {{address?: string, date?: Date}} [envelope]
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
export function mboxEntry(source, {address = '', date = new Date()} = {}) {
|
|
68
|
+
const text = Buffer.isBuffer(source) ? source.toString('utf8') : String(source ?? '');
|
|
69
|
+
// Normalised to LF. mbox is a line-oriented text format and mixing CRLF
|
|
70
|
+
// bodies into it is what makes some readers show a blank line between
|
|
71
|
+
// every line of every message.
|
|
72
|
+
const body = escapeFromLines(text.replace(/\r\n/g, '\n'));
|
|
73
|
+
return `${fromLine(address, date)}\n${body}${body.endsWith('\n') ? '' : '\n'}\n`;
|
|
74
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deciding which mailboxes are due a poll.
|
|
3
|
+
*
|
|
4
|
+
* Pure, and separate from the timer that acts on it, because the interesting
|
|
5
|
+
* behaviour here is not "call sync every N minutes" - it is what happens when
|
|
6
|
+
* a mail server is down, or slow, or rate-limiting, and the obvious
|
|
7
|
+
* implementation keeps hammering it every N minutes forever.
|
|
8
|
+
*
|
|
9
|
+
* @module _lib/mail/pollSchedule
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Multiplier applied per consecutive failure. */
|
|
13
|
+
const BACKOFF_FACTOR = 2;
|
|
14
|
+
|
|
15
|
+
/** However bad things get, try again at least this often. */
|
|
16
|
+
export const MAX_BACKOFF_MS = 3_600_000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How long to wait before the next attempt for one mailbox.
|
|
20
|
+
*
|
|
21
|
+
* Doubles per consecutive failure and is capped, so a mailbox whose server is
|
|
22
|
+
* down backs off towards hourly instead of knocking every few minutes for a
|
|
23
|
+
* week - which is how an IP ends up rate-limited or blocked.
|
|
24
|
+
*
|
|
25
|
+
* @param {number} intervalMs - the mailbox's configured interval
|
|
26
|
+
* @param {number} failures - consecutive failures so far
|
|
27
|
+
* @returns {number}
|
|
28
|
+
*/
|
|
29
|
+
export function backoffFor(intervalMs, failures) {
|
|
30
|
+
if (!failures) return intervalMs;
|
|
31
|
+
const grown = intervalMs * Math.pow(BACKOFF_FACTOR, Math.min(failures, 10));
|
|
32
|
+
return Math.min(grown, MAX_BACKOFF_MS);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Which mailboxes should be polled now.
|
|
37
|
+
*
|
|
38
|
+
* A mailbox never polled before is due immediately: the alternative is that
|
|
39
|
+
* adding one shows an empty list until the first interval elapses, which
|
|
40
|
+
* reads as broken.
|
|
41
|
+
*
|
|
42
|
+
* @param {object[]} accounts
|
|
43
|
+
* @param {Map<string, {lastAttemptAt?: number, failures?: number, running?: boolean}>} state
|
|
44
|
+
* @param {{now: number, defaultMinutes: number}} options
|
|
45
|
+
* @returns {{id: string, waitedMs: number|null}[]} due mailboxes, most overdue first
|
|
46
|
+
*/
|
|
47
|
+
export function selectDuePolls(accounts, state, {now, defaultMinutes}) {
|
|
48
|
+
const due = [];
|
|
49
|
+
|
|
50
|
+
for (const account of accounts) {
|
|
51
|
+
const entry = state.get(account.id) ?? {};
|
|
52
|
+
|
|
53
|
+
// A pass already running must never be started again: two syncs of one
|
|
54
|
+
// folder race on the same UID range and the later write wins twice.
|
|
55
|
+
if (entry.running) continue;
|
|
56
|
+
|
|
57
|
+
const minutes = Number.isFinite(account.pollIntervalMinutes) && account.pollIntervalMinutes > 0
|
|
58
|
+
? account.pollIntervalMinutes
|
|
59
|
+
: defaultMinutes;
|
|
60
|
+
const intervalMs = minutes * 60_000;
|
|
61
|
+
|
|
62
|
+
if (!entry.lastAttemptAt) {
|
|
63
|
+
due.push({id: account.id, waitedMs: null});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const wait = backoffFor(intervalMs, entry.failures ?? 0);
|
|
68
|
+
const waited = now - entry.lastAttemptAt;
|
|
69
|
+
if (waited >= wait) due.push({id: account.id, waitedMs: waited});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Never-polled mailboxes first, then whoever has waited longest - so a
|
|
73
|
+
// backlog drains fairly rather than always favouring the same mailbox.
|
|
74
|
+
return due.sort((a, b) => {
|
|
75
|
+
if (a.waitedMs === null) return b.waitedMs === null ? 0 : -1;
|
|
76
|
+
if (b.waitedMs === null) return 1;
|
|
77
|
+
return b.waitedMs - a.waitedMs;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The background poller.
|
|
3
|
+
*
|
|
4
|
+
* One ticker for the whole plugin rather than a timer per mailbox: mailboxes
|
|
5
|
+
* come and go while the process runs, and a per-mailbox timer means tracking
|
|
6
|
+
* and cancelling each one. A single tick asks `selectDuePolls()` who is due
|
|
7
|
+
* and works through them.
|
|
8
|
+
*
|
|
9
|
+
* The scheduling decision - including the backoff that stops a dead mail
|
|
10
|
+
* server being knocked every few minutes for a week - lives in
|
|
11
|
+
* `pollSchedule.js`, pure and tested there.
|
|
12
|
+
*
|
|
13
|
+
* @module _lib/mail/poller
|
|
14
|
+
*/
|
|
15
|
+
import {selectDuePolls} from './pollSchedule.js';
|
|
16
|
+
|
|
17
|
+
/** How often the ticker looks for work. Finer than any poll interval. */
|
|
18
|
+
const TICK_MS = 30_000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create a poller.
|
|
22
|
+
*
|
|
23
|
+
* @param {{listAccounts: Function, syncAccount: Function, defaultMinutes: number,
|
|
24
|
+
* logger: object, tickMs?: number}} deps
|
|
25
|
+
* @returns {{start: Function, stop: Function, state: Function, pollNow: Function}}
|
|
26
|
+
*/
|
|
27
|
+
export function createPoller({listAccounts, syncAccount, defaultMinutes, logger, tickMs = TICK_MS}) {
|
|
28
|
+
/** @type {Map<string, {lastAttemptAt?: number, failures?: number, running?: boolean, lastError?: string|null}>} */
|
|
29
|
+
const state = new Map();
|
|
30
|
+
let timer = null;
|
|
31
|
+
let stopped = false;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Sync one mailbox, recording whether it worked.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} account
|
|
37
|
+
* @returns {Promise<void>}
|
|
38
|
+
*/
|
|
39
|
+
async function runOne(account) {
|
|
40
|
+
const entry = state.get(account.id) ?? {};
|
|
41
|
+
state.set(account.id, {...entry, running: true, lastAttemptAt: Date.now()});
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const result = await syncAccount(account);
|
|
45
|
+
state.set(account.id, {
|
|
46
|
+
lastAttemptAt: Date.now(), failures: 0, running: false, lastError: null,
|
|
47
|
+
lastResult: result
|
|
48
|
+
});
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const failures = (entry.failures ?? 0) + 1;
|
|
51
|
+
state.set(account.id, {
|
|
52
|
+
lastAttemptAt: Date.now(), failures, running: false, lastError: err.message
|
|
53
|
+
});
|
|
54
|
+
// Warn, not error: a mail server being unreachable is an ordinary
|
|
55
|
+
// condition, and the backoff already handles it.
|
|
56
|
+
logger.warn(
|
|
57
|
+
`[mail] poll failed for mailbox ${account.id} (${failures} in a row): ${err.message}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One pass: find who is due and sync them, one at a time.
|
|
64
|
+
*
|
|
65
|
+
* Sequential on purpose. Polling every mailbox at once means opening every
|
|
66
|
+
* connection at once, which is exactly the burst a mail server throttles.
|
|
67
|
+
*
|
|
68
|
+
* @returns {Promise<void>}
|
|
69
|
+
*/
|
|
70
|
+
async function tick() {
|
|
71
|
+
if (stopped) return;
|
|
72
|
+
|
|
73
|
+
let accounts;
|
|
74
|
+
try {
|
|
75
|
+
accounts = await listAccounts();
|
|
76
|
+
} catch (err) {
|
|
77
|
+
logger.warn(`[mail] could not list mailboxes to poll: ${err.message}`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Forget state for mailboxes that have been removed, so the map does
|
|
82
|
+
// not grow for the life of the process.
|
|
83
|
+
const live = new Set(accounts.map(a => a.id));
|
|
84
|
+
for (const id of [...state.keys()]) {
|
|
85
|
+
if (!live.has(id)) state.delete(id);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const due = selectDuePolls(accounts, state, {now: Date.now(), defaultMinutes});
|
|
89
|
+
for (const {id} of due) {
|
|
90
|
+
if (stopped) return;
|
|
91
|
+
const account = accounts.find(a => a.id === id);
|
|
92
|
+
if (account) await runOne(account);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
/**
|
|
98
|
+
* @returns {void}
|
|
99
|
+
*/
|
|
100
|
+
start() {
|
|
101
|
+
if (timer) return;
|
|
102
|
+
stopped = false;
|
|
103
|
+
timer = setInterval(() => {
|
|
104
|
+
tick().catch(err => logger.warn(`[mail] poll tick failed: ${err.message}`));
|
|
105
|
+
}, tickMs);
|
|
106
|
+
// Never hold the process open for a poll.
|
|
107
|
+
timer.unref?.();
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @returns {void}
|
|
112
|
+
*/
|
|
113
|
+
stop() {
|
|
114
|
+
stopped = true;
|
|
115
|
+
if (timer) clearInterval(timer);
|
|
116
|
+
timer = null;
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Run a pass immediately, for a manual "sync now".
|
|
121
|
+
*
|
|
122
|
+
* @returns {Promise<void>}
|
|
123
|
+
*/
|
|
124
|
+
pollNow: tick,
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* What the poller currently believes about each mailbox.
|
|
128
|
+
*
|
|
129
|
+
* @returns {object}
|
|
130
|
+
*/
|
|
131
|
+
state() {
|
|
132
|
+
return Object.fromEntries(state);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|