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
@@ -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
@@ -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;
845
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 });
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 });
@@ -2390,12 +2443,18 @@ class IMAPClient extends BaseClient {
2390
2443
  });
2391
2444
  }
2392
2445
 
2393
- // Pack response data
2446
+ // Pack response data. ImapFlow's append() returns `destination` as the
2447
+ // append target and `path` as the currently selected mailbox (often the
2448
+ // idling INBOX), so prefer `destination` when building the response path
2449
+ // and message id. Otherwise uploads to a non-selected folder would report
2450
+ // an id that points at the selected mailbox and fails to resolve.
2451
+ let destinationPath = uploadResponse.destination || uploadResponse.path || data.path;
2452
+
2394
2453
  if (uploadResponse.uid) {
2395
- response.id = await this.packUid(uploadResponse.path || data.path, uploadResponse.uid);
2454
+ response.id = await this.packUid(destinationPath, uploadResponse.uid);
2396
2455
  }
2397
2456
 
2398
- response.path = uploadResponse.path;
2457
+ response.path = destinationPath;
2399
2458
 
2400
2459
  if (uploadResponse.uid) {
2401
2460
  response.uid = uploadResponse.uid;
@@ -2448,12 +2507,11 @@ class IMAPClient extends BaseClient {
2448
2507
  * @param {Object} payload - Expunge event data
2449
2508
  */
2450
2509
  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
2510
  try {
2511
+ let mailbox = this.resolveEventMailbox(payload);
2512
+ if (!mailbox) {
2513
+ return;
2514
+ }
2457
2515
  await mailbox.onExpunge(payload);
2458
2516
  } catch (err) {
2459
2517
  this.logger.error({ msg: 'Expunge error', err });
@@ -41,6 +41,27 @@ const MAX_BATCH_SIZE = graphApi.MAX_BATCH_SIZE;
41
41
  // Subscription is renewed automatically. But just in case, check once in an hour
42
42
  const RENEW_WATCH_TTL = 60 * 60 * 1000; // 1h
43
43
 
44
+ // MS Graph folder hierarchy is defined by id/parentFolderId, not by "/" in folder
45
+ // names. To build an unambiguous, reversible pathName we percent-encode "%" and "/"
46
+ // inside a single displayName segment, keeping the real "/" as the delimiter BETWEEN
47
+ // segments. Encode "%" first then "/"; decode "%2F" first then "%25" so that a folder
48
+ // name literally containing "%2F" or "%25" round-trips correctly.
49
+ // Both helpers are pure and exception-safe: the typeof guard returns non-string input
50
+ // unchanged, and replaceAll on a string uses literal (not regex) replacement.
51
+ function encodeFolderSegment(name) {
52
+ if (typeof name !== 'string') {
53
+ return name;
54
+ }
55
+ return name.replaceAll('%', '%25').replaceAll('/', '%2F');
56
+ }
57
+
58
+ function decodeFolderSegment(segment) {
59
+ if (typeof segment !== 'string') {
60
+ return segment;
61
+ }
62
+ return segment.replaceAll('%2F', '/').replaceAll('%25', '%');
63
+ }
64
+
44
65
  /*
45
66
  Supported operations status:
46
67
  ✅ listMessages - with paging (cursor + page nr) and search queries (no support for to/cc/bcc queries)
@@ -625,7 +646,8 @@ class OutlookClient extends BaseClient {
625
646
  'ccRecipients',
626
647
  'bccRecipients',
627
648
  'internetMessageId',
628
- 'bodyPreview'
649
+ 'bodyPreview',
650
+ 'categories'
629
651
  ];
630
652
  expandFields = 'attachments($select=id,name,contentType,size,isInline,microsoft.graph.fileAttachment/contentId)';
631
653
  }
@@ -647,6 +669,7 @@ class OutlookClient extends BaseClient {
647
669
 
648
670
  let useOutlookSearch = false;
649
671
  let skipToken = null;
672
+ let labelFilterActive = false;
650
673
 
651
674
  // Handle search queries
652
675
  if (query.search) {
@@ -668,15 +691,48 @@ class OutlookClient extends BaseClient {
668
691
  // we need to have receivedDateTime as the first filtering property, otherwise ordering will fail
669
692
  requestQuery.$filter = `receivedDateTime gt 1970-01-01T00:00:00.000Z and ${$filter}`;
670
693
  }
694
+ // Category (label) filters compile to a categories/any() lambda. Some mailboxes refuse
695
+ // to combine that with $orderBy; remember it so we can retry without ordering below.
696
+ labelFilterActive = !!(query.search.labels && [].concat(query.search.labels.has || [], query.search.labels.not || []).some(Boolean));
671
697
  }
672
698
  }
673
699
 
674
700
  let messages;
675
701
  let totalMessages;
676
702
 
703
+ let messagesUrl = `/${this.oauth2UserPath}/${folder ? `mailFolders/${folder.id}/` : ''}messages`;
704
+ // The "not" form of a category filter may require advanced query capabilities; the header is
705
+ // harmless for the "has" form and for queries without a label filter we omit it entirely.
706
+ let requestOptions = labelFilterActive ? { headers: { ConsistencyLevel: 'eventual' } } : undefined;
707
+
677
708
  // Execute the message list request
678
709
  try {
679
- let listing = await this.request(`/${this.oauth2UserPath}/${folder ? `mailFolders/${folder.id}/` : ''}messages`, 'get', requestQuery);
710
+ let listing;
711
+ try {
712
+ listing = await this.request(messagesUrl, 'get', requestQuery, requestOptions);
713
+ } catch (err) {
714
+ // A categories/any() filter combined with $orderBy can be rejected as too complex
715
+ // ("InefficientFilter"). Retry once without server-side ordering and sort the page locally.
716
+ let graphCode = err?.oauthRequest?.response?.error?.code || '';
717
+ let graphMessage = err?.oauthRequest?.response?.error?.message || '';
718
+ let inefficientFilter = /inefficientfilter/i.test(graphCode) || /restriction or sort order is too complex/i.test(graphMessage);
719
+
720
+ if (labelFilterActive && requestQuery.$orderBy && inefficientFilter) {
721
+ this.logger.warn({
722
+ msg: 'Graph rejected ordered category filter, retrying without server-side ordering',
723
+ account: this.account,
724
+ path
725
+ });
726
+ delete requestQuery.$orderBy;
727
+ listing = await this.request(messagesUrl, 'get', requestQuery, requestOptions);
728
+ // Without $orderBy the server no longer guarantees order; sort this page newest-first
729
+ if (listing && Array.isArray(listing.value)) {
730
+ listing.value.sort((a, b) => new Date(b.receivedDateTime) - new Date(a.receivedDateTime));
731
+ }
732
+ } else {
733
+ throw err;
734
+ }
735
+ }
680
736
 
681
737
  totalMessages = !isNaN(listing['@odata.count']) ? Number(listing['@odata.count']) : undefined;
682
738
 
@@ -2500,7 +2556,9 @@ class OutlookClient extends BaseClient {
2500
2556
 
2501
2557
  let subPaths = path.split('/');
2502
2558
 
2503
- let displayName = subPaths.pop();
2559
+ // Decode the leaf so the literal folder name is sent to Graph; parent segments
2560
+ // stay encoded for resolveFolder (which matches against the encoded pathName).
2561
+ let displayName = decodeFolderSegment(subPaths.pop());
2504
2562
  let parentPath = subPaths.join('/');
2505
2563
 
2506
2564
  // Resolve parent folder if specified
@@ -2570,7 +2628,7 @@ class OutlookClient extends BaseClient {
2570
2628
  mailboxId: mailbox.id,
2571
2629
  path: []
2572
2630
  .concat(parentFolder?.pathName || [])
2573
- .concat(mailbox.displayName)
2631
+ .concat(encodeFolderSegment(mailbox.displayName))
2574
2632
  .join('/'),
2575
2633
  created: true
2576
2634
  };
@@ -2637,8 +2695,10 @@ class OutlookClient extends BaseClient {
2637
2695
  if (sourceName !== destinationName) {
2638
2696
  let mailbox;
2639
2697
  try {
2698
+ // sourceName/destinationName stay encoded for the comparison above;
2699
+ // decode only here so Graph receives the literal folder name.
2640
2700
  mailbox = await this.request(`/${this.oauth2UserPath}/mailFolders/${sourceFolder.id}`, 'patch', {
2641
- displayName: destinationName
2701
+ displayName: decodeFolderSegment(destinationName)
2642
2702
  });
2643
2703
  if (!mailbox) {
2644
2704
  throw new Error('Failed to rename mailbox');
@@ -3077,7 +3137,7 @@ class OutlookClient extends BaseClient {
3077
3137
  if (pathNamePrefix) {
3078
3138
  entry.parentPath = pathNamePrefix;
3079
3139
  }
3080
- entry.pathName = `${pathNamePrefix ? `${pathNamePrefix}/` : ''}${entry.displayName}`;
3140
+ entry.pathName = `${pathNamePrefix ? `${pathNamePrefix}/` : ''}${encodeFolderSegment(entry.displayName)}`;
3081
3141
  return entry;
3082
3142
  })
3083
3143
  );
@@ -3094,8 +3154,8 @@ class OutlookClient extends BaseClient {
3094
3154
 
3095
3155
  // Traverse child folders
3096
3156
  for (let entry of list) {
3097
- // do not traverse subfolders for folders with a slash in the name (would create ambiguous paths)
3098
- if (entry.childFolderCount && entry.displayName.indexOf('/') < 0) {
3157
+ // pathName percent-encodes any literal "/" in the segment, so child paths stay unambiguous
3158
+ if (entry.childFolderCount) {
3099
3159
  await traverse(entry.pathName, entry.id);
3100
3160
  }
3101
3161
  }
@@ -3103,10 +3163,8 @@ class OutlookClient extends BaseClient {
3103
3163
 
3104
3164
  await traverse();
3105
3165
 
3106
- // keep only real folders and folders that do not contain slash in the name
3107
- mailboxListing = mailboxListing.filter(
3108
- entry => (!entry['@odata.type'] || /^#?microsoft\.graph\.mailFolder$/.test(entry['@odata.type'])) && entry.displayName.indexOf('/') < 0
3109
- );
3166
+ // keep only real mail folders (drop search folders and other non-mailFolder types)
3167
+ mailboxListing = mailboxListing.filter(entry => !entry['@odata.type'] || /^#?microsoft\.graph\.mailFolder$/.test(entry['@odata.type']));
3110
3168
 
3111
3169
  return mailboxListing;
3112
3170
  }
@@ -4385,6 +4443,20 @@ class OutlookClient extends BaseClient {
4385
4443
  filterParts.push(`receivedDateTime gt ${this.formatSearchTerm(search.since || search.sentSince, false)}`);
4386
4444
  }
4387
4445
 
4446
+ // Category (label) filters - "has" matches messages tagged with the category, "not" excludes them
4447
+ if (search.labels && typeof search.labels === 'object') {
4448
+ for (let category of [].concat(search.labels.has || [])) {
4449
+ if (category) {
4450
+ filterParts.push(`categories/any(c:c eq ${this.formatSearchTerm(category)})`);
4451
+ }
4452
+ }
4453
+ for (let category of [].concat(search.labels.not || [])) {
4454
+ if (category) {
4455
+ filterParts.push(`not (categories/any(c:c eq ${this.formatSearchTerm(category)}))`);
4456
+ }
4457
+ }
4458
+ }
4459
+
4388
4460
  // Limited header search support
4389
4461
  for (let headerKey of Object.keys(search.header || {})) {
4390
4462
  switch (headerKey.toLowerCase().trim()) {
@@ -4522,4 +4594,4 @@ class OutlookClient extends BaseClient {
4522
4594
  }
4523
4595
  }
4524
4596
 
4525
- module.exports = { OutlookClient };
4597
+ module.exports = { OutlookClient, encodeFolderSegment, decodeFolderSegment };