emailengine-app 2.68.0 → 2.69.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.
Files changed (74) hide show
  1. package/.github/codeql/codeql-config.yml +16 -0
  2. package/.github/workflows/codeql.yml +102 -0
  3. package/.github/workflows/deploy.yml +8 -0
  4. package/.github/workflows/release.yaml +4 -0
  5. package/.github/workflows/test.yml +3 -0
  6. package/CHANGELOG.md +49 -0
  7. package/SECURITY.md +80 -0
  8. package/SECURITY.txt +27 -0
  9. package/config/default.toml +2 -0
  10. package/data/google-crawlers.json +13 -1
  11. package/lib/account.js +62 -25
  12. package/lib/api-routes/account-routes.js +493 -75
  13. package/lib/api-routes/blocklist-routes.js +337 -0
  14. package/lib/api-routes/delivery-test-routes.js +321 -0
  15. package/lib/api-routes/export-routes.js +1 -12
  16. package/lib/api-routes/gateway-routes.js +376 -0
  17. package/lib/api-routes/license-routes.js +142 -0
  18. package/lib/api-routes/mailbox-routes.js +318 -0
  19. package/lib/api-routes/message-routes.js +21 -129
  20. package/lib/api-routes/oauth2-app-routes.js +631 -0
  21. package/lib/api-routes/outbox-routes.js +173 -0
  22. package/lib/api-routes/pubsub-routes.js +98 -0
  23. package/lib/api-routes/route-helpers.js +45 -0
  24. package/lib/api-routes/settings-routes.js +331 -0
  25. package/lib/api-routes/stats-routes.js +77 -0
  26. package/lib/api-routes/submit-routes.js +472 -0
  27. package/lib/api-routes/template-routes.js +7 -55
  28. package/lib/api-routes/token-routes.js +297 -0
  29. package/lib/api-routes/webhook-route-routes.js +152 -0
  30. package/lib/email-client/gmail-client.js +14 -0
  31. package/lib/email-client/imap/mailbox.js +34 -11
  32. package/lib/email-client/imap/subconnection.js +20 -12
  33. package/lib/email-client/imap/sync-operations.js +130 -2
  34. package/lib/email-client/imap-client.js +116 -58
  35. package/lib/email-client/outlook-client.js +85 -13
  36. package/lib/export.js +60 -19
  37. package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
  38. package/lib/imapproxy/imap-core/lib/imap-command.js +7 -2
  39. package/lib/imapproxy/imap-core/lib/imap-connection.js +113 -23
  40. package/lib/imapproxy/imap-core/lib/imap-server.js +25 -1
  41. package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
  42. package/lib/imapproxy/imap-server.js +92 -29
  43. package/lib/message-port-stream.js +113 -16
  44. package/lib/reject-worker-calls.js +42 -0
  45. package/lib/routes-ui.js +37 -8778
  46. package/lib/schemas.js +26 -1
  47. package/lib/tools.js +73 -0
  48. package/lib/ui-routes/account-routes.js +40 -210
  49. package/lib/ui-routes/admin-config-routes.js +913 -487
  50. package/lib/ui-routes/admin-entities-routes.js +1 -0
  51. package/lib/ui-routes/auth-routes.js +1339 -0
  52. package/lib/ui-routes/dashboard-routes.js +188 -0
  53. package/lib/ui-routes/document-store-routes.js +800 -0
  54. package/lib/ui-routes/export-routes.js +217 -0
  55. package/lib/ui-routes/internals-routes.js +354 -0
  56. package/lib/ui-routes/network-config-routes.js +759 -0
  57. package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +371 -91
  58. package/lib/ui-routes/route-helpers.js +316 -0
  59. package/lib/ui-routes/smtp-test-routes.js +236 -0
  60. package/lib/ui-routes/unsubscribe-routes.js +234 -0
  61. package/lib/webhook-request.js +36 -0
  62. package/package.json +17 -17
  63. package/sbom.json +1 -1
  64. package/server.js +217 -19
  65. package/static/licenses.html +52 -182
  66. package/translations/messages.pot +131 -151
  67. package/views/dashboard.hbs +7 -26
  68. package/views/internals/index.hbs +15 -0
  69. package/views/tokens/index.hbs +9 -0
  70. package/workers/api.js +198 -4401
  71. package/workers/export.js +87 -54
  72. package/workers/imap.js +29 -13
  73. package/workers/submit.js +20 -11
  74. package/workers/webhooks.js +6 -20
@@ -0,0 +1,217 @@
1
+ 'use strict';
2
+
3
+ // Admin UI routes for account data exports (the Exports tab on an account page): list,
4
+ // status, create, delete, and download. Extracted verbatim from lib/routes-ui.js. These
5
+ // are session-authenticated JSON/file endpoints backed by the Export class.
6
+
7
+ const Joi = require('joi');
8
+ const Boom = require('@hapi/boom');
9
+ const fs = require('fs');
10
+
11
+ const { Export } = require('../export');
12
+ const getSecret = require('../get-secret');
13
+ const { failAction } = require('../tools');
14
+ const { exportIdSchema } = require('../schemas');
15
+ const { throwAsBoom } = require('./route-helpers');
16
+
17
+ function init(args) {
18
+ const { server } = args;
19
+
20
+ // List exports for account
21
+ server.route({
22
+ method: 'GET',
23
+ path: '/admin/accounts/{account}/exports',
24
+ async handler(request) {
25
+ try {
26
+ return await Export.list(request.params.account, {
27
+ page: request.query.page,
28
+ pageSize: request.query.pageSize
29
+ });
30
+ } catch (err) {
31
+ request.logger.error({ msg: 'Failed to list exports', err, account: request.params.account });
32
+ throwAsBoom(err);
33
+ }
34
+ },
35
+ options: {
36
+ validate: {
37
+ options: {
38
+ stripUnknown: true,
39
+ abortEarly: false,
40
+ convert: true
41
+ },
42
+ failAction,
43
+ params: Joi.object({
44
+ account: Joi.string().max(256).required()
45
+ }),
46
+ query: Joi.object({
47
+ page: Joi.number().integer().min(0).default(0),
48
+ pageSize: Joi.number().integer().min(1).max(100).default(20)
49
+ })
50
+ }
51
+ }
52
+ });
53
+
54
+ // Get export status
55
+ server.route({
56
+ method: 'GET',
57
+ path: '/admin/accounts/{account}/export/{exportId}',
58
+ async handler(request) {
59
+ try {
60
+ const result = await Export.get(request.params.account, request.params.exportId);
61
+ if (!result) {
62
+ throw Boom.notFound('Export not found');
63
+ }
64
+ return result;
65
+ } catch (err) {
66
+ request.logger.error({ msg: 'Failed to get export', err, account: request.params.account, exportId: request.params.exportId });
67
+ throwAsBoom(err);
68
+ }
69
+ },
70
+ options: {
71
+ validate: {
72
+ options: {
73
+ stripUnknown: true,
74
+ abortEarly: false,
75
+ convert: true
76
+ },
77
+ failAction,
78
+ params: Joi.object({
79
+ account: Joi.string().max(256).required(),
80
+ exportId: exportIdSchema
81
+ })
82
+ }
83
+ }
84
+ });
85
+
86
+ // Create export
87
+ server.route({
88
+ method: 'POST',
89
+ path: '/admin/accounts/{account}/export',
90
+ async handler(request) {
91
+ try {
92
+ return await Export.create(request.params.account, {
93
+ startDate: request.payload.startDate,
94
+ endDate: request.payload.endDate,
95
+ includeAttachments: request.payload.includeAttachments,
96
+ folders: []
97
+ });
98
+ } catch (err) {
99
+ request.logger.error({ msg: 'Failed to create export', err, account: request.params.account });
100
+ throwAsBoom(err);
101
+ }
102
+ },
103
+ options: {
104
+ validate: {
105
+ options: {
106
+ stripUnknown: true,
107
+ abortEarly: false,
108
+ convert: true
109
+ },
110
+ failAction,
111
+ params: Joi.object({
112
+ account: Joi.string().max(256).required()
113
+ }),
114
+ payload: Joi.object({
115
+ startDate: Joi.date().iso().required(),
116
+ endDate: Joi.date().iso().required(),
117
+ includeAttachments: Joi.boolean().default(false)
118
+ })
119
+ }
120
+ }
121
+ });
122
+
123
+ // Delete export
124
+ server.route({
125
+ method: 'DELETE',
126
+ path: '/admin/accounts/{account}/export/{exportId}',
127
+ async handler(request) {
128
+ try {
129
+ const deleted = await Export.delete(request.params.account, request.params.exportId);
130
+ if (!deleted) {
131
+ throw Boom.notFound('Export not found');
132
+ }
133
+ return { deleted: true };
134
+ } catch (err) {
135
+ request.logger.error({ msg: 'Failed to delete export', err, account: request.params.account, exportId: request.params.exportId });
136
+ throwAsBoom(err);
137
+ }
138
+ },
139
+ options: {
140
+ validate: {
141
+ options: {
142
+ stripUnknown: true,
143
+ abortEarly: false,
144
+ convert: true
145
+ },
146
+ failAction,
147
+ params: Joi.object({
148
+ account: Joi.string().max(256).required(),
149
+ exportId: exportIdSchema
150
+ })
151
+ }
152
+ }
153
+ });
154
+
155
+ // Download export file
156
+ server.route({
157
+ method: 'GET',
158
+ path: '/admin/accounts/{account}/export/{exportId}/download',
159
+ async handler(request, h) {
160
+ try {
161
+ const { account, exportId } = request.params;
162
+ const fileInfo = await Export.getFile(account, exportId);
163
+ if (!fileInfo) {
164
+ throw Boom.notFound('Export not found');
165
+ }
166
+
167
+ const fileReadStream = fs.createReadStream(fileInfo.filePath);
168
+ let stream = fileReadStream;
169
+
170
+ stream.on('error', err => {
171
+ request.logger.error({ msg: 'Export download stream error', exportId, err });
172
+ });
173
+
174
+ // Decrypt file if encrypted
175
+ if (fileInfo.isEncrypted) {
176
+ const secret = await getSecret();
177
+ if (!secret) {
178
+ fileReadStream.destroy();
179
+ throw Boom.serverUnavailable('Encryption secret not available for decryption');
180
+ }
181
+ const { createDecryptStream } = require('../stream-encrypt');
182
+ const decryptStream = await createDecryptStream(secret);
183
+ decryptStream.on('error', err => {
184
+ request.logger.error({ msg: 'Export decryption error', exportId, err });
185
+ fileReadStream.destroy();
186
+ });
187
+ stream = fileReadStream.pipe(decryptStream);
188
+ }
189
+
190
+ return h
191
+ .response(stream)
192
+ .type('application/gzip')
193
+ .header('Content-Disposition', `attachment; filename="${fileInfo.filename}"`)
194
+ .header('Content-Encoding', 'identity');
195
+ } catch (err) {
196
+ request.logger.error({ msg: 'Failed to download export', err, account: request.params.account, exportId: request.params.exportId });
197
+ throwAsBoom(err);
198
+ }
199
+ },
200
+ options: {
201
+ validate: {
202
+ options: {
203
+ stripUnknown: true,
204
+ abortEarly: false,
205
+ convert: true
206
+ },
207
+ failAction,
208
+ params: Joi.object({
209
+ account: Joi.string().max(256).required(),
210
+ exportId: exportIdSchema
211
+ })
212
+ }
213
+ }
214
+ });
215
+ }
216
+
217
+ module.exports = init;
@@ -0,0 +1,354 @@
1
+ 'use strict';
2
+
3
+ // Admin UI routes for the system internals / threads tools (/admin/internals*): the
4
+ // worker-thread overview, kill/snapshot actions, and the per-thread account listing.
5
+ // Extracted verbatim from lib/routes-ui.js.
6
+
7
+ const Joi = require('joi');
8
+ const Boom = require('@hapi/boom');
9
+
10
+ const settings = require('../settings');
11
+ const { Account } = require('../account');
12
+ const { redis } = require('../db');
13
+ const { DEFAULT_PAGE_SIZE } = require('../consts');
14
+ const { formatAccountData } = require('./route-helpers');
15
+
16
+ function init(args) {
17
+ const { server, call } = args;
18
+
19
+ server.route({
20
+ method: 'GET',
21
+ path: '/admin/internals',
22
+ async handler(request, h) {
23
+ let threads = await call({ cmd: 'threads' });
24
+
25
+ // Surface a warning when more API workers were requested than could be started
26
+ let apiWorkerScaling;
27
+ try {
28
+ apiWorkerScaling = await call({ cmd: 'apiWorkerScaling' });
29
+ } catch (err) {
30
+ apiWorkerScaling = false;
31
+ }
32
+
33
+ let defaultLocale = (await settings.get('locale')) || 'en';
34
+
35
+ let bytesFormatter;
36
+
37
+ let bytesFormatterOpts = {
38
+ style: 'unit',
39
+ unit: 'byte',
40
+ notation: 'compact',
41
+ unitDisplay: 'narrow'
42
+ };
43
+
44
+ try {
45
+ bytesFormatter = new Intl.NumberFormat(defaultLocale, bytesFormatterOpts);
46
+ } catch (err) {
47
+ bytesFormatter = new Intl.NumberFormat('en-US', bytesFormatterOpts);
48
+ }
49
+
50
+ return h.view(
51
+ 'internals/index',
52
+ {
53
+ pageTitle: 'System Threads',
54
+ menuToolsInternals: true,
55
+ menuTools: true,
56
+
57
+ apiWorkerWarning: apiWorkerScaling && apiWorkerScaling.fallback ? apiWorkerScaling : false,
58
+
59
+ threads: threads.map(threadInfo => {
60
+ // Check if this worker is unresponsive
61
+ if (threadInfo.resourceUsageError) {
62
+ threadInfo.isUnresponsive = true;
63
+ threadInfo.heapUsed = threadInfo.resourceUsageError.unresponsive ? 'UNRESPONSIVE' : 'ERROR';
64
+ threadInfo.errorMessage = threadInfo.resourceUsageError.error;
65
+ }
66
+
67
+ // CPU metrics removed to prevent potential native code issues
68
+
69
+ // Process health status
70
+ if (threadInfo.healthStatus) {
71
+ switch (threadInfo.healthStatus) {
72
+ case 'unhealthy':
73
+ threadInfo.healthBadge = 'Unhealthy';
74
+ threadInfo.healthBadgeType = 'warning';
75
+ break;
76
+ case 'critical':
77
+ case 'restarting':
78
+ threadInfo.healthBadge = 'Critical';
79
+ threadInfo.healthBadgeType = 'danger';
80
+ break;
81
+ case 'unknown':
82
+ threadInfo.healthBadge = 'Unknown';
83
+ threadInfo.healthBadgeType = 'secondary';
84
+ break;
85
+ // healthy - no badge shown to avoid clutter
86
+ }
87
+ }
88
+
89
+ for (let key of Object.keys(threadInfo)) {
90
+ switch (key) {
91
+ case 'online':
92
+ threadInfo.timeStr = new Date(threadInfo.online).toISOString();
93
+ break;
94
+
95
+ /*
96
+ // managed by the template helper
97
+ case 'messages':
98
+ case 'called':
99
+ case 'accounts':
100
+ case 'threadId':
101
+ threadInfo[key] = threadInfo[key];
102
+ break;
103
+ */
104
+
105
+ case 'heapUsed':
106
+ if (!threadInfo.isUnresponsive) {
107
+ threadInfo.heapUsed = bytesFormatter.format(threadInfo[key]).replace(/BB$/, 'GB');
108
+ }
109
+ break;
110
+
111
+ case 'heapTotal':
112
+ // Not displayed anymore to avoid confusion with heap limit
113
+ break;
114
+
115
+ // Handle other memory metrics from new format
116
+ case 'rss':
117
+ case 'external':
118
+ case 'arrayBuffers':
119
+ // These are available but not displayed in the current UI
120
+ break;
121
+ }
122
+ }
123
+
124
+ return threadInfo;
125
+ })
126
+ },
127
+ {
128
+ layout: 'app'
129
+ }
130
+ );
131
+ }
132
+ });
133
+
134
+ server.route({
135
+ method: 'POST',
136
+ path: '/admin/internals/kill',
137
+ async handler(request, h) {
138
+ try {
139
+ let killed = await call({ cmd: 'kill-thread', thread: request.payload.thread });
140
+ if (killed) {
141
+ await request.flash({ type: 'info', message: `Worker stopped` });
142
+ }
143
+
144
+ return h.redirect('/admin/internals');
145
+ } catch (err) {
146
+ await request.flash({ type: 'danger', message: `Couldn't stop worker. Try again.` });
147
+ request.logger.error({ msg: 'Failed to kill thread', err, thread: request.payload.thread, remoteAddress: request.app.ip });
148
+ return h.redirect('/admin/internals');
149
+ }
150
+ },
151
+ options: {
152
+ validate: {
153
+ options: {
154
+ stripUnknown: true,
155
+ abortEarly: false,
156
+ convert: true
157
+ },
158
+
159
+ async failAction(request, h, err) {
160
+ await request.flash({ type: 'danger', message: `Couldn't stop worker. Try again.` });
161
+ request.logger.error({ msg: 'Failed to kill thread', err });
162
+
163
+ return h.redirect('/admin/internals').takeover();
164
+ },
165
+
166
+ payload: Joi.object({
167
+ thread: Joi.number().integer().min(1).max(1000000).required().example(1).description('Thread ID')
168
+ })
169
+ }
170
+ }
171
+ });
172
+
173
+ server.route({
174
+ method: 'POST',
175
+ path: '/admin/internals/snapshot',
176
+ async handler(request, h) {
177
+ try {
178
+ let snapshot = await call({ cmd: 'snapshot-thread', thread: request.payload.thread, timeout: 10 * 60 * 1000 });
179
+ if (!snapshot) {
180
+ let error = Boom.boomify(new Error('Snapshot was not found'), { statusCode: 404 });
181
+ throw error;
182
+ }
183
+
184
+ return h
185
+ .response(Buffer.from(snapshot))
186
+ .header('Content-Type', 'application/octet-stream')
187
+ .header(
188
+ 'Content-Disposition',
189
+ `attachment; filename=Heap-${new Date()
190
+ .toISOString()
191
+ .substring(0, 19)
192
+ .replace(/[^0-9T]+/g, '')}.heapsnapshot`
193
+ )
194
+ .header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0, post-check=0, pre-check=0')
195
+ .header('Pragma', 'no-cache')
196
+ .code(200);
197
+ } catch (err) {
198
+ await request.flash({ type: 'danger', message: `Couldn't create snapshot. Try again.` });
199
+ request.logger.error({ msg: 'Failed to generate snapshot', err, thread: request.payload.thread, remoteAddress: request.app.ip });
200
+ return h.redirect('/admin/internals');
201
+ }
202
+ },
203
+ options: {
204
+ validate: {
205
+ options: {
206
+ stripUnknown: true,
207
+ abortEarly: false,
208
+ convert: true
209
+ },
210
+
211
+ async failAction(request, h, err) {
212
+ await request.flash({ type: 'danger', message: `Couldn't create snapshot. Try again.` });
213
+ request.logger.error({ msg: 'Failed to generate snapshot', err });
214
+
215
+ return h.redirect('/admin/internals').takeover();
216
+ },
217
+
218
+ payload: Joi.object({
219
+ thread: Joi.number().integer().empty('').min(0).max(1000000).required().example(1).description('Thread ID')
220
+ })
221
+ }
222
+ }
223
+ });
224
+
225
+ server.route({
226
+ method: 'GET',
227
+ path: '/admin/internals/thread/{threadId}',
228
+ async handler(request, h) {
229
+ const threadId = request.params.threadId;
230
+
231
+ // Get thread info to verify this is a valid email worker
232
+ const threads = await call({ cmd: 'threads' });
233
+ const threadInfo = threads.find(t => t.threadId === threadId);
234
+
235
+ if (!threadInfo) {
236
+ await request.flash({ type: 'danger', message: `Worker not found` });
237
+ return h.redirect('/admin/internals');
238
+ }
239
+
240
+ if (threadInfo.type !== 'imap') {
241
+ await request.flash({ type: 'warning', message: `Only email workers have assigned accounts` });
242
+ return h.redirect('/admin/internals');
243
+ }
244
+
245
+ // Get accounts assigned to this worker
246
+ const result = await call({
247
+ cmd: 'worker-accounts',
248
+ threadId,
249
+ page: request.query.page,
250
+ pageSize: request.query.pageSize
251
+ });
252
+
253
+ const runIndex = await call({ cmd: 'runIndex' });
254
+
255
+ // Load account data for each account
256
+ const accountsWithData = [];
257
+ for (const accountId of result.accounts) {
258
+ const accountObject = new Account({ redis, account: accountId });
259
+ const accountData = await accountObject.loadAccountData(null, null, runIndex);
260
+ if (accountData) {
261
+ accountsWithData.push(formatAccountData(accountData, request.app.gt));
262
+ } else {
263
+ // Account exists in assignment but data couldn't be loaded
264
+ accountsWithData.push({
265
+ account: accountId,
266
+ name: accountId,
267
+ email: '',
268
+ type: { name: 'Unknown' },
269
+ stateLabel: { type: 'secondary', name: 'Unknown' }
270
+ });
271
+ }
272
+ }
273
+
274
+ // Build pagination
275
+ let nextPage = false;
276
+ let prevPage = false;
277
+
278
+ const getPagingUrl = page => {
279
+ const url = new URL(`admin/internals/thread/${threadId}`, 'http://localhost');
280
+ if (page) {
281
+ url.searchParams.append('page', page);
282
+ }
283
+ if (request.query.pageSize && request.query.pageSize !== DEFAULT_PAGE_SIZE) {
284
+ url.searchParams.append('pageSize', request.query.pageSize);
285
+ }
286
+ return url.pathname + url.search;
287
+ };
288
+
289
+ if (result.pages > result.page) {
290
+ nextPage = getPagingUrl(result.page + 1);
291
+ }
292
+
293
+ if (result.page > 1) {
294
+ prevPage = getPagingUrl(result.page - 1);
295
+ }
296
+
297
+ return h.view(
298
+ 'internals/thread',
299
+ {
300
+ pageTitle: `Thread ${threadId} Accounts`,
301
+ menuToolsInternals: true,
302
+ menuTools: true,
303
+
304
+ threadId,
305
+ threadInfo: {
306
+ type: threadInfo.type,
307
+ description: threadInfo.description,
308
+ accounts: threadInfo.accounts
309
+ },
310
+
311
+ accounts: accountsWithData,
312
+ total: result.total,
313
+
314
+ showPaging: result.pages > 1,
315
+ nextPage,
316
+ prevPage,
317
+ pageLinks: new Array(result.pages).fill(0).map((z, i) => ({
318
+ url: getPagingUrl(i + 1),
319
+ title: i + 1,
320
+ active: i + 1 === result.page
321
+ }))
322
+ },
323
+ {
324
+ layout: 'app'
325
+ }
326
+ );
327
+ },
328
+ options: {
329
+ validate: {
330
+ options: {
331
+ stripUnknown: true,
332
+ abortEarly: false,
333
+ convert: true
334
+ },
335
+
336
+ async failAction(request, h, err) {
337
+ request.logger.error({ msg: 'Failed to load thread accounts', err });
338
+ return h.redirect('/admin/internals').takeover();
339
+ },
340
+
341
+ params: Joi.object({
342
+ threadId: Joi.number().integer().min(0).max(1000000).required().description('Thread ID')
343
+ }),
344
+
345
+ query: Joi.object({
346
+ page: Joi.number().integer().min(1).max(1000000).default(1),
347
+ pageSize: Joi.number().integer().min(1).max(250).default(DEFAULT_PAGE_SIZE)
348
+ })
349
+ }
350
+ }
351
+ });
352
+ }
353
+
354
+ module.exports = init;