emailengine-app 2.68.1 → 2.70.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 (95) hide show
  1. package/.github/workflows/deploy.yml +8 -3
  2. package/.github/workflows/release.yaml +6 -0
  3. package/CHANGELOG.md +59 -0
  4. package/Gruntfile.js +3 -1
  5. package/config/default.toml +2 -0
  6. package/data/google-crawlers.json +7 -1
  7. package/getswagger.sh +40 -4
  8. package/gettext-extract.js +163 -0
  9. package/lib/account.js +135 -72
  10. package/lib/api-routes/account-routes.js +684 -106
  11. package/lib/api-routes/blocklist-routes.js +344 -0
  12. package/lib/api-routes/chat-routes.js +32 -14
  13. package/lib/api-routes/delivery-test-routes.js +346 -0
  14. package/lib/api-routes/export-routes.js +28 -14
  15. package/lib/api-routes/gateway-routes.js +427 -0
  16. package/lib/api-routes/license-routes.js +156 -0
  17. package/lib/api-routes/mailbox-routes.js +344 -0
  18. package/lib/api-routes/message-routes.js +221 -187
  19. package/lib/api-routes/oauth2-app-routes.js +697 -0
  20. package/lib/api-routes/outbox-routes.js +185 -0
  21. package/lib/api-routes/pubsub-routes.js +102 -0
  22. package/lib/api-routes/route-helpers.js +58 -0
  23. package/lib/api-routes/settings-routes.js +357 -0
  24. package/lib/api-routes/stats-routes.js +111 -0
  25. package/lib/api-routes/submit-routes.js +461 -0
  26. package/lib/api-routes/template-routes.js +60 -75
  27. package/lib/api-routes/token-routes.js +297 -0
  28. package/lib/api-routes/webhook-route-routes.js +181 -0
  29. package/lib/autodetect-imap-settings.js +0 -2
  30. package/lib/consts.js +5 -0
  31. package/lib/email-client/base-client.js +28 -6
  32. package/lib/email-client/gmail-client.js +133 -112
  33. package/lib/email-client/imap/mailbox.js +34 -11
  34. package/lib/email-client/imap/subconnection.js +20 -13
  35. package/lib/email-client/imap/sync-operations.js +131 -3
  36. package/lib/email-client/imap-client.js +152 -75
  37. package/lib/email-client/notification-handler.js +1 -4
  38. package/lib/email-client/outlook-client.js +134 -75
  39. package/lib/export.js +97 -20
  40. package/lib/feature-flags.js +2 -2
  41. package/lib/gateway.js +4 -9
  42. package/lib/get-raw-email.js +5 -5
  43. package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
  44. package/lib/imapproxy/imap-core/lib/imap-command.js +6 -1
  45. package/lib/imapproxy/imap-core/lib/imap-connection.js +106 -24
  46. package/lib/imapproxy/imap-core/lib/imap-server.js +24 -0
  47. package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
  48. package/lib/logger.js +24 -21
  49. package/lib/message-port-stream.js +113 -16
  50. package/lib/metrics-collector.js +0 -2
  51. package/lib/oauth2-apps.js +13 -4
  52. package/lib/outbox.js +24 -40
  53. package/lib/redis-operations.js +1 -1
  54. package/lib/reject-worker-calls.js +42 -0
  55. package/lib/routes-ui.js +37 -8778
  56. package/lib/schemas.js +429 -84
  57. package/lib/sentry.js +139 -0
  58. package/lib/settings.js +9 -3
  59. package/lib/stream-encrypt.js +1 -1
  60. package/lib/templates.js +1 -1
  61. package/lib/tokens.js +5 -3
  62. package/lib/tools.js +70 -4
  63. package/lib/ui-routes/account-routes.js +45 -212
  64. package/lib/ui-routes/admin-config-routes.js +928 -489
  65. package/lib/ui-routes/admin-entities-routes.js +1 -0
  66. package/lib/ui-routes/auth-routes.js +1339 -0
  67. package/lib/ui-routes/dashboard-routes.js +188 -0
  68. package/lib/ui-routes/document-store-routes.js +800 -0
  69. package/lib/ui-routes/export-routes.js +217 -0
  70. package/lib/ui-routes/internals-routes.js +354 -0
  71. package/lib/ui-routes/network-config-routes.js +759 -0
  72. package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +369 -91
  73. package/lib/ui-routes/route-helpers.js +314 -0
  74. package/lib/ui-routes/smtp-test-routes.js +236 -0
  75. package/lib/ui-routes/unsubscribe-routes.js +232 -0
  76. package/lib/webhook-request.js +36 -0
  77. package/lib/webhooks.js +8 -4
  78. package/package.json +13 -12
  79. package/sbom.json +1 -1
  80. package/server.js +222 -39
  81. package/static/licenses.html +160 -300
  82. package/translations/messages.pot +112 -132
  83. package/update-info.sh +19 -1
  84. package/views/config/logging.hbs +48 -0
  85. package/views/dashboard.hbs +7 -26
  86. package/views/internals/index.hbs +15 -0
  87. package/views/tokens/index.hbs +9 -0
  88. package/workers/api.js +200 -4424
  89. package/workers/documents.js +2 -22
  90. package/workers/export.js +103 -104
  91. package/workers/imap-proxy.js +3 -23
  92. package/workers/imap.js +32 -36
  93. package/workers/smtp.js +2 -22
  94. package/workers/submit.js +26 -35
  95. package/workers/webhooks.js +9 -43
@@ -0,0 +1,232 @@
1
+ 'use strict';
2
+
3
+ // Public (unauthenticated) subscription-management routes. These render the unsubscribe
4
+ // landing page reached from the List-Unsubscribe link in outgoing messages and process
5
+ // the subscribe/unsubscribe form submission. Extracted verbatim from lib/routes-ui.js.
6
+ // Both routes set `auth: false` and define their own validation failAction handlers.
7
+
8
+ const Joi = require('joi');
9
+ const Boom = require('@hapi/boom');
10
+ const { Account } = require('../account');
11
+ const { redis } = require('../db');
12
+ const { accountIdSchema } = require('../schemas');
13
+ const { REDIS_PREFIX } = require('../consts');
14
+
15
+ function init(args) {
16
+ const { server, call } = args;
17
+
18
+ server.route({
19
+ method: 'GET',
20
+ path: '/unsubscribe',
21
+ async handler(request, h) {
22
+ let data = Buffer.from(request.query.data, 'base64url').toString();
23
+ // do not check signature, validate fields in the submit step
24
+
25
+ data = JSON.parse(data);
26
+
27
+ if (!data || typeof data !== 'object' || data.act !== 'unsub') {
28
+ throw new Error(request.app.gt.gettext('Invalid input'));
29
+ }
30
+
31
+ // throws if account does not exist
32
+ let accountObject = new Account({ redis, account: data.acc });
33
+ await accountObject.loadAccountData();
34
+
35
+ return h.view(
36
+ 'unsubscribe',
37
+ {
38
+ pageTitleFull: request.app.gt.gettext('Subscription Management'),
39
+
40
+ unsubscribed: await redis.hexists(`${REDIS_PREFIX}lists:unsub:entries:${data.list}`, data.rcpt),
41
+ values: {
42
+ listId: data.list,
43
+ account: data.acc,
44
+ messageId: data.msg,
45
+ email: data.rcpt
46
+ }
47
+ },
48
+ {
49
+ layout: 'public'
50
+ }
51
+ );
52
+ },
53
+ options: {
54
+ auth: false,
55
+
56
+ validate: {
57
+ options: {
58
+ stripUnknown: true,
59
+ abortEarly: false,
60
+ convert: true
61
+ },
62
+
63
+ async failAction(request, h, err) {
64
+ request.logger.error({ msg: 'Failed to validate request arguments', err });
65
+ let error = Boom.boomify(new Error(request.app.gt.gettext('Invalid request. Check your input and try again.')), { statusCode: 400 });
66
+ if (err.code) {
67
+ error.output.payload.code = err.code;
68
+ }
69
+ throw error;
70
+ },
71
+
72
+ query: Joi.object({
73
+ data: Joi.string().base64({ paddingRequired: false, urlSafe: true }).required(),
74
+ sig: Joi.string().base64({ paddingRequired: false, urlSafe: true })
75
+ })
76
+ }
77
+ }
78
+ });
79
+
80
+ server.route({
81
+ method: 'POST',
82
+ path: '/unsubscribe/address',
83
+ async handler(request, h) {
84
+ try {
85
+ // throws if account does not exist
86
+ let accountObject = new Account({ redis, account: request.payload.account });
87
+ await accountObject.loadAccountData();
88
+
89
+ let reSubscribed = false;
90
+
91
+ switch (request.payload.action) {
92
+ case 'unsubscribe': {
93
+ let isNew = await redis.eeListAdd(
94
+ `${REDIS_PREFIX}lists:unsub:lists`,
95
+ `${REDIS_PREFIX}lists:unsub:entries:${request.payload.listId}`,
96
+ request.payload.listId,
97
+ request.payload.email.toLowerCase().trim(),
98
+ JSON.stringify({
99
+ recipient: request.payload.email,
100
+ account: request.payload.account,
101
+ source: 'form',
102
+ reason: 'unsubscribe',
103
+ messageId: request.payload.messageId,
104
+ remoteAddress: request.info.remoteAddress,
105
+ userAgent: request.headers['user-agent'],
106
+ created: new Date().toISOString()
107
+ })
108
+ );
109
+
110
+ if (isNew) {
111
+ await call({
112
+ cmd: 'unsubscribe',
113
+ account: request.payload.account,
114
+ payload: {
115
+ recipient: request.payload.email,
116
+ messageId: request.payload.messageId,
117
+ listId: request.payload.listId,
118
+ remoteAddress: request.info.remoteAddress,
119
+ userAgent: request.headers['user-agent']
120
+ }
121
+ });
122
+ }
123
+ break;
124
+ }
125
+
126
+ case 'subscribe': {
127
+ let removed = await redis.eeListRemove(
128
+ `${REDIS_PREFIX}lists:unsub:lists`,
129
+ `${REDIS_PREFIX}lists:unsub:entries:${request.payload.listId}`,
130
+ request.payload.listId,
131
+ request.payload.email.toLowerCase().trim()
132
+ );
133
+
134
+ if (removed) {
135
+ await call({
136
+ cmd: 'subscribe',
137
+ account: request.payload.account,
138
+ payload: {
139
+ recipient: request.payload.email,
140
+ messageId: request.payload.messageId,
141
+ listId: request.payload.listId,
142
+ remoteAddress: request.info.remoteAddress,
143
+ userAgent: request.headers['user-agent']
144
+ }
145
+ });
146
+ }
147
+
148
+ reSubscribed = true;
149
+ break;
150
+ }
151
+ }
152
+
153
+ return h.view(
154
+ 'unsubscribe',
155
+ {
156
+ pageTitleFull: request.app.gt.gettext('Subscription Management'),
157
+
158
+ unsubscribed: await redis.hexists(`${REDIS_PREFIX}lists:unsub:entries:${request.payload.listId}`, request.payload.email),
159
+ values: request.payload,
160
+ reSubscribed
161
+ },
162
+ {
163
+ layout: 'public'
164
+ }
165
+ );
166
+ } catch (err) {
167
+ await request.flash({ type: 'danger', message: request.app.gt.gettext("Couldn't process request. Try again.") });
168
+ request.logger.error({ msg: 'Failed to process subscription request', err });
169
+
170
+ return h.view(
171
+ 'unsubscribe',
172
+ {
173
+ pageTitleFull: request.app.gt.gettext('Subscription Management'),
174
+ unsubscribed: await redis.hexists(`${REDIS_PREFIX}lists:unsub:entries:${request.payload.listId}`, request.payload.email)
175
+ },
176
+ {
177
+ layout: 'public'
178
+ }
179
+ );
180
+ }
181
+ },
182
+ options: {
183
+ auth: false,
184
+ validate: {
185
+ options: {
186
+ stripUnknown: true,
187
+ abortEarly: false,
188
+ convert: true
189
+ },
190
+
191
+ async failAction(request, h, err) {
192
+ let errors = {};
193
+
194
+ if (err.details) {
195
+ err.details.forEach(detail => {
196
+ if (!errors[detail.path]) {
197
+ errors[detail.path] = detail.message;
198
+ }
199
+ });
200
+ }
201
+
202
+ await request.flash({ type: 'danger', message: request.app.gt.gettext("Couldn't process request. Try again.") });
203
+ request.logger.error({ msg: 'Failed to process subscription request', err });
204
+
205
+ return h
206
+ .view(
207
+ 'unsubscribe',
208
+ {
209
+ pageTitleFull: request.app.gt.gettext('Subscription Management'),
210
+ unsubscribed: await redis.hexists(`${REDIS_PREFIX}lists:unsub:entries:${request.payload.listId}`, request.payload.email),
211
+ errors
212
+ },
213
+ {
214
+ layout: 'public'
215
+ }
216
+ )
217
+ .takeover();
218
+ },
219
+
220
+ payload: Joi.object({
221
+ action: Joi.string().valid('subscribe', 'unsubscribe').required(),
222
+ account: accountIdSchema.required(),
223
+ listId: Joi.string().hostname().empty('').example('test-list').label('List ID').required(),
224
+ email: Joi.string().email().empty('').required().description('Email address').required(),
225
+ messageId: Joi.string().empty('').max(996).example('<test123@example.com>').description('Message ID')
226
+ })
227
+ }
228
+ }
229
+ });
230
+ }
231
+
232
+ 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/lib/webhooks.js CHANGED
@@ -70,12 +70,12 @@ class WebhooksHandler {
70
70
 
71
71
  try {
72
72
  let webhookMeta = msgpack.decode(entry);
73
- if (webhookErrorFlag && typeof webhookErrorFlag === 'object' && !Object.keys(webhookErrorFlag)) {
73
+ if (webhookErrorFlag && typeof webhookErrorFlag === 'object' && !Object.keys(webhookErrorFlag).length) {
74
74
  webhookErrorFlag = null;
75
75
  }
76
76
  response.webhooks.push(Object.assign(webhookMeta, { tcount, webhookErrorFlag }));
77
77
  } catch (err) {
78
- logger.error({ msg: 'Failed to process webhook', entry: entry.toString('base64') });
78
+ logger.error({ msg: 'Failed to process webhook', entry: entry && entry.toString('base64'), err });
79
79
  continue;
80
80
  }
81
81
  }
@@ -398,7 +398,6 @@ class WebhooksHandler {
398
398
  v = Number(v) || 0;
399
399
  if (v !== this.handlerCacheV) {
400
400
  // changes detected
401
- v = this.handlerCacheV;
402
401
 
403
402
  let webhookIds = await this.redis.smembers(this.getWebhooksIndexKey());
404
403
  webhookIds = [].concat(webhookIds || []).sort((a, b) => -a.localeCompare(b));
@@ -418,7 +417,8 @@ class WebhooksHandler {
418
417
  this.handlerCache.push(handler);
419
418
  } else {
420
419
  // compare existing
421
- let webhookV = await this.redis.hget(this.getWebhooksContentKey(), `${webhookId}:v`);
420
+ // the per-route counter is stored as a string, existing.v is a number (see get())
421
+ let webhookV = Number(await this.redis.hget(this.getWebhooksContentKey(), `${webhookId}:v`)) || 0;
422
422
  if (existing.v !== webhookV) {
423
423
  // update
424
424
  for (let i = this.handlerCache.length - 1; i >= 0; i--) {
@@ -430,6 +430,10 @@ class WebhooksHandler {
430
430
  }
431
431
  }
432
432
  }
433
+
434
+ // mark the cache as current only after a successful refresh, so a failure above
435
+ // leaves the cache stale and the next call retries
436
+ this.handlerCacheV = v;
433
437
  }
434
438
 
435
439
  return this.handlerCache;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "emailengine-app",
3
- "version": "2.68.1",
3
+ "version": "2.70.0",
4
4
  "private": false,
5
5
  "productTitle": "EmailEngine",
6
6
  "description": "Email Sync Engine",
@@ -11,13 +11,13 @@
11
11
  "single": "EE_OPENAPI_VERBOSE=true EENGINE_LOG_RAW=true EENGINE_SECRET=your-encryption-key EENGINE_WORKERS=1 node --inspect server --dbs.redis='redis://127.0.0.1:6379/6' --api.port=7003 --api.host=0.0.0.0 | tee $HOME/ee.log.single.txt | pino-pretty",
12
12
  "gmail": "EE_OPENAPI_VERBOSE=true EENGINE_LOG_RAW=true EENGINE_SECRET=your-encryption-key EENGINE_WORKERS=2 EENGINE_CORS_ORIGIN='*' node --inspect server --dbs.redis='redis://127.0.0.1:6379/11' --api.port=7003 --api.host=0.0.0.0 | tee $HOME/ee.log.gmail.txt | pino-pretty",
13
13
  "test": "NODE_ENV=test grunt",
14
- "lint": "npx eslint lib/**/*.js workers/**/*.js test/**/*.js server.js Gruntfile.js",
14
+ "lint": "npx eslint 'lib/**/*.js' 'workers/**/*.js' 'test/**/*.js' server.js Gruntfile.js",
15
15
  "swagger": "./getswagger.sh",
16
16
  "build-source": "rm -rf node_modules && npm install && rm -rf node_modules && npm ci --omit=dev && rm -rf node_modules/ace-builds node_modules/@postalsys/ee-client && ./update-info.sh",
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 && node gettext-extract.js",
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",
@@ -43,9 +43,8 @@
43
43
  },
44
44
  "homepage": "https://emailengine.app/",
45
45
  "dependencies": {
46
- "@bugsnag/js": "8.9.0",
47
- "@bull-board/api": "7.1.5",
48
- "@bull-board/hapi": "7.1.5",
46
+ "@bull-board/api": "8.0.0",
47
+ "@bull-board/hapi": "8.0.0",
49
48
  "@elastic/elasticsearch": "8.15.3",
50
49
  "@hapi/accept": "6.0.3",
51
50
  "@hapi/bell": "13.1.0",
@@ -64,13 +63,14 @@
64
63
  "@postalsys/gettext": "4.1.1",
65
64
  "@postalsys/joi-messages": "1.0.5",
66
65
  "@postalsys/templates": "2.0.1",
66
+ "@sentry/node": "10.57.0",
67
67
  "@simplewebauthn/browser": "13.3.0",
68
68
  "@simplewebauthn/server": "13.3.1",
69
69
  "@zone-eu/mailsplit": "5.4.12",
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",
@@ -100,7 +100,7 @@
100
100
  "msgpack5": "6.0.2",
101
101
  "murmurhash": "2.0.1",
102
102
  "nanoid": "3.3.8",
103
- "nodemailer": "8.0.10",
103
+ "nodemailer": "8.0.11",
104
104
  "pino": "10.3.1",
105
105
  "popper.js": "1.16.1",
106
106
  "prom-client": "15.1.3",
@@ -118,6 +118,8 @@
118
118
  },
119
119
  "devDependencies": {
120
120
  "@eslint/js": "10.0.1",
121
+ "acorn": "^8.16.0",
122
+ "acorn-walk": "^8.3.5",
121
123
  "chai": "4.3.10",
122
124
  "eerawlog": "1.5.3",
123
125
  "eslint": "10.4.1",
@@ -125,9 +127,8 @@
125
127
  "grunt-cli": "1.5.0",
126
128
  "grunt-shell-spawn": "0.5.0",
127
129
  "grunt-wait": "0.3.0",
128
- "jsxgettext": "0.11.0",
129
130
  "pino-pretty": "13.0.0",
130
- "prettier": "3.8.3",
131
+ "prettier": "3.8.4",
131
132
  "resedit": "3.0.2",
132
133
  "spdx-satisfies": "6.0.0",
133
134
  "supertest": "7.2.2",