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
@@ -23,7 +23,6 @@ const {
23
23
  MESSAGE_DELETED_NOTIFY,
24
24
  MESSAGE_UPDATED_NOTIFY,
25
25
  MESSAGE_MISSING_NOTIFY,
26
- MAILBOX_RESET_NOTIFY,
27
26
  MAILBOX_NEW_NOTIFY,
28
27
  EMAIL_BOUNCE_NOTIFY,
29
28
  EMAIL_COMPLAINT_NOTIFY,
@@ -41,6 +40,7 @@ const {
41
40
  canUseCondstorePartialSync,
42
41
  canUseSimplePartialSync,
43
42
  canSkipSync,
43
+ shouldSeedLostIndex,
44
44
  FULL_SYNC_DELAY
45
45
  } = require('./sync-operations');
46
46
 
@@ -154,6 +154,8 @@ class Mailbox {
154
154
  let data = await this.connection.redis.hgetall(this.getMailboxKey());
155
155
  data = data || {};
156
156
 
157
+ let hasStoredState = Object.keys(data).length > 0;
158
+
157
159
  // Log diagnostic info if stored uidValidity is invalid or missing
158
160
  if (!validUidValidity(data.uidValidity)) {
159
161
  this.logger.warn({
@@ -162,12 +164,16 @@ class Mailbox {
162
164
  redisKey: this.getMailboxKey(),
163
165
  rawUidValidity: data.uidValidity,
164
166
  rawUidValidityType: typeof data.uidValidity,
165
- hasData: Object.keys(data).length > 0,
167
+ hasData: hasStoredState,
166
168
  storedKeys: Object.keys(data)
167
169
  });
168
170
  }
169
171
 
170
172
  return {
173
+ // True when the mailbox hash held any fields at all. Redis eviction removes
174
+ // whole keys, so this distinguishes "state lost" from "individual fields
175
+ // never persisted" (e.g. uidNext on servers that omit UIDNEXT from SELECT)
176
+ hasStoredState,
171
177
  path: data.path || this.path,
172
178
  uidValidity: validUidValidity(data.uidValidity) ? BigInt(data.uidValidity) : false,
173
179
  highestModseq: data.highestModseq && !isNaN(data.highestModseq) ? BigInt(data.highestModseq) : false,
@@ -1934,6 +1940,16 @@ class Mailbox {
1934
1940
  return this.syncOps.partialSync(storedStatus);
1935
1941
  }
1936
1942
 
1943
+ /**
1944
+ * Silently rebuilds the message index after lost or invalidated sync state
1945
+ * Delegates to SyncOperations
1946
+ * @param {Object} mailboxStatus - Current mailbox status from IMAP
1947
+ * @param {Object} [options] - Reseed options (reason, prevUidValidity)
1948
+ */
1949
+ async seedMailboxIndex(mailboxStatus, options) {
1950
+ return this.syncOps.seedMailboxIndex(mailboxStatus, options);
1951
+ }
1952
+
1937
1953
  /**
1938
1954
  * Processes queued notification events after sync
1939
1955
  * Fetches full message details and sends notifications
@@ -2060,6 +2076,15 @@ class Mailbox {
2060
2076
  try {
2061
2077
  let storedStatus = await this.getStoredStatus();
2062
2078
 
2079
+ // Lost-index recovery: the account has synced in a previous session but this folder
2080
+ // has no stored state at all (e.g. Redis evicted the whole hash) while the server
2081
+ // still has messages. Rebuild the baseline silently instead of replaying every
2082
+ // message as a new email.
2083
+ if (shouldSeedLostIndex(storedStatus, mailboxStatus, this.previouslyConnected)) {
2084
+ await this.seedMailboxIndex(mailboxStatus, { reason: 'syncStateLost' });
2085
+ return false;
2086
+ }
2087
+
2063
2088
  // Store initial UID on first sync
2064
2089
  if (storedStatus.uidNext === false && typeof mailboxStatus.uidNext === 'number') {
2065
2090
  // update first UID
@@ -2088,19 +2113,17 @@ class Mailbox {
2088
2113
  });
2089
2114
 
2090
2115
  this.logger.debug({ msg: 'Mailbox reset', path: this.listingEntry.path });
2091
- await this.connection.notify(this, MAILBOX_RESET_NOTIFY, {
2092
- path: this.listingEntry.path,
2093
- name: this.listingEntry.name,
2094
- specialUse: this.listingEntry.specialUse || false,
2095
- uidValidity: validUidValidity(mailboxStatus.uidValidity) ? mailboxStatus.uidValidity.toString() : false,
2096
- prevUidValidity: validUidValidity(storedStatus.uidValidity) ? storedStatus.uidValidity.toString() : false
2097
- });
2098
2116
 
2099
2117
  // do not advertise messages as new
2100
2118
  this.listingEntry.isNew = true;
2101
2119
 
2102
- // generates blank stored status as the Redis key was deleted
2103
- storedStatus = await this.getStoredStatus();
2120
+ // Rebuild the index silently from the recreated mailbox and emit a single
2121
+ // mailboxReset (with prev/current UIDVALIDITY) instead of replaying every message.
2122
+ await this.seedMailboxIndex(mailboxStatus, {
2123
+ reason: 'uidValidityChange',
2124
+ prevUidValidity: validUidValidity(storedStatus.uidValidity) ? storedStatus.uidValidity.toString() : false
2125
+ });
2126
+ return false;
2104
2127
  }
2105
2128
 
2106
2129
  // Determine sync strategy using helper functions
@@ -133,7 +133,6 @@ class Subconnection extends EventEmitter {
133
133
  if (prevImapClient === this.imapClient) {
134
134
  this.imapClient = null;
135
135
  }
136
- prevImapClient = null;
137
136
  }
138
137
  }
139
138
 
@@ -210,24 +209,32 @@ class Subconnection extends EventEmitter {
210
209
  let response = await imapClient.connect();
211
210
 
212
211
  // Process untagged EXISTS responses
213
- imapClient.on('exists', async event => {
214
- if (!event || !event.path) {
215
- return; //?
216
- }
212
+ imapClient.on('exists', event => {
213
+ try {
214
+ if (!event || !event.path) {
215
+ return; //?
216
+ }
217
217
 
218
- this.logger.info({ msg: 'Exists notification', account: this.account, event });
218
+ this.logger.info({ msg: 'Exists notification', account: this.account, event });
219
219
 
220
- this.requestSync();
220
+ this.requestSync();
221
+ } catch (err) {
222
+ this.logger.error({ msg: 'Exists notification handling failed', account: this.account, err });
223
+ }
221
224
  });
222
225
 
223
- imapClient.on('flags', async event => {
224
- if (!event || !event.path) {
225
- return; //?
226
- }
226
+ imapClient.on('flags', event => {
227
+ try {
228
+ if (!event || !event.path) {
229
+ return; //?
230
+ }
227
231
 
228
- this.logger.info({ msg: 'Flags notification', account: this.account, event });
232
+ this.logger.info({ msg: 'Flags notification', account: this.account, event });
229
233
 
230
- this.requestSync();
234
+ this.requestSync();
235
+ } catch (err) {
236
+ this.logger.error({ msg: 'Flags notification handling failed', account: this.account, err });
237
+ }
231
238
  });
232
239
 
233
240
  return response;
@@ -1,9 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  const crypto = require('crypto');
4
- const { compareExisting, calculateFetchBackoff, readEnvValue } = require('../../tools');
4
+ const { compareExisting, calculateFetchBackoff, readEnvValue, validUidValidity } = require('../../tools');
5
5
  const config = require('@zone-eu/wild-config');
6
- const { DEFAULT_FETCH_BATCH_SIZE, FETCH_RETRY_MAX_TIME, FETCH_RETRY_MIN_ATTEMPTS } = require('../../consts');
6
+ const { DEFAULT_FETCH_BATCH_SIZE, FETCH_RETRY_MAX_TIME, FETCH_RETRY_MIN_ATTEMPTS, MAILBOX_RESET_NOTIFY } = require('../../consts');
7
7
 
8
8
  // Configurable batch size for fetching messages (default: 250)
9
9
  const FETCH_BATCH_SIZE = Number(readEnvValue('EENGINE_FETCH_BATCH_SIZE') || config.service.fetchBatchSize) || DEFAULT_FETCH_BATCH_SIZE;
@@ -111,6 +111,32 @@ function canSkipSync(storedStatus, mailboxStatus) {
111
111
  return storedStatus.messages === mailboxStatus.messages && storedStatus.uidNext === mailboxStatus.uidNext && isRecentFullSync(storedStatus);
112
112
  }
113
113
 
114
+ /**
115
+ * Determines whether a folder with no stored sync state should be rebuilt SILENTLY
116
+ * (recording existing messages without emitting messageNew) instead of replayed as new mail.
117
+ *
118
+ * This is the "lost index recovery" case: the account has already completed a previous
119
+ * connected session, yet this folder's mailbox hash is gone entirely while the server
120
+ * still reports messages. That mismatch means EmailEngine's per-mailbox state was lost
121
+ * (e.g. Redis evicted it) - replaying those already-synced messages as new would flood
122
+ * the webhook queue. A genuine first sync keeps `previouslyConnected <= 1` and is left
123
+ * to the normal notifyFrom-bounded path so intentional backfills still work.
124
+ *
125
+ * The decision keys on `hasStoredState` (did the hash hold ANY field), not on individual
126
+ * fields: Redis eviction removes whole keys, never single hash fields, while individual
127
+ * fields can legitimately stay absent forever - most notably `uidNext` on servers that
128
+ * omit UIDNEXT from the SELECT response (updateStoredStatus skips persisting falsy
129
+ * values), which must not re-trigger the reseed on every open.
130
+ *
131
+ * @param {Object} storedStatus - Stored mailbox status from getStoredStatus()
132
+ * @param {Object} mailboxStatus - Current mailbox status from IMAP
133
+ * @param {Number} previouslyConnected - Account-level connected-session counter (`state:count:connected`)
134
+ * @returns {Boolean} True if the index should be silently reseeded
135
+ */
136
+ function shouldSeedLostIndex(storedStatus, mailboxStatus, previouslyConnected) {
137
+ return Number(previouslyConnected) > 1 && !(storedStatus && storedStatus.hasStoredState) && Number(mailboxStatus.messages) > 0;
138
+ }
139
+
114
140
  /**
115
141
  * Handles the synchronization of IMAP mailboxes
116
142
  */
@@ -192,6 +218,107 @@ class SyncOperations {
192
218
  return { type: 'full', reason: 'changes_detected' };
193
219
  }
194
220
 
221
+ /**
222
+ * Rebuilds the mailbox baseline from the server's current state WITHOUT emitting
223
+ * messageNew notifications. Used when the stored sync state was lost (e.g. Redis evicted
224
+ * it) or invalidated (UIDVALIDITY change), then emits a single mailboxReset event so
225
+ * integrators can reconcile.
226
+ *
227
+ * Full indexer mode records every message on the server in the message index so only
228
+ * mail arriving afterwards is advertised as new. Fast indexer mode never maintains that
229
+ * index - the only baseline it needs is the stored uidNext that runFastSync compares
230
+ * against, so no per-message fetch is performed.
231
+ *
232
+ * @param {Object} mailboxStatus - Current mailbox status from IMAP
233
+ * @param {Object} [options]
234
+ * @param {String} [options.reason] - Why the reseed happened (included in the mailboxReset payload and logs)
235
+ * @param {String|Boolean} [options.prevUidValidity] - Previous UIDVALIDITY, when reseeding after a UIDVALIDITY change
236
+ * @returns {Number} Count of indexed messages (always 0 in fast indexer mode)
237
+ */
238
+ async seedMailboxIndex(mailboxStatus, options) {
239
+ options = options || {};
240
+ let reason = options.reason || 'syncStateLost';
241
+
242
+ let imapClient = this.connection.imapClient;
243
+ if (!imapClient) {
244
+ let err = new Error('IMAP connection not available');
245
+ err.code = 'IMAPConnectionClosing';
246
+ err.statusCode = 503;
247
+ throw err;
248
+ }
249
+
250
+ let imapIndexer = this.mailbox.imapIndexer;
251
+
252
+ let lock = await this.mailbox.getMailboxLock(null, { description: 'Seed mailbox index' });
253
+ this.connection.syncing = true;
254
+ this.mailbox.syncing = true;
255
+ try {
256
+ // Drop any stale queued notifications - we are establishing a fresh baseline
257
+ await this.connection.redis.del(this.mailbox.getNotificationsKey());
258
+
259
+ let indexed = 0;
260
+
261
+ if (imapIndexer === 'fast') {
262
+ // Fast mode detects new messages by comparing UIDs against the stored uidNext
263
+ // (hUpdateBigger in runFastSync), so that value is the baseline to establish.
264
+ // updateStoredStatus below persists it; when the server omits UIDNEXT from the
265
+ // SELECT response, derive the baseline from the highest UID on the server -
266
+ // otherwise the next runFastSync would replay every message as messageNew.
267
+ if (!mailboxStatus.uidNext && Number(mailboxStatus.messages) > 0) {
268
+ let lastMessage = await imapClient.fetchOne('*', { uid: true });
269
+ if (lastMessage && lastMessage.uid) {
270
+ mailboxStatus = Object.assign({}, mailboxStatus, { uidNext: lastMessage.uid + 1 });
271
+ }
272
+ }
273
+ } else {
274
+ let fields = { uid: true, flags: true, modseq: true, emailId: true, labels: true, internalDate: true };
275
+
276
+ // Record every message currently on the server as already-seen, without queuing notifications
277
+ for await (let messageData of imapClient.fetch('1:*', fields, { uid: true })) {
278
+ if (!messageData || !messageData.uid) {
279
+ continue;
280
+ }
281
+ // ignore Recent flag
282
+ messageData.flags.delete('\\Recent');
283
+ await this.mailbox.entryListSet(messageData);
284
+ indexed++;
285
+ }
286
+ }
287
+
288
+ // Persist the current server state as the new baseline. updateStoredStatus also sets
289
+ // initialUidNext (via hSetNew) so future arrivals are detected normally.
290
+ await this.mailbox.updateStoredStatus(mailboxStatus);
291
+
292
+ this.logger.warn({
293
+ msg: 'Rebuilt mailbox baseline without notifications',
294
+ action: 'lost_index_recovery',
295
+ path: this.mailbox.path,
296
+ imapIndexer,
297
+ messages: imapIndexer === 'fast' ? Number(mailboxStatus.messages) || 0 : indexed,
298
+ reason
299
+ });
300
+
301
+ // Inform integrators that this folder was reset so they can reconcile if needed
302
+ let resetPayload = {
303
+ path: this.mailbox.listingEntry.path,
304
+ name: this.mailbox.listingEntry.name,
305
+ specialUse: this.mailbox.listingEntry.specialUse || false,
306
+ uidValidity: validUidValidity(mailboxStatus.uidValidity) ? mailboxStatus.uidValidity.toString() : false,
307
+ reason
308
+ };
309
+ if (typeof options.prevUidValidity !== 'undefined') {
310
+ resetPayload.prevUidValidity = options.prevUidValidity;
311
+ }
312
+ await this.connection.notify(this.mailbox, MAILBOX_RESET_NOTIFY, resetPayload);
313
+
314
+ return indexed;
315
+ } finally {
316
+ lock.release();
317
+ this.connection.syncing = false;
318
+ this.mailbox.syncing = false;
319
+ }
320
+ }
321
+
195
322
  /**
196
323
  * Fast sync mode - only tracks new messages, doesn't maintain full message list
197
324
  * More efficient for large mailboxes where we only care about new messages
@@ -782,7 +909,7 @@ class SyncOperations {
782
909
  currentPath: currentMailbox ? currentMailbox.path : 'none',
783
910
  loopId
784
911
  });
785
- throw new Error('Mailbox changed during sync operation');
912
+ throw new Error('Mailbox changed during sync operation', { cause: err });
786
913
  }
787
914
 
788
915
  // Refresh mailbox status in case it changed
@@ -856,6 +983,7 @@ module.exports = {
856
983
  canUseCondstorePartialSync,
857
984
  canUseSimplePartialSync,
858
985
  canSkipSync,
986
+ shouldSeedLostIndex,
859
987
  FETCH_BATCH_SIZE,
860
988
  FULL_SYNC_DELAY
861
989
  };
@@ -790,69 +790,116 @@ class IMAPClient extends BaseClient {
790
790
  this.main = mainList.sort((a, b) => a.index - b.index)[0].entry;
791
791
  }
792
792
 
793
- // Set up event handlers for IMAP notifications
793
+ // Set up event handlers for IMAP notifications. Each listener delegates to a
794
+ // method that wraps the whole body (including normalizePath/lookup) in
795
+ // try/catch, so a malformed event can never become an unhandled rejection
796
+ // that kills the worker.
794
797
 
795
798
  // Process untagged EXISTS responses (new messages)
796
- imapClient.on('exists', async event => {
797
- if (!event || !event.path || !this.mailboxes.has(normalizePath(event.path))) {
798
- return; //?
799
- }
800
-
801
- let mailbox = this.mailboxes.get(normalizePath(event.path));
802
- try {
803
- await mailbox.onExists(event);
804
- } catch (err) {
805
- imapClient.log.error({ msg: 'Exists error', err });
806
- }
807
- });
799
+ imapClient.on('exists', event => this.handleExistsEvent(imapClient, event));
808
800
 
809
801
  // Handle mailbox open events
810
- imapClient.on('mailboxOpen', async event => {
811
- if (!event || !event.path || !this.mailboxes.has(normalizePath(event.path))) {
812
- return; //?
813
- }
814
-
815
- let mailbox = this.mailboxes.get(normalizePath(event.path));
816
- try {
817
- await mailbox.onOpen(event);
818
- } catch (err) {
819
- if (err.code === 'IMAPConnectionClosing') {
820
- imapClient.log.debug({ msg: 'Open skipped, connection closing', err });
821
- } else {
822
- imapClient.log.error({ msg: 'Open error', err });
823
- }
824
- }
825
- });
802
+ imapClient.on('mailboxOpen', event => this.handleMailboxOpenEvent(imapClient, event));
826
803
 
827
804
  // Handle mailbox close events
828
- imapClient.on('mailboxClose', async event => {
829
- if (!event || !event.path || !this.mailboxes.has(normalizePath(event.path))) {
830
- return; //?
831
- }
805
+ imapClient.on('mailboxClose', event => this.handleMailboxCloseEvent(imapClient, event));
832
806
 
833
- let mailbox = this.mailboxes.get(normalizePath(event.path));
834
- try {
835
- await mailbox.onClose(event);
836
- } catch (err) {
837
- imapClient.log.error({ msg: 'Close error', err });
807
+ // Handle flag changes
808
+ imapClient.on('flags', event => this.handleFlagsEvent(imapClient, event));
809
+
810
+ return response;
811
+ }
812
+
813
+ /**
814
+ * Resolves the tracked Mailbox for an IMAP event, or null when the event has no
815
+ * tracked path. May throw on a malformed path - callers must run it inside a
816
+ * try/catch (see the handle*Event methods below).
817
+ * @param {Object} event - IMAP event with a `path` property
818
+ * @returns {Mailbox|null} Tracked mailbox or null
819
+ */
820
+ resolveEventMailbox(event) {
821
+ if (!event || !event.path) {
822
+ return null;
823
+ }
824
+
825
+ let path = normalizePath(event.path);
826
+ if (!this.mailboxes.has(path)) {
827
+ return null;
828
+ }
829
+
830
+ return this.mailboxes.get(path);
831
+ }
832
+
833
+ /**
834
+ * Handles an untagged EXISTS notification (new messages in a mailbox)
835
+ * @param {Object} imapClient - IMAP connection that emitted the event
836
+ * @param {Object} event - Event payload
837
+ */
838
+ async handleExistsEvent(imapClient, event) {
839
+ try {
840
+ let mailbox = this.resolveEventMailbox(event);
841
+ if (!mailbox) {
842
+ return;
838
843
  }
839
- });
844
+ await mailbox.onExists(event);
845
+ } catch (err) {
846
+ imapClient.log.error({ msg: 'Exists error', err });
847
+ }
848
+ }
840
849
 
841
- // Handle flag changes
842
- imapClient.on('flags', async event => {
843
- if (!event || !event.path || !this.mailboxes.has(normalizePath(event.path))) {
844
- return; //?
850
+ /**
851
+ * Handles a mailbox open notification
852
+ * @param {Object} imapClient - IMAP connection that emitted the event
853
+ * @param {Object} event - Event payload
854
+ */
855
+ async handleMailboxOpenEvent(imapClient, event) {
856
+ try {
857
+ let mailbox = this.resolveEventMailbox(event);
858
+ if (!mailbox) {
859
+ return;
860
+ }
861
+ await mailbox.onOpen(event);
862
+ } catch (err) {
863
+ if (err.code === 'IMAPConnectionClosing') {
864
+ imapClient.log.debug({ msg: 'Open skipped, connection closing', err });
865
+ } else {
866
+ imapClient.log.error({ msg: 'Open error', err });
845
867
  }
868
+ }
869
+ }
846
870
 
847
- let mailbox = this.mailboxes.get(normalizePath(event.path));
848
- try {
849
- await mailbox.onFlags(event);
850
- } catch (err) {
851
- imapClient.log.error({ msg: 'Flags error', err });
871
+ /**
872
+ * Handles a mailbox close notification
873
+ * @param {Object} imapClient - IMAP connection that emitted the event
874
+ * @param {Object} event - Event payload
875
+ */
876
+ async handleMailboxCloseEvent(imapClient, event) {
877
+ try {
878
+ let mailbox = this.resolveEventMailbox(event);
879
+ if (!mailbox) {
880
+ return;
852
881
  }
853
- });
882
+ await mailbox.onClose(event);
883
+ } catch (err) {
884
+ imapClient.log.error({ msg: 'Close error', err });
885
+ }
886
+ }
854
887
 
855
- return response;
888
+ /**
889
+ * Handles a flag change notification
890
+ * @param {Object} imapClient - IMAP connection that emitted the event
891
+ * @param {Object} event - Event payload
892
+ */
893
+ async handleFlagsEvent(imapClient, event) {
894
+ try {
895
+ let mailbox = this.resolveEventMailbox(event);
896
+ if (!mailbox) {
897
+ return;
898
+ }
899
+ await mailbox.onFlags(event);
900
+ } catch (err) {
901
+ imapClient.log.error({ msg: 'Flags error', err });
902
+ }
856
903
  }
857
904
 
858
905
  /**
@@ -1599,6 +1646,12 @@ class IMAPClient extends BaseClient {
1599
1646
  } finally {
1600
1647
  this.isClosing = false;
1601
1648
  this.isClosed = true;
1649
+
1650
+ // Tear down subconnections just like close() does, otherwise their IMAP
1651
+ // clients keep reconnecting to the now-deleted account. This runs in the
1652
+ // finally so a mailbox or Redis error above cannot leave them alive
1653
+ // (closeSubconnections is internally guarded and never throws).
1654
+ this.closeSubconnections();
1602
1655
  }
1603
1656
 
1604
1657
  this.logger.info({ msg: 'Closed account', account: this.account });
@@ -1805,11 +1858,13 @@ class IMAPClient extends BaseClient {
1805
1858
 
1806
1859
  this.checkIMAPConnection(connectionOptions);
1807
1860
 
1808
- if (!this.mailboxes.has(normalizePath(path))) {
1861
+ path = await this.resolvePathAlias(path);
1862
+
1863
+ if (!this.mailboxes.has(path)) {
1809
1864
  return false; //?
1810
1865
  }
1811
1866
 
1812
- let mailbox = this.mailboxes.get(normalizePath(path));
1867
+ let mailbox = this.mailboxes.get(path);
1813
1868
 
1814
1869
  return await mailbox.updateMessages(search, updates, connectionOptions);
1815
1870
  }
@@ -1871,11 +1926,13 @@ class IMAPClient extends BaseClient {
1871
1926
 
1872
1927
  this.checkIMAPConnection(connectionOptions);
1873
1928
 
1874
- if (!this.mailboxes.has(normalizePath(source))) {
1929
+ source = await this.resolvePathAlias(source);
1930
+
1931
+ if (!this.mailboxes.has(source)) {
1875
1932
  return false; //?
1876
1933
  }
1877
1934
 
1878
- let mailbox = this.mailboxes.get(normalizePath(source));
1935
+ let mailbox = this.mailboxes.get(source);
1879
1936
 
1880
1937
  let res = await mailbox.moveMessages(search, target, connectionOptions);
1881
1938
 
@@ -1928,11 +1985,13 @@ class IMAPClient extends BaseClient {
1928
1985
 
1929
1986
  this.checkIMAPConnection(connectionOptions);
1930
1987
 
1931
- if (!this.mailboxes.has(normalizePath(path))) {
1988
+ path = await this.resolvePathAlias(path);
1989
+
1990
+ if (!this.mailboxes.has(path)) {
1932
1991
  return false; //?
1933
1992
  }
1934
1993
 
1935
- let mailbox = this.mailboxes.get(normalizePath(path));
1994
+ let mailbox = this.mailboxes.get(path);
1936
1995
  let res = await mailbox.deleteMessages(search, force, connectionOptions);
1937
1996
 
1938
1997
  // force sync target mailbox if messages were moved to trash
@@ -2059,15 +2118,8 @@ class IMAPClient extends BaseClient {
2059
2118
 
2060
2119
  this.checkIMAPConnection(connectionOptions);
2061
2120
 
2062
- let path = normalizePath(options.path);
2063
-
2064
2121
  // Handle special-use folder aliases
2065
- if (['\\Junk', '\\Sent', '\\Trash', '\\Inbox', '\\Drafts', '\\All'].includes(path)) {
2066
- let resolvedPath = await this.getSpecialUseMailbox(path);
2067
- if (resolvedPath) {
2068
- path = resolvedPath.path;
2069
- }
2070
- }
2122
+ let path = await this.resolvePathAlias(options.path);
2071
2123
 
2072
2124
  if (!this.mailboxes.has(path)) {
2073
2125
  return false; //?
@@ -2241,7 +2293,7 @@ class IMAPClient extends BaseClient {
2241
2293
  throw error;
2242
2294
  } else if (err.responseStatus === 'NO') {
2243
2295
  return {
2244
- path,
2296
+ path: [].concat(path || []).join('/'),
2245
2297
  created: false
2246
2298
  };
2247
2299
  } else {
@@ -2304,7 +2356,7 @@ class IMAPClient extends BaseClient {
2304
2356
  error.info = {
2305
2357
  response: err.response && typeof err.response === 'string' && err.response.replace(/^[^\s]*\s*/, '')
2306
2358
  };
2307
- error.code = err.serverResponseCode;
2359
+ error.code = err.serverResponseCode || 'RenameFailed';
2308
2360
  error.statusCode = 400;
2309
2361
  throw error;
2310
2362
  } else {
@@ -2360,6 +2412,26 @@ class IMAPClient extends BaseClient {
2360
2412
  .find(entry => entry.specialUse === specialUse);
2361
2413
  }
2362
2414
 
2415
+ /**
2416
+ * Resolves a special-use folder alias (e.g. "\Sent") to the real mailbox path.
2417
+ * Returns the normalized input path unchanged if it is not an alias or if no
2418
+ * mailbox with the requested special-use flag exists.
2419
+ * @param {string} path - Mailbox path or special-use alias
2420
+ * @returns {string} Normalized mailbox path
2421
+ */
2422
+ async resolvePathAlias(path) {
2423
+ path = normalizePath(path);
2424
+
2425
+ if (['\\Junk', '\\Sent', '\\Trash', '\\Inbox', '\\Drafts', '\\All'].includes(path)) {
2426
+ let resolved = await this.getSpecialUseMailbox(path);
2427
+ if (resolved) {
2428
+ path = normalizePath(resolved.path);
2429
+ }
2430
+ }
2431
+
2432
+ return path;
2433
+ }
2434
+
2363
2435
  /**
2364
2436
  * Uploads a message to a mailbox
2365
2437
  * @param {Object} data - Message data including path, flags, content
@@ -2390,12 +2462,18 @@ class IMAPClient extends BaseClient {
2390
2462
  });
2391
2463
  }
2392
2464
 
2393
- // Pack response data
2465
+ // Pack response data. ImapFlow's append() returns `destination` as the
2466
+ // append target and `path` as the currently selected mailbox (often the
2467
+ // idling INBOX), so prefer `destination` when building the response path
2468
+ // and message id. Otherwise uploads to a non-selected folder would report
2469
+ // an id that points at the selected mailbox and fails to resolve.
2470
+ let destinationPath = uploadResponse.destination || uploadResponse.path || data.path;
2471
+
2394
2472
  if (uploadResponse.uid) {
2395
- response.id = await this.packUid(uploadResponse.path || data.path, uploadResponse.uid);
2473
+ response.id = await this.packUid(destinationPath, uploadResponse.uid);
2396
2474
  }
2397
2475
 
2398
- response.path = uploadResponse.path;
2476
+ response.path = destinationPath;
2399
2477
 
2400
2478
  if (uploadResponse.uid) {
2401
2479
  response.uid = uploadResponse.uid;
@@ -2448,12 +2526,11 @@ class IMAPClient extends BaseClient {
2448
2526
  * @param {Object} payload - Expunge event data
2449
2527
  */
2450
2528
  async expungeHandler(payload) {
2451
- if (!payload || !payload.path || !this.mailboxes.has(normalizePath(payload.path))) {
2452
- return; //?
2453
- }
2454
-
2455
- let mailbox = this.mailboxes.get(normalizePath(payload.path));
2456
2529
  try {
2530
+ let mailbox = this.resolveEventMailbox(payload);
2531
+ if (!mailbox) {
2532
+ return;
2533
+ }
2457
2534
  await mailbox.onExpunge(payload);
2458
2535
  } catch (err) {
2459
2536
  this.logger.error({ msg: 'Expunge error', err });
@@ -2676,7 +2753,7 @@ class IMAPClient extends BaseClient {
2676
2753
  const emptyResponse = { signatures: [], signaturesSupported: false };
2677
2754
  let accountData = await this.accountObject.loadAccountData();
2678
2755
 
2679
- if (!accountData.oauth2.provider) {
2756
+ if (!accountData.oauth2?.provider) {
2680
2757
  // Not an OAuth2 account
2681
2758
  return emptyResponse;
2682
2759
  }
@@ -157,10 +157,7 @@ class NotificationHandler {
157
157
  }
158
158
  } catch (err) {
159
159
  if (this.logger.notifyError) {
160
- this.logger.notifyError(err, event => {
161
- event.setUser(this.account);
162
- event.addMetadata('ee', { index });
163
- });
160
+ this.logger.notifyError(err, { user: this.account, meta: { index } });
164
161
  }
165
162
  this.logger.error({
166
163
  msg: 'Failed to resolve thread',