emailengine-app 2.68.1 → 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 (68) hide show
  1. package/.github/workflows/deploy.yml +2 -0
  2. package/.github/workflows/release.yaml +4 -0
  3. package/CHANGELOG.md +40 -0
  4. package/config/default.toml +2 -0
  5. package/data/google-crawlers.json +7 -1
  6. package/lib/account.js +62 -25
  7. package/lib/api-routes/account-routes.js +493 -75
  8. package/lib/api-routes/blocklist-routes.js +337 -0
  9. package/lib/api-routes/delivery-test-routes.js +321 -0
  10. package/lib/api-routes/export-routes.js +1 -12
  11. package/lib/api-routes/gateway-routes.js +376 -0
  12. package/lib/api-routes/license-routes.js +142 -0
  13. package/lib/api-routes/mailbox-routes.js +318 -0
  14. package/lib/api-routes/message-routes.js +21 -129
  15. package/lib/api-routes/oauth2-app-routes.js +631 -0
  16. package/lib/api-routes/outbox-routes.js +173 -0
  17. package/lib/api-routes/pubsub-routes.js +98 -0
  18. package/lib/api-routes/route-helpers.js +45 -0
  19. package/lib/api-routes/settings-routes.js +331 -0
  20. package/lib/api-routes/stats-routes.js +77 -0
  21. package/lib/api-routes/submit-routes.js +472 -0
  22. package/lib/api-routes/template-routes.js +7 -55
  23. package/lib/api-routes/token-routes.js +297 -0
  24. package/lib/api-routes/webhook-route-routes.js +152 -0
  25. package/lib/email-client/gmail-client.js +14 -0
  26. package/lib/email-client/imap/mailbox.js +34 -11
  27. package/lib/email-client/imap/subconnection.js +20 -12
  28. package/lib/email-client/imap/sync-operations.js +130 -2
  29. package/lib/email-client/imap-client.js +116 -58
  30. package/lib/email-client/outlook-client.js +85 -13
  31. package/lib/export.js +60 -19
  32. package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
  33. package/lib/imapproxy/imap-core/lib/imap-command.js +6 -1
  34. package/lib/imapproxy/imap-core/lib/imap-connection.js +106 -23
  35. package/lib/imapproxy/imap-core/lib/imap-server.js +24 -0
  36. package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
  37. package/lib/message-port-stream.js +113 -16
  38. package/lib/reject-worker-calls.js +42 -0
  39. package/lib/routes-ui.js +37 -8778
  40. package/lib/schemas.js +26 -1
  41. package/lib/tools.js +68 -0
  42. package/lib/ui-routes/account-routes.js +40 -210
  43. package/lib/ui-routes/admin-config-routes.js +913 -487
  44. package/lib/ui-routes/admin-entities-routes.js +1 -0
  45. package/lib/ui-routes/auth-routes.js +1339 -0
  46. package/lib/ui-routes/dashboard-routes.js +188 -0
  47. package/lib/ui-routes/document-store-routes.js +800 -0
  48. package/lib/ui-routes/export-routes.js +217 -0
  49. package/lib/ui-routes/internals-routes.js +354 -0
  50. package/lib/ui-routes/network-config-routes.js +759 -0
  51. package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +371 -91
  52. package/lib/ui-routes/route-helpers.js +316 -0
  53. package/lib/ui-routes/smtp-test-routes.js +236 -0
  54. package/lib/ui-routes/unsubscribe-routes.js +234 -0
  55. package/lib/webhook-request.js +36 -0
  56. package/package.json +8 -8
  57. package/sbom.json +1 -1
  58. package/server.js +214 -16
  59. package/static/licenses.html +12 -12
  60. package/translations/messages.pot +129 -149
  61. package/views/dashboard.hbs +7 -26
  62. package/views/internals/index.hbs +15 -0
  63. package/views/tokens/index.hbs +9 -0
  64. package/workers/api.js +198 -4401
  65. package/workers/export.js +87 -54
  66. package/workers/imap.js +29 -13
  67. package/workers/submit.js +20 -11
  68. package/workers/webhooks.js +6 -20
@@ -0,0 +1,316 @@
1
+ 'use strict';
2
+
3
+ // NB! This file is processed by the gettext parser (npm run gettext) and can not use newer syntax like ?.
4
+
5
+ // Shared helpers used by more than one extracted UI route module - and still by
6
+ // lib/routes-ui.js for the route groups not yet extracted. Lifting these here lets each
7
+ // consumer import the single canonical copy instead of the monolith, so a route group can
8
+ // be extracted without stranding a helper its sibling groups still need. Pure functions
9
+ // and cached data only - this module registers no routes.
10
+ //
11
+ // Every symbol below was moved verbatim from lib/routes-ui.js. The only change is in
12
+ // cachedTemplates: its __dirname-relative paths gain one extra '..' because this file
13
+ // lives one directory deeper (lib/ui-routes/) than the original (lib/).
14
+
15
+ const Boom = require('@hapi/boom');
16
+ const util = require('util');
17
+ const fs = require('fs');
18
+ const pathlib = require('path');
19
+ const psl = require('psl');
20
+
21
+ const settings = require('../settings');
22
+ const { redis } = require('../db');
23
+ const { REDIS_PREFIX } = require('../consts');
24
+ const { oauth2ProviderData } = require('../oauth2-apps');
25
+ const exampleDocumentsPayloads = require('../payload-examples-documents.json');
26
+
27
+ const OPEN_AI_MODELS = [
28
+ {
29
+ name: 'GPT-3 (instruct)',
30
+ id: 'gpt-3.5-turbo-instruct'
31
+ },
32
+
33
+ {
34
+ name: 'GPT-3 (chat)',
35
+ id: 'gpt-3.5-turbo'
36
+ },
37
+
38
+ {
39
+ name: 'GPT-4',
40
+ id: 'gpt-4'
41
+ }
42
+ ];
43
+
44
+ const cachedTemplates = {
45
+ addressList: fs.readFileSync(pathlib.join(__dirname, '..', '..', 'views', 'partials', 'address_list.hbs'), 'utf-8'),
46
+ testSend: fs.readFileSync(pathlib.join(__dirname, '..', '..', 'views', 'partials', 'test_send.hbs'), 'utf-8')
47
+ };
48
+
49
+ const getOpenAiModels = async (models, selectedModel) => {
50
+ let modelList = (await settings.get('openAiModels')) || structuredClone(models);
51
+
52
+ if (selectedModel && !modelList.find(model => model.id === selectedModel)) {
53
+ modelList.unshift({
54
+ name: selectedModel,
55
+ id: selectedModel
56
+ });
57
+ }
58
+
59
+ return modelList.map(model => {
60
+ model.selected = model.id === selectedModel;
61
+ return model;
62
+ });
63
+ };
64
+
65
+ function formatAccountData(account, gt) {
66
+ account.type = {};
67
+
68
+ if (account.oauth2 && account.oauth2.app) {
69
+ let providerData = oauth2ProviderData(account.oauth2.app.provider);
70
+ account.type = providerData;
71
+ } else if (account.oauth2 && account.oauth2.provider) {
72
+ account.type = oauth2ProviderData(account.oauth2.provider);
73
+ } else if (account.imap && !account.imap.disabled) {
74
+ account.type.icon = 'fa fa-envelope-square';
75
+ account.type.name = 'IMAP';
76
+ account.type.comment = psl.get(account.imap.host) || account.imap.host;
77
+ } else if (account.smtp) {
78
+ account.type.icon = 'fa fa-paper-plane';
79
+ account.type.name = 'SMTP';
80
+ account.type.comment = psl.get(account.smtp.host) || account.smtp.host;
81
+ } else if (account.oauth2 && account.oauth2.auth && account.oauth2.auth.delegatedAccount) {
82
+ account.type.icon = 'fa fa-arrow-alt-circle-right';
83
+ account.type.name = gt.gettext('Delegated');
84
+ account.type.comment = util.format(gt.gettext('Using credentials from "%s"'), account.oauth2.auth.delegatedAccount);
85
+ } else {
86
+ account.type.name = 'N/A';
87
+ }
88
+
89
+ switch (account.state) {
90
+ case 'init':
91
+ account.stateLabel = {
92
+ type: 'info',
93
+ name: 'Initializing',
94
+ spinner: true
95
+ };
96
+ break;
97
+
98
+ case 'connecting':
99
+ account.stateLabel = {
100
+ type: 'info',
101
+ name: 'Connecting'
102
+ };
103
+ break;
104
+
105
+ case 'syncing':
106
+ account.stateLabel = {
107
+ type: 'info',
108
+ name: 'Syncing',
109
+ spinner: true
110
+ };
111
+ break;
112
+
113
+ case 'connected':
114
+ account.stateLabel = {
115
+ type: 'success',
116
+ name: 'Connected'
117
+ };
118
+ break;
119
+
120
+ case 'disabled':
121
+ account.stateLabel = {
122
+ type: 'secondary',
123
+ name: 'Disabled',
124
+ error: account.disabledReason
125
+ };
126
+ break;
127
+
128
+ case 'authenticationError':
129
+ case 'connectError': {
130
+ let errorMessage = account.lastErrorState ? account.lastErrorState.response : false;
131
+ if (account.lastErrorState) {
132
+ switch (account.lastErrorState.serverResponseCode) {
133
+ case 'ETIMEDOUT':
134
+ errorMessage = gt.gettext('Connection timed out. This usually occurs if you are behind a firewall or connecting to the wrong port.');
135
+ break;
136
+ case 'ClosedAfterConnectTLS':
137
+ errorMessage = gt.gettext('The server unexpectedly closed the connection.');
138
+ break;
139
+ case 'ClosedAfterConnectText':
140
+ errorMessage = gt.gettext(
141
+ 'The server unexpectedly closed the connection. This usually happens when attempting to connect to a TLS port without TLS enabled.'
142
+ );
143
+ break;
144
+ case 'ECONNREFUSED':
145
+ errorMessage = gt.gettext(
146
+ 'The server refused the connection. This typically occurs if the server is not running, is overloaded, or you are connecting to the wrong host or port.'
147
+ );
148
+ break;
149
+ }
150
+ }
151
+
152
+ account.stateLabel = {
153
+ type: 'danger',
154
+ name: 'Failed',
155
+ error: errorMessage
156
+ };
157
+ break;
158
+ }
159
+ case 'unset':
160
+ account.stateLabel = {
161
+ type: 'light',
162
+ name: 'Not syncing'
163
+ };
164
+ break;
165
+ case 'disconnected':
166
+ account.stateLabel = {
167
+ type: 'warning',
168
+ name: 'Disconnected'
169
+ };
170
+ break;
171
+ case 'paused':
172
+ account.stateLabel = {
173
+ type: 'secondary',
174
+ name: 'Paused'
175
+ };
176
+ break;
177
+ default:
178
+ account.stateLabel = {
179
+ type: 'secondary',
180
+ name: 'N/A'
181
+ };
182
+ break;
183
+ }
184
+
185
+ // Check if IMAP was disabled due to errors - override state label to show error
186
+ if (account.imap && account.imap.disabled && account.lastErrorState) {
187
+ account.stateLabel = {
188
+ type: 'danger',
189
+ name: 'Failed',
190
+ error: account.lastErrorState.description || account.lastErrorState.response
191
+ };
192
+ }
193
+
194
+ if (account.oauth2) {
195
+ account.oauth2.scopes = []
196
+ .concat(account.oauth2.scope || [])
197
+ .concat(account.oauth2.scopes || [])
198
+ .flatMap(entry => entry.split(/\s+/))
199
+ .map(entry => entry.trim())
200
+ .filter(entry => entry);
201
+
202
+ account.oauth2.expiresStr = account.oauth2.expires ? account.oauth2.expires.toISOString() : false;
203
+ account.oauth2.generatedStr = account.oauth2.generated ? account.oauth2.generated.toISOString() : false;
204
+
205
+ if (account.outlookSubscription) {
206
+ account.outlookSubscription.subscriptionExpiresStr = account.outlookSubscription.expirationDateTime
207
+ ? account.outlookSubscription.expirationDateTime.toISOString()
208
+ : false;
209
+
210
+ let state = account.outlookSubscription.state || {};
211
+
212
+ account.outlookSubscription.isValid =
213
+ state.state !== 'error' && account.outlookSubscription.expirationDateTime && account.outlookSubscription.expirationDateTime > new Date();
214
+
215
+ account.outlookSubscription.stateLabel = (state.state || '').replace(/^./, c => c.toUpperCase());
216
+
217
+ if ((state.state === 'created' && !account.outlookSubscription.expirationDateTime) || account.outlookSubscription.expirationDateTime < new Date()) {
218
+ account.outlookSubscription.stateLabel = 'Expired';
219
+ }
220
+ }
221
+ }
222
+
223
+ return account;
224
+ }
225
+
226
+ function formatServerState(state, payload) {
227
+ switch (state) {
228
+ case 'suspended':
229
+ case 'exited':
230
+ case 'disabled':
231
+ return {
232
+ type: 'warning',
233
+ name: state
234
+ };
235
+
236
+ case 'spawning':
237
+ case 'initializing':
238
+ return {
239
+ type: 'info',
240
+ name: state,
241
+ spinner: true
242
+ };
243
+
244
+ case 'listening':
245
+ return {
246
+ type: 'success',
247
+ name: state
248
+ };
249
+
250
+ case 'failed':
251
+ return {
252
+ type: 'danger',
253
+ name: state,
254
+ error: (payload && payload.error && payload.error.message) || null
255
+ };
256
+
257
+ default:
258
+ return {
259
+ type: 'secondary',
260
+ name: 'N/A'
261
+ };
262
+ }
263
+ }
264
+
265
+ async function getExampleDocumentsPayloads() {
266
+ let date = new Date().toISOString();
267
+
268
+ let examplePayloads = structuredClone(exampleDocumentsPayloads);
269
+
270
+ examplePayloads.forEach(payload => {
271
+ if (payload && payload.content) {
272
+ if (typeof payload.content.date === 'string') {
273
+ payload.content.date = date;
274
+ }
275
+
276
+ if (typeof payload.content.created === 'string') {
277
+ payload.content.created = date;
278
+ }
279
+ }
280
+ });
281
+ return examplePayloads;
282
+ }
283
+
284
+ async function getServerStatus(type) {
285
+ let serverStatus = await redis.hgetall(`${REDIS_PREFIX}${type}`);
286
+ let state = (serverStatus && serverStatus.state) || 'disabled';
287
+ let payload;
288
+ try {
289
+ payload = (serverStatus && typeof serverStatus.payload === 'string' && JSON.parse(serverStatus.payload)) || {};
290
+ } catch (err) {
291
+ // ignore
292
+ }
293
+
294
+ return { state, payload, label: formatServerState(state, payload) };
295
+ }
296
+
297
+ function throwAsBoom(err) {
298
+ if (Boom.isBoom(err)) {
299
+ throw err;
300
+ }
301
+ let error = Boom.boomify(err, { statusCode: err.statusCode || 500 });
302
+ if (err.code) {
303
+ error.output.payload.code = err.code;
304
+ }
305
+ throw error;
306
+ }
307
+
308
+ module.exports = {
309
+ OPEN_AI_MODELS,
310
+ cachedTemplates,
311
+ getOpenAiModels,
312
+ formatAccountData,
313
+ getExampleDocumentsPayloads,
314
+ getServerStatus,
315
+ throwAsBoom
316
+ };
@@ -0,0 +1,236 @@
1
+ 'use strict';
2
+
3
+ // Admin UI routes for the SMTP deliverability test tool (the "Send a test email" feature
4
+ // on an account page). Extracted verbatim from lib/routes-ui.js. These two endpoints call
5
+ // the external Nodemailer test service (api.nodemailer.com) to send a probe message and
6
+ // then fetch its DKIM/SPF analysis.
7
+
8
+ const Joi = require('joi');
9
+ const { fetch: fetchCmd } = require('undici');
10
+ const { Account } = require('../account');
11
+ const { redis } = require('../db');
12
+ const getSecret = require('../get-secret');
13
+ const { failAction, httpAgent } = require('../tools');
14
+ const { accountIdSchema } = require('../schemas');
15
+ const { REDIS_PREFIX } = require('../consts');
16
+ const packageData = require('../../package.json');
17
+
18
+ const SMTP_TEST_HOST = 'https://api.nodemailer.com';
19
+
20
+ function init(args) {
21
+ const { server, call } = args;
22
+
23
+ server.route({
24
+ method: 'POST',
25
+ path: '/admin/smtp/create-test',
26
+ async handler(request) {
27
+ let account = request.payload.account;
28
+
29
+ try {
30
+ request.logger.info({ msg: 'Request SMTP test', account });
31
+
32
+ let accountObject = new Account({ redis, account, call, secret: await getSecret() });
33
+
34
+ let accountData;
35
+ try {
36
+ accountData = await accountObject.loadAccountData();
37
+ } catch (err) {
38
+ return {
39
+ error: err.message
40
+ };
41
+ }
42
+
43
+ let headers = {
44
+ 'Content-Type': 'application/json',
45
+ 'User-Agent': `${packageData.name}/${packageData.version} (+${packageData.homepage})`
46
+ };
47
+
48
+ let res = await fetchCmd(`${SMTP_TEST_HOST}/test-address`, {
49
+ method: 'post',
50
+ body: JSON.stringify({
51
+ version: packageData.version,
52
+ requestor: '@postalsys/emailengine-app'
53
+ }),
54
+ headers,
55
+ dispatcher: httpAgent.retry
56
+ });
57
+
58
+ if (!res.ok) {
59
+ let err = new Error(res.statusText || `Invalid response: ${res.status} ${res.statusText}`);
60
+ err.statusCode = res.status;
61
+
62
+ try {
63
+ err.response = await res.json();
64
+ } catch (err) {
65
+ // ignore
66
+ }
67
+
68
+ throw err;
69
+ }
70
+
71
+ let testAccount = await res.json();
72
+ if (!testAccount || !testAccount.user) {
73
+ let err = new Error(`Invalid test account`);
74
+ err.status = 500;
75
+
76
+ try {
77
+ err.response = testAccount;
78
+ } catch (err) {
79
+ // ignore
80
+ }
81
+
82
+ throw err;
83
+ }
84
+
85
+ try {
86
+ let now = new Date().toISOString();
87
+ let queueResponse = await accountObject.queueMessage(
88
+ {
89
+ account: accountData.account,
90
+ subject: `Delivery test ${now}`,
91
+ text: `Hello
92
+
93
+ This is an automated email to test deliverability settings. If you see this email, you can safely delete it.
94
+
95
+ ${now}`,
96
+ html: `<p>Hello</p>
97
+ <p>This is an automated email to test deliverability settings. If you see this email, you can safely delete it.</p>
98
+ <p>${now}</p>`,
99
+
100
+ from: {
101
+ name: accountData.name,
102
+ address: accountData.email
103
+ },
104
+ to: [{ name: 'Delivery Test Server', address: testAccount.address }],
105
+ copy: false,
106
+ gateway: request.payload.gateway,
107
+ feedbackKey: `${REDIS_PREFIX}test-send:${testAccount.user}`,
108
+ deliveryAttempts: 1
109
+ },
110
+ { source: 'test' }
111
+ );
112
+
113
+ return Object.assign(testAccount, queueResponse || {});
114
+ } catch (err) {
115
+ return {
116
+ error: err.message
117
+ };
118
+ }
119
+ } catch (err) {
120
+ request.logger.error({ msg: 'Failed to request test account', err, account });
121
+ return { success: false, error: err.message };
122
+ }
123
+ },
124
+ options: {
125
+ validate: {
126
+ options: {
127
+ stripUnknown: true,
128
+ abortEarly: false,
129
+ convert: true
130
+ },
131
+
132
+ failAction,
133
+
134
+ payload: Joi.object({
135
+ account: accountIdSchema.required(),
136
+ gateway: Joi.string().empty('').max(256).example('sendgun').description('Gateway ID')
137
+ })
138
+ }
139
+ }
140
+ });
141
+
142
+ server.route({
143
+ method: 'POST',
144
+ path: '/admin/smtp/check-test',
145
+ async handler(request) {
146
+ let user = request.payload.user;
147
+
148
+ try {
149
+ request.logger.info({ msg: 'Request SMTP test response', user });
150
+
151
+ let deliveryStatus = (await redis.hgetall(`${REDIS_PREFIX}test-send:${user}`)) || {};
152
+ if (deliveryStatus.success === 'false') {
153
+ let err = new Error(`Failed to deliver email: ${deliveryStatus.error}`);
154
+ err.status = 500;
155
+ throw err;
156
+ }
157
+
158
+ let headers = {
159
+ 'Content-Type': 'application/json',
160
+ 'User-Agent': `${packageData.name}/${packageData.version} (+${packageData.homepage})`
161
+ };
162
+
163
+ let res = await fetchCmd(`${SMTP_TEST_HOST}/test-address/${user}`, {
164
+ method: 'get',
165
+ headers,
166
+ dispatcher: httpAgent.retry
167
+ });
168
+
169
+ if (!res.ok) {
170
+ let err = new Error(res.statusText || `Invalid response: ${res.status} ${res.statusText}`);
171
+ err.statusCode = res.status;
172
+
173
+ try {
174
+ err.response = await res.json();
175
+ } catch (err) {
176
+ // ignore
177
+ }
178
+
179
+ throw err;
180
+ }
181
+
182
+ let testResponse = await res.json();
183
+
184
+ if (testResponse) {
185
+ let mainSig =
186
+ testResponse.dkim &&
187
+ testResponse.dkim.results &&
188
+ testResponse.dkim.results.find(entry => entry && entry.status && entry.status.result === 'pass' && entry.status.aligned);
189
+
190
+ if (!mainSig) {
191
+ mainSig =
192
+ testResponse.dkim &&
193
+ testResponse.dkim.results &&
194
+ testResponse.dkim.results.find(entry => entry && entry.status && entry.status.result === 'pass');
195
+ }
196
+
197
+ if (!mainSig) {
198
+ mainSig = testResponse.dkim && testResponse.dkim.results && testResponse.dkim.results[0];
199
+ }
200
+
201
+ testResponse.mainSig = mainSig || {
202
+ status: {
203
+ result: 'none'
204
+ }
205
+ };
206
+
207
+ if (testResponse.spf && testResponse.spf.status && testResponse.spf.status.comment) {
208
+ testResponse.spf.status.comment = testResponse.spf.status.comment.replace(/^[^:\s]+:s*/, '');
209
+ }
210
+ }
211
+
212
+ return testResponse;
213
+ } catch (err) {
214
+ request.logger.error({ msg: 'Failed to request test response', err, user });
215
+ return { status: 'error', error: err.message };
216
+ }
217
+ },
218
+ options: {
219
+ validate: {
220
+ options: {
221
+ stripUnknown: true,
222
+ abortEarly: false,
223
+ convert: true
224
+ },
225
+
226
+ failAction,
227
+
228
+ payload: Joi.object({
229
+ user: Joi.string().guid().description('Test ID')
230
+ })
231
+ }
232
+ }
233
+ });
234
+ }
235
+
236
+ module.exports = init;