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.
- package/.github/codeql/codeql-config.yml +16 -0
- package/.github/workflows/codeql.yml +102 -0
- package/.github/workflows/deploy.yml +8 -0
- package/.github/workflows/release.yaml +4 -0
- package/.github/workflows/test.yml +3 -0
- package/CHANGELOG.md +49 -0
- package/SECURITY.md +80 -0
- package/SECURITY.txt +27 -0
- package/config/default.toml +2 -0
- package/data/google-crawlers.json +13 -1
- package/lib/account.js +62 -25
- package/lib/api-routes/account-routes.js +493 -75
- package/lib/api-routes/blocklist-routes.js +337 -0
- package/lib/api-routes/delivery-test-routes.js +321 -0
- package/lib/api-routes/export-routes.js +1 -12
- package/lib/api-routes/gateway-routes.js +376 -0
- package/lib/api-routes/license-routes.js +142 -0
- package/lib/api-routes/mailbox-routes.js +318 -0
- package/lib/api-routes/message-routes.js +21 -129
- package/lib/api-routes/oauth2-app-routes.js +631 -0
- package/lib/api-routes/outbox-routes.js +173 -0
- package/lib/api-routes/pubsub-routes.js +98 -0
- package/lib/api-routes/route-helpers.js +45 -0
- package/lib/api-routes/settings-routes.js +331 -0
- package/lib/api-routes/stats-routes.js +77 -0
- package/lib/api-routes/submit-routes.js +472 -0
- package/lib/api-routes/template-routes.js +7 -55
- package/lib/api-routes/token-routes.js +297 -0
- package/lib/api-routes/webhook-route-routes.js +152 -0
- package/lib/email-client/gmail-client.js +14 -0
- package/lib/email-client/imap/mailbox.js +34 -11
- package/lib/email-client/imap/subconnection.js +20 -12
- package/lib/email-client/imap/sync-operations.js +130 -2
- package/lib/email-client/imap-client.js +116 -58
- package/lib/email-client/outlook-client.js +85 -13
- package/lib/export.js +60 -19
- package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
- package/lib/imapproxy/imap-core/lib/imap-command.js +7 -2
- package/lib/imapproxy/imap-core/lib/imap-connection.js +113 -23
- package/lib/imapproxy/imap-core/lib/imap-server.js +25 -1
- package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
- package/lib/imapproxy/imap-server.js +92 -29
- package/lib/message-port-stream.js +113 -16
- package/lib/reject-worker-calls.js +42 -0
- package/lib/routes-ui.js +37 -8778
- package/lib/schemas.js +26 -1
- package/lib/tools.js +73 -0
- package/lib/ui-routes/account-routes.js +40 -210
- package/lib/ui-routes/admin-config-routes.js +913 -487
- package/lib/ui-routes/admin-entities-routes.js +1 -0
- package/lib/ui-routes/auth-routes.js +1339 -0
- package/lib/ui-routes/dashboard-routes.js +188 -0
- package/lib/ui-routes/document-store-routes.js +800 -0
- package/lib/ui-routes/export-routes.js +217 -0
- package/lib/ui-routes/internals-routes.js +354 -0
- package/lib/ui-routes/network-config-routes.js +759 -0
- package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +371 -91
- package/lib/ui-routes/route-helpers.js +316 -0
- package/lib/ui-routes/smtp-test-routes.js +236 -0
- package/lib/ui-routes/unsubscribe-routes.js +234 -0
- package/lib/webhook-request.js +36 -0
- package/package.json +17 -17
- package/sbom.json +1 -1
- package/server.js +217 -19
- package/static/licenses.html +52 -182
- package/translations/messages.pot +131 -151
- package/views/dashboard.hbs +7 -26
- package/views/internals/index.hbs +15 -0
- package/views/tokens/index.hbs +9 -0
- package/workers/api.js +198 -4401
- package/workers/export.js +87 -54
- package/workers/imap.js +29 -13
- package/workers/submit.js +20 -11
- 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;
|