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,234 @@
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
+ // Public (unauthenticated) subscription-management routes. These render the unsubscribe
6
+ // landing page reached from the List-Unsubscribe link in outgoing messages and process
7
+ // the subscribe/unsubscribe form submission. Extracted verbatim from lib/routes-ui.js.
8
+ // Both routes set `auth: false` and define their own validation failAction handlers.
9
+
10
+ const Joi = require('joi');
11
+ const Boom = require('@hapi/boom');
12
+ const { Account } = require('../account');
13
+ const { redis } = require('../db');
14
+ const { accountIdSchema } = require('../schemas');
15
+ const { REDIS_PREFIX } = require('../consts');
16
+
17
+ function init(args) {
18
+ const { server, call } = args;
19
+
20
+ server.route({
21
+ method: 'GET',
22
+ path: '/unsubscribe',
23
+ async handler(request, h) {
24
+ let data = Buffer.from(request.query.data, 'base64url').toString();
25
+ // do not check signature, validate fields in the submit step
26
+
27
+ data = JSON.parse(data);
28
+
29
+ if (!data || typeof data !== 'object' || data.act !== 'unsub') {
30
+ throw new Error(request.app.gt.gettext('Invalid input'));
31
+ }
32
+
33
+ // throws if account does not exist
34
+ let accountObject = new Account({ redis, account: data.acc });
35
+ await accountObject.loadAccountData();
36
+
37
+ return h.view(
38
+ 'unsubscribe',
39
+ {
40
+ pageTitleFull: request.app.gt.gettext('Subscription Management'),
41
+
42
+ unsubscribed: await redis.hexists(`${REDIS_PREFIX}lists:unsub:entries:${data.list}`, data.rcpt),
43
+ values: {
44
+ listId: data.list,
45
+ account: data.acc,
46
+ messageId: data.msg,
47
+ email: data.rcpt
48
+ }
49
+ },
50
+ {
51
+ layout: 'public'
52
+ }
53
+ );
54
+ },
55
+ options: {
56
+ auth: false,
57
+
58
+ validate: {
59
+ options: {
60
+ stripUnknown: true,
61
+ abortEarly: false,
62
+ convert: true
63
+ },
64
+
65
+ async failAction(request, h, err) {
66
+ request.logger.error({ msg: 'Failed to validate request arguments', err });
67
+ let error = Boom.boomify(new Error(request.app.gt.gettext('Invalid request. Check your input and try again.')), { statusCode: 400 });
68
+ if (err.code) {
69
+ error.output.payload.code = err.code;
70
+ }
71
+ throw error;
72
+ },
73
+
74
+ query: Joi.object({
75
+ data: Joi.string().base64({ paddingRequired: false, urlSafe: true }).required(),
76
+ sig: Joi.string().base64({ paddingRequired: false, urlSafe: true })
77
+ })
78
+ }
79
+ }
80
+ });
81
+
82
+ server.route({
83
+ method: 'POST',
84
+ path: '/unsubscribe/address',
85
+ async handler(request, h) {
86
+ try {
87
+ // throws if account does not exist
88
+ let accountObject = new Account({ redis, account: request.payload.account });
89
+ await accountObject.loadAccountData();
90
+
91
+ let reSubscribed = false;
92
+
93
+ switch (request.payload.action) {
94
+ case 'unsubscribe': {
95
+ let isNew = await redis.eeListAdd(
96
+ `${REDIS_PREFIX}lists:unsub:lists`,
97
+ `${REDIS_PREFIX}lists:unsub:entries:${request.payload.listId}`,
98
+ request.payload.listId,
99
+ request.payload.email.toLowerCase().trim(),
100
+ JSON.stringify({
101
+ recipient: request.payload.email,
102
+ account: request.payload.account,
103
+ source: 'form',
104
+ reason: 'unsubscribe',
105
+ messageId: request.payload.messageId,
106
+ remoteAddress: request.info.remoteAddress,
107
+ userAgent: request.headers['user-agent'],
108
+ created: new Date().toISOString()
109
+ })
110
+ );
111
+
112
+ if (isNew) {
113
+ await call({
114
+ cmd: 'unsubscribe',
115
+ account: request.payload.account,
116
+ payload: {
117
+ recipient: request.payload.email,
118
+ messageId: request.payload.messageId,
119
+ listId: request.payload.listId,
120
+ remoteAddress: request.info.remoteAddress,
121
+ userAgent: request.headers['user-agent']
122
+ }
123
+ });
124
+ }
125
+ break;
126
+ }
127
+
128
+ case 'subscribe': {
129
+ let removed = await redis.eeListRemove(
130
+ `${REDIS_PREFIX}lists:unsub:lists`,
131
+ `${REDIS_PREFIX}lists:unsub:entries:${request.payload.listId}`,
132
+ request.payload.listId,
133
+ request.payload.email.toLowerCase().trim()
134
+ );
135
+
136
+ if (removed) {
137
+ await call({
138
+ cmd: 'subscribe',
139
+ account: request.payload.account,
140
+ payload: {
141
+ recipient: request.payload.email,
142
+ messageId: request.payload.messageId,
143
+ listId: request.payload.listId,
144
+ remoteAddress: request.info.remoteAddress,
145
+ userAgent: request.headers['user-agent']
146
+ }
147
+ });
148
+ }
149
+
150
+ reSubscribed = true;
151
+ break;
152
+ }
153
+ }
154
+
155
+ return h.view(
156
+ 'unsubscribe',
157
+ {
158
+ pageTitleFull: request.app.gt.gettext('Subscription Management'),
159
+
160
+ unsubscribed: await redis.hexists(`${REDIS_PREFIX}lists:unsub:entries:${request.payload.listId}`, request.payload.email),
161
+ values: request.payload,
162
+ reSubscribed
163
+ },
164
+ {
165
+ layout: 'public'
166
+ }
167
+ );
168
+ } catch (err) {
169
+ await request.flash({ type: 'danger', message: request.app.gt.gettext("Couldn't process request. Try again.") });
170
+ request.logger.error({ msg: 'Failed to process subscription request', err });
171
+
172
+ return h.view(
173
+ 'unsubscribe',
174
+ {
175
+ pageTitleFull: request.app.gt.gettext('Subscription Management'),
176
+ unsubscribed: await redis.hexists(`${REDIS_PREFIX}lists:unsub:entries:${request.payload.listId}`, request.payload.email)
177
+ },
178
+ {
179
+ layout: 'public'
180
+ }
181
+ );
182
+ }
183
+ },
184
+ options: {
185
+ auth: false,
186
+ validate: {
187
+ options: {
188
+ stripUnknown: true,
189
+ abortEarly: false,
190
+ convert: true
191
+ },
192
+
193
+ async failAction(request, h, err) {
194
+ let errors = {};
195
+
196
+ if (err.details) {
197
+ err.details.forEach(detail => {
198
+ if (!errors[detail.path]) {
199
+ errors[detail.path] = detail.message;
200
+ }
201
+ });
202
+ }
203
+
204
+ await request.flash({ type: 'danger', message: request.app.gt.gettext("Couldn't process request. Try again.") });
205
+ request.logger.error({ msg: 'Failed to process subscription request', err });
206
+
207
+ return h
208
+ .view(
209
+ 'unsubscribe',
210
+ {
211
+ pageTitleFull: request.app.gt.gettext('Subscription Management'),
212
+ unsubscribed: await redis.hexists(`${REDIS_PREFIX}lists:unsub:entries:${request.payload.listId}`, request.payload.email),
213
+ errors
214
+ },
215
+ {
216
+ layout: 'public'
217
+ }
218
+ )
219
+ .takeover();
220
+ },
221
+
222
+ payload: Joi.object({
223
+ action: Joi.string().valid('subscribe', 'unsubscribe').required(),
224
+ account: accountIdSchema.required(),
225
+ listId: Joi.string().hostname().empty('').example('test-list').label('List ID').required(),
226
+ email: Joi.string().email().empty('').required().description('Email address').required(),
227
+ messageId: Joi.string().empty('').max(996).example('<test123@example.com>').description('Message ID')
228
+ })
229
+ }
230
+ }
231
+ });
232
+ }
233
+
234
+ module.exports = init;
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Performs a single webhook HTTP delivery and always drains the response body.
5
+ *
6
+ * The webhook response body is never used, but with undici's keepAlive dispatcher
7
+ * a connection is only returned to the pool once its body has been consumed.
8
+ * Draining on every path (success and failure) prevents successful deliveries -
9
+ * the common, high-volume case - from pinning pooled sockets.
10
+ *
11
+ * @param {Function} fetchImpl - fetch implementation (undici fetch)
12
+ * @param {string} url - Destination URL
13
+ * @param {Object} options - fetch options (method, body, headers, dispatcher)
14
+ * @returns {Promise<number>} Resolves with the HTTP status code on success
15
+ * @throws {Error} On a non-2xx response; the error carries a `statusCode` property
16
+ */
17
+ async function sendWebhookRequest(fetchImpl, url, options) {
18
+ const res = await fetchImpl(url, options);
19
+
20
+ // Drain the body regardless of status so the socket can be reused.
21
+ try {
22
+ await res.text();
23
+ } catch (err) {
24
+ // ignore drain errors
25
+ }
26
+
27
+ if (!res.ok) {
28
+ let err = new Error(res.statusText || `Invalid response: ${res.status} ${res.statusText}`);
29
+ err.statusCode = res.status;
30
+ throw err;
31
+ }
32
+
33
+ return res.status;
34
+ }
35
+
36
+ module.exports = { sendWebhookRequest };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "emailengine-app",
3
- "version": "2.68.1",
3
+ "version": "2.69.0",
4
4
  "private": false,
5
5
  "productTitle": "EmailEngine",
6
6
  "description": "Email Sync Engine",
@@ -17,7 +17,7 @@
17
17
  "build-dist": "pkg --compress Brotli package.json && npm install && node winconf.js",
18
18
  "build-dist-fast": "pkg --debug package.json && npm install && node winconf.js",
19
19
  "licenses": "license-checker --excludePackages 'emailengine-app' --json | node list-generate.js > static/licenses.html",
20
- "gettext": "find ./views -name \"*.hbs\" -print0 | xargs -0 xgettext-template -L Handlebars -o translations/messages.pot --force-po && jsxgettext --parser-options '{\"ecmaVersion\": 2018}' lib/routes-ui.js workers/api.js lib/tools.js lib/autodetect-imap-settings.js lib/ui-routes/account-routes.js lib/ui-routes/admin-config-routes.js lib/ui-routes/admin-entities-routes.js -j -o translations/messages.pot",
20
+ "gettext": "find ./views -name \"*.hbs\" -print0 | xargs -0 xgettext-template -L Handlebars -o translations/messages.pot --force-po && jsxgettext --parser-options '{\"ecmaVersion\": 2018}' workers/api.js lib/tools.js lib/autodetect-imap-settings.js lib/ui-routes/admin-entities-routes.js lib/ui-routes/account-routes.js lib/ui-routes/oauth-config-routes.js lib/ui-routes/admin-config-routes.js lib/ui-routes/route-helpers.js lib/ui-routes/unsubscribe-routes.js -j -o translations/messages.pot",
21
21
  "prepare-docker": "echo \"EE_DOCKER_LEGACY=$EE_DOCKER_LEGACY\" >> system.env && cat system.env",
22
22
  "update": "rm -rf node_modules package-lock.json && ncu -u && npm install && ./copy-static-files.sh && npm run licenses && npm run gettext",
23
23
  "test-gmail-api": "node lib/email-client/gmail-client.js --dbs.redis=redis://127.0.0.1/11",
@@ -44,8 +44,8 @@
44
44
  "homepage": "https://emailengine.app/",
45
45
  "dependencies": {
46
46
  "@bugsnag/js": "8.9.0",
47
- "@bull-board/api": "7.1.5",
48
- "@bull-board/hapi": "7.1.5",
47
+ "@bull-board/api": "7.2.1",
48
+ "@bull-board/hapi": "7.2.1",
49
49
  "@elastic/elasticsearch": "8.15.3",
50
50
  "@hapi/accept": "6.0.3",
51
51
  "@hapi/bell": "13.1.0",
@@ -70,7 +70,7 @@
70
70
  "@zone-eu/wild-config": "1.7.5",
71
71
  "ace-builds": "1.44.0",
72
72
  "base32.js": "0.1.0",
73
- "bullmq": "5.77.7",
73
+ "bullmq": "5.78.0",
74
74
  "compare-versions": "6.1.1",
75
75
  "dotenv": "17.4.2",
76
76
  "encoding-japanese": "2.2.0",
@@ -84,9 +84,9 @@
84
84
  "html-to-text": "10.0.0",
85
85
  "ical.js": "1.5.0",
86
86
  "iconv-lite": "0.7.2",
87
- "imapflow": "1.3.5",
87
+ "imapflow": "1.4.0",
88
88
  "ioredfour": "1.4.1",
89
- "ioredis": "5.11.0",
89
+ "ioredis": "5.11.1",
90
90
  "ipaddr.js": "2.4.0",
91
91
  "joi": "17.13.3",
92
92
  "jquery": "4.0.0",
@@ -127,7 +127,7 @@
127
127
  "grunt-wait": "0.3.0",
128
128
  "jsxgettext": "0.11.0",
129
129
  "pino-pretty": "13.0.0",
130
- "prettier": "3.8.3",
130
+ "prettier": "3.8.4",
131
131
  "resedit": "3.0.2",
132
132
  "spdx-satisfies": "6.0.0",
133
133
  "supertest": "7.2.2",