emailengine-app 2.71.0 → 2.72.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.
@@ -852,8 +852,10 @@ class OAuth2AppsHandler {
852
852
  return { id, deleted: false, accounts: 0 };
853
853
  }
854
854
 
855
- // Acquire the same lock used by ensurePubsub to prevent racing with recovery
856
- let needsPubSubLock = appData.pubSubTopic || appData.baseScopes === 'pubsub';
855
+ // Acquire the same lock used by ensurePubsub to prevent racing with recovery. Mirror the
856
+ // deletion gate below (pubSubTopic || pubSubSubscription) so an app that only has a
857
+ // subscription recorded is still locked before its resources are deleted.
858
+ let needsPubSubLock = appData.pubSubTopic || appData.pubSubSubscription || appData.baseScopes === 'pubsub';
857
859
  let delLock;
858
860
 
859
861
  if (needsPubSubLock) {
@@ -867,11 +869,21 @@ class OAuth2AppsHandler {
867
869
  logger.error({ msg: 'Failed to acquire lock for pubsub app deletion', lockKey, err });
868
870
  throw err;
869
871
  }
872
+
873
+ // Re-read under the lock so the ownership-flag deletion decision below acts on the
874
+ // current record, not the snapshot taken before the lock - a concurrent ensurePubsub /
875
+ // recovery run may have adopted or created a resource in the meantime. Keep the pre-lock
876
+ // snapshot if the app was deleted while waiting, so best-effort GCP cleanup still runs.
877
+ let lockedData = await this.get(id);
878
+ if (lockedData) {
879
+ appData = lockedData;
880
+ }
870
881
  }
871
882
 
872
883
  try {
873
- if (appData.pubSubTopic) {
874
- // try to delete topic
884
+ if (appData.pubSubTopic || appData.pubSubSubscription) {
885
+ // try to delete the Pub/Sub resources EmailEngine created (deleteTopic skips any
886
+ // resource marked unmanaged, i.e. adopted from a pre-existing GCP setup)
875
887
  try {
876
888
  await this.deleteTopic(appData);
877
889
  } catch (err) {
@@ -1037,8 +1049,16 @@ class OAuth2AppsHandler {
1037
1049
  throw new Error('Failed to get access token');
1038
1050
  }
1039
1051
 
1040
- await this._deletePubSubResource(client, accessToken, appData, 'subscription', appData.pubSubSubscription);
1041
- await this._deletePubSubResource(client, accessToken, appData, 'topic', appData.pubSubTopic);
1052
+ // Only delete resources EmailEngine provisioned. A `false` managed flag means the resource
1053
+ // was adopted from a pre-existing GCP setup and must be left in place. Legacy apps created
1054
+ // before ownership tracking have the flag undefined and are treated as managed (deleted),
1055
+ // preserving the prior behavior.
1056
+ if (appData.pubSubSubscriptionManaged !== false) {
1057
+ await this._deletePubSubResource(client, accessToken, appData, 'subscription', appData.pubSubSubscription);
1058
+ }
1059
+ if (appData.pubSubTopicManaged !== false) {
1060
+ await this._deletePubSubResource(client, accessToken, appData, 'topic', appData.pubSubTopic);
1061
+ }
1042
1062
  }
1043
1063
 
1044
1064
  async ensurePubsub(appData) {
@@ -1093,6 +1113,23 @@ class OAuth2AppsHandler {
1093
1113
  }
1094
1114
 
1095
1115
  try {
1116
+ // Re-read inside the lock so the "persist only if absent" guards below act on the
1117
+ // current state, not the caller's pre-lock snapshot (which can be stale after a
1118
+ // concurrent ensurePubsub run). Falls back to the passed-in data if the app was
1119
+ // deleted while waiting for the lock (preserves the prior proceed-anyway behavior).
1120
+ let current = (await this.get(appData.id)) || appData;
1121
+
1122
+ // Record a marker for a Pub/Sub resource that already existed in GCP (adopted, not
1123
+ // created by us): mark it unmanaged so deletion never removes it, and surface it in
1124
+ // results on first adoption so the create/update route restarts the pull worker. The
1125
+ // !current guard keeps re-runs no-ops (no redundant writes or restarts).
1126
+ const adoptExistingResource = async (key, value) => {
1127
+ if (!current[key]) {
1128
+ await this.update(appData.id, { [key]: value, [`${key}Managed`]: false }, { partial: true });
1129
+ results[key] = value;
1130
+ }
1131
+ };
1132
+
1096
1133
  let client = await this.getClient(appData.id);
1097
1134
 
1098
1135
  // Step 1. Get access token for service client
@@ -1109,6 +1146,9 @@ class OAuth2AppsHandler {
1109
1146
  /*
1110
1147
  {name: 'projects/...'}
1111
1148
  */
1149
+
1150
+ // Adopt a pre-existing topic so the Gmail watch can arm (kept unmanaged - we did not create it).
1151
+ await adoptExistingResource('pubSubTopic', topicName);
1112
1152
  } catch (err) {
1113
1153
  if (TRANSIENT_NETWORK_CODES.has(err.code)) {
1114
1154
  logger.warn({ msg: 'Network error checking Pub/Sub topic', app: appData.id, code: err.code });
@@ -1149,7 +1189,9 @@ class OAuth2AppsHandler {
1149
1189
  await this.update(
1150
1190
  appData.id,
1151
1191
  {
1152
- pubSubTopic: topicName
1192
+ pubSubTopic: topicName,
1193
+ // EmailEngine created this topic, so it may delete it on app removal.
1194
+ pubSubTopicManaged: true
1153
1195
  },
1154
1196
  { partial: true }
1155
1197
  );
@@ -1169,8 +1211,9 @@ class OAuth2AppsHandler {
1169
1211
  });
1170
1212
  throw err;
1171
1213
  case 409:
1172
- // already exists
1214
+ // already exists (raced) - adopt it; mark unmanaged to be safe against deletion.
1173
1215
  logger.info({ msg: 'Topic already exists', app: appData.id, topic: topicName });
1216
+ await adoptExistingResource('pubSubTopic', topicName);
1174
1217
  break;
1175
1218
  default:
1176
1219
  throw err;
@@ -1193,12 +1236,44 @@ class OAuth2AppsHandler {
1193
1236
  {name: 'projects/...'}
1194
1237
  */
1195
1238
 
1239
+ // Adopt a pre-existing subscription so the pull worker recognizes it (also stops the
1240
+ // recovery loop in lib/oauth/pubsub/google.js); kept unmanaged - we did not create it.
1241
+ await adoptExistingResource('pubSubSubscription', subscriptionName);
1242
+
1196
1243
  // Patch existing subscription's expiration policy if it differs from desired
1197
1244
  // When no explicit TTL is configured, use Google's default (31 days) for comparison
1198
1245
  let effectivePolicy = desiredExpirationPolicy ?? { ttl: GMAIL_PUBSUB_DEFAULT_EXPIRATION_TTL };
1199
1246
  let currentTtl = subscriptionData?.expirationPolicy?.ttl || null;
1200
1247
  let desiredTtl = effectivePolicy.ttl || null;
1201
- if (currentTtl !== desiredTtl) {
1248
+
1249
+ // Adopted (pre-existing, unmanaged) subscriptions belong to the customer - never
1250
+ // rewrite their lifecycle policy. A subscription is ours to manage only when it is
1251
+ // already recorded as managed (true) or predates ownership tracking (undefined);
1252
+ // a freshly adopted one (marker absent in the pre-lock snapshot) or an explicit
1253
+ // `false` flag is hands-off.
1254
+ let subscriptionManagedByUs = !!current.pubSubSubscription && current.pubSubSubscriptionManaged !== false;
1255
+
1256
+ if (!subscriptionManagedByUs && desiredExpirationPolicy !== null) {
1257
+ // Do not mutate the customer's subscription, but warn when an explicitly
1258
+ // configured expiration is longer than theirs: the subscription may expire on
1259
+ // its own and silently disable Gmail push.
1260
+ let toSeconds = ttl => {
1261
+ let n = ttl ? parseInt(ttl, 10) : NaN;
1262
+ return Number.isFinite(n) ? n : null;
1263
+ };
1264
+ let currentSeconds = toSeconds(currentTtl);
1265
+ let desiredSeconds = toSeconds(desiredTtl);
1266
+ // desiredSeconds === null means EmailEngine wants an indefinite subscription
1267
+ if (currentSeconds !== null && (desiredSeconds === null || currentSeconds < desiredSeconds)) {
1268
+ logger.warn({
1269
+ msg: 'Adopted Pub/Sub subscription expires sooner than the configured expiration; leaving it unchanged',
1270
+ app: appData.id,
1271
+ subscription: subscriptionName,
1272
+ currentTtl,
1273
+ desiredTtl
1274
+ });
1275
+ }
1276
+ } else if (subscriptionManagedByUs && currentTtl !== desiredTtl) {
1202
1277
  try {
1203
1278
  await client.request(accessToken, subscriptionUrl, 'PATCH', {
1204
1279
  subscription: { expirationPolicy: effectivePolicy },
@@ -1271,7 +1346,9 @@ class OAuth2AppsHandler {
1271
1346
  await this.update(
1272
1347
  appData.id,
1273
1348
  {
1274
- pubSubSubscription: subscriptionName
1349
+ pubSubSubscription: subscriptionName,
1350
+ // EmailEngine created this subscription, so it may delete it on app removal.
1351
+ pubSubSubscriptionManaged: true
1275
1352
  },
1276
1353
  { partial: true }
1277
1354
  );
@@ -1296,8 +1373,9 @@ class OAuth2AppsHandler {
1296
1373
  });
1297
1374
  throw err;
1298
1375
  case 409:
1299
- // already exists
1376
+ // already exists (raced) - adopt it; mark unmanaged to be safe against deletion.
1300
1377
  logger.info({ msg: 'Subscription already exists', app: appData.id, subscription: subscriptionName });
1378
+ await adoptExistingResource('pubSubSubscription', subscriptionName);
1301
1379
  break;
1302
1380
  default:
1303
1381
  throw err;
@@ -1350,6 +1428,17 @@ class OAuth2AppsHandler {
1350
1428
 
1351
1429
  if (existingPolicy) {
1352
1430
  logger.debug({ msg: 'Gmail publisher policy already exists', app: appData.id, topic: topicName });
1431
+
1432
+ // The required publisher binding is already in place (granted manually or by a
1433
+ // prior run). Record the marker so the Gmail watch can arm - it gates on this being
1434
+ // truthy. Store the same {members, role} object shape the create path uses. No
1435
+ // ownership flag: this marker has no deletion side effect (only read at the watch
1436
+ // gate). Only populate results (and write) on first adoption, so a re-run is a no-op
1437
+ // and does not trigger a redundant pull-worker restart.
1438
+ if (!current.pubSubIamPolicy) {
1439
+ results.iamPolicy = { members: [member], role };
1440
+ await this.update(appData.id, { pubSubIamPolicy: results.iamPolicy }, { partial: true });
1441
+ }
1353
1442
  } else {
1354
1443
  logger.debug({ msg: 'Granting access to Gmail publisher', app: appData.id, topic: topicName });
1355
1444
  let existingBindings = (getIamPolicyRes && getIamPolicyRes.bindings) || [];
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+
3
+ // SMTP submission-server authentication handler. Extracted from workers/smtp.js
4
+ // so the auth logic can be unit tested without booting the worker thread (which
5
+ // connects to a parentPort and starts listening at require time).
6
+
7
+ const logger = require('./logger');
8
+ const settings = require('./settings');
9
+ const { redis } = require('./db');
10
+ const { Account } = require('./account');
11
+ const getSecret = require('./get-secret');
12
+ const { validateAuthToken, REASON_MESSAGES } = require('./auth-token');
13
+
14
+ /**
15
+ * Builds the SMTP server onAuth handler.
16
+ *
17
+ * @param {Object} deps
18
+ * @param {WeakMap} deps.accountCache - session -> Account cache shared with the worker
19
+ * @param {Function} deps.call - RPC function passed to the Account instance
20
+ * @returns {Function} async onAuth(auth, session)
21
+ */
22
+ function createSmtpAuthHandler({ accountCache, call }) {
23
+ return async function onAuth(auth, session) {
24
+ if (!session.eeAuthEnabled) {
25
+ throw new Error('Authentication not enabled');
26
+ }
27
+
28
+ let account = auth.username;
29
+
30
+ let smtpPassword = await settings.get('smtpServerPassword');
31
+ if (!smtpPassword || auth.password !== smtpPassword) {
32
+ // fall back to API token authentication
33
+ let result = await validateAuthToken({
34
+ password: auth.password,
35
+ account: auth.username,
36
+ requiredScope: 'smtp',
37
+ remoteAddress: session.remoteAddress
38
+ });
39
+
40
+ if (!result.authenticated) {
41
+ throw new Error(REASON_MESSAGES[result.reason] || 'Failed to authenticate user');
42
+ }
43
+ }
44
+
45
+ let accountObject = new Account({ account, redis, call, secret: await getSecret() });
46
+ let accountData;
47
+ try {
48
+ accountData = await accountObject.loadAccountData();
49
+ } catch (err) {
50
+ let respErr = new Error('Failed to authenticate user');
51
+
52
+ if (!err.output || err.output.statusCode !== 404) {
53
+ // only log non-obvious errors
54
+ logger.error({ msg: 'Failed to load account data', account: auth.username, err });
55
+ respErr.statusCode = 454;
56
+ }
57
+
58
+ throw respErr;
59
+ }
60
+
61
+ if (!accountData) {
62
+ throw new Error('Failed to authenticate user');
63
+ }
64
+
65
+ accountCache.set(session, accountObject);
66
+ return { user: accountData.account };
67
+ };
68
+ }
69
+
70
+ module.exports = { createSmtpAuthHandler };
package/lib/sub-script.js CHANGED
@@ -67,11 +67,17 @@ class SubScript {
67
67
  }
68
68
  }
69
69
 
70
- async exec(payload) {
70
+ async exec(payload, options) {
71
71
  if (!this.fn) {
72
72
  throw new Error('Subscript not compiled');
73
73
  }
74
74
 
75
+ // Allow callers (and tests) to shorten the execution timeout, but never
76
+ // raise it above SUBSCRIPT_RUNTIME_TIMEOUT - the sandbox cap is a hard
77
+ // ceiling. A falsy override (including 0, which vm treats as "no limit")
78
+ // falls back to the default so the cap can never be disabled.
79
+ let runtimeTimeout = options && options.timeout ? Math.min(options.timeout, SUBSCRIPT_RUNTIME_TIMEOUT) : SUBSCRIPT_RUNTIME_TIMEOUT;
80
+
75
81
  let start = Date.now();
76
82
 
77
83
  try {
@@ -93,7 +99,7 @@ class SubScript {
93
99
 
94
100
  vm.createContext(ctx);
95
101
  let result = await this.fn.runInContext(ctx, {
96
- timeout: SUBSCRIPT_RUNTIME_TIMEOUT,
102
+ timeout: runtimeTimeout,
97
103
  microtaskMode: 'afterEvaluate'
98
104
  });
99
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "emailengine-app",
3
- "version": "2.71.0",
3
+ "version": "2.72.0",
4
4
  "private": false,
5
5
  "productTitle": "EmailEngine",
6
6
  "description": "Email Sync Engine",
@@ -13,6 +13,8 @@
13
13
  "test": "NODE_ENV=test grunt",
14
14
  "test:unit": "NODE_ENV=test grunt test-unit",
15
15
  "test:integration": "NODE_ENV=test grunt test-integration",
16
+ "test:e2e": "playwright test",
17
+ "test:e2e:install": "playwright install --with-deps chromium",
16
18
  "lint": "npx eslint 'lib/**/*.js' 'workers/**/*.js' 'test/**/*.js' server.js Gruntfile.js",
17
19
  "swagger": "./getswagger.sh",
18
20
  "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",
@@ -45,8 +47,8 @@
45
47
  },
46
48
  "homepage": "https://emailengine.app/",
47
49
  "dependencies": {
48
- "@bull-board/api": "8.0.0",
49
- "@bull-board/hapi": "8.0.0",
50
+ "@bull-board/api": "8.0.1",
51
+ "@bull-board/hapi": "8.0.1",
50
52
  "@elastic/elasticsearch": "8.15.3",
51
53
  "@hapi/accept": "6.0.3",
52
54
  "@hapi/bell": "13.1.0",
@@ -54,7 +56,7 @@
54
56
  "@hapi/cookie": "12.0.1",
55
57
  "@hapi/crumb": "9.0.1",
56
58
  "@hapi/hapi": "21.4.9",
57
- "@hapi/inert": "7.1.1",
59
+ "@hapi/inert": "7.1.2",
58
60
  "@hapi/vision": "7.0.3",
59
61
  "@phc/pbkdf2": "1.1.14",
60
62
  "@postalsys/bounce-classifier": "3.0.0",
@@ -72,7 +74,7 @@
72
74
  "@zone-eu/wild-config": "1.7.5",
73
75
  "ace-builds": "1.44.0",
74
76
  "base32.js": "0.1.0",
75
- "bullmq": "5.78.1",
77
+ "bullmq": "5.79.0",
76
78
  "compare-versions": "6.1.1",
77
79
  "dotenv": "17.4.2",
78
80
  "encoding-japanese": "2.2.0",
@@ -97,12 +99,12 @@
97
99
  "libqp": "2.1.1",
98
100
  "license-checker": "25.0.1",
99
101
  "mailparser": "3.9.10",
100
- "marked": "9.1.6",
102
+ "marked": "15.0.12",
101
103
  "minimist": "1.2.8",
102
104
  "msgpack5": "6.0.2",
103
105
  "murmurhash": "2.0.1",
104
- "nanoid": "3.3.8",
105
- "nodemailer": "9.0.0",
106
+ "nanoid": "3.3.13",
107
+ "nodemailer": "9.0.1",
106
108
  "pino": "10.3.1",
107
109
  "popper.js": "1.16.1",
108
110
  "prom-client": "15.1.3",
@@ -115,20 +117,21 @@
115
117
  "speakeasy": "2.0.0",
116
118
  "startbootstrap-sb-admin-2": "3.3.7",
117
119
  "timezones-list": "3.1.0",
118
- "undici": "7.25.0",
120
+ "undici": "7.28.0",
119
121
  "xml2js": "0.6.2"
120
122
  },
121
123
  "devDependencies": {
122
124
  "@eslint/js": "10.0.1",
125
+ "@playwright/test": "1.61.0",
123
126
  "acorn": "8.17.0",
124
127
  "acorn-walk": "8.3.5",
125
- "chai": "4.3.10",
128
+ "chai": "4.5.0",
126
129
  "eerawlog": "1.5.3",
127
130
  "eslint": "10.5.0",
128
131
  "grunt": "1.6.2",
129
132
  "grunt-cli": "1.5.0",
130
133
  "grunt-shell-spawn": "0.5.0",
131
- "pino-pretty": "13.0.0",
134
+ "pino-pretty": "13.1.3",
132
135
  "prettier": "3.8.4",
133
136
  "resedit": "3.0.2",
134
137
  "spdx-satisfies": "6.0.0",
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ // Playwright config for the EmailEngine happy-path e2e suite (test/e2e). Boots a fresh
4
+ // EmailEngine via the webServer command (flush the isolated Redis db 14, then `node server.js`
5
+ // with NODE_ENV=e2e -> config/e2e.toml) and drives it with a real browser.
6
+ //
7
+ // Run once: npm run test:e2e:install (fetch the Chromium browser)
8
+ // Run suite: npm run test:e2e
9
+
10
+ const { defineConfig, devices } = require('@playwright/test');
11
+
12
+ const PORT = 7099;
13
+ const BASE_URL = `http://127.0.0.1:${PORT}`;
14
+
15
+ module.exports = defineConfig({
16
+ testDir: './test/e2e',
17
+ testMatch: '**/*.spec.js',
18
+ // One worker against one app + one database keeps the fresh-instance flow predictable.
19
+ fullyParallel: false,
20
+ workers: 1,
21
+ forbidOnly: !!process.env.CI,
22
+ retries: process.env.CI ? 1 : 0,
23
+ // The single happy-path test chains several slow external steps (Ethereal provisioning,
24
+ // trial activation against postalsys.com, IMAP connect, message read-back).
25
+ timeout: 300000,
26
+ reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
27
+ use: {
28
+ baseURL: BASE_URL,
29
+ trace: 'on-first-retry'
30
+ },
31
+ projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
32
+ webServer: {
33
+ // Flush the isolated e2e Redis DB so every run starts from a clean, unconfigured instance.
34
+ command: 'node test/e2e/flush-redis.js && node server.js',
35
+ url: BASE_URL,
36
+ timeout: 120000,
37
+ // Always boot fresh: the suite asserts fresh-instance behaviour (enabling auth, activating
38
+ // the trial), so a reused server with those already set would break it. The dedicated
39
+ // db 14 makes the pre-boot flush safe.
40
+ reuseExistingServer: false,
41
+ stdout: 'pipe',
42
+ stderr: 'pipe',
43
+ env: Object.assign({}, process.env, { NODE_ENV: 'e2e' })
44
+ }
45
+ });