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.
- package/.github/workflows/deploy.yml +2 -0
- package/.github/workflows/release.yaml +4 -0
- package/CHANGELOG.md +40 -0
- package/config/default.toml +2 -0
- package/data/google-crawlers.json +7 -1
- package/lib/account.js +62 -25
- package/lib/api-routes/account-routes.js +493 -75
- package/lib/api-routes/blocklist-routes.js +337 -0
- package/lib/api-routes/delivery-test-routes.js +321 -0
- package/lib/api-routes/export-routes.js +1 -12
- package/lib/api-routes/gateway-routes.js +376 -0
- package/lib/api-routes/license-routes.js +142 -0
- package/lib/api-routes/mailbox-routes.js +318 -0
- package/lib/api-routes/message-routes.js +21 -129
- package/lib/api-routes/oauth2-app-routes.js +631 -0
- package/lib/api-routes/outbox-routes.js +173 -0
- package/lib/api-routes/pubsub-routes.js +98 -0
- package/lib/api-routes/route-helpers.js +45 -0
- package/lib/api-routes/settings-routes.js +331 -0
- package/lib/api-routes/stats-routes.js +77 -0
- package/lib/api-routes/submit-routes.js +472 -0
- package/lib/api-routes/template-routes.js +7 -55
- package/lib/api-routes/token-routes.js +297 -0
- package/lib/api-routes/webhook-route-routes.js +152 -0
- package/lib/email-client/gmail-client.js +14 -0
- package/lib/email-client/imap/mailbox.js +34 -11
- package/lib/email-client/imap/subconnection.js +20 -12
- package/lib/email-client/imap/sync-operations.js +130 -2
- package/lib/email-client/imap-client.js +116 -58
- package/lib/email-client/outlook-client.js +85 -13
- package/lib/export.js +60 -19
- package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
- package/lib/imapproxy/imap-core/lib/imap-command.js +6 -1
- package/lib/imapproxy/imap-core/lib/imap-connection.js +106 -23
- package/lib/imapproxy/imap-core/lib/imap-server.js +24 -0
- package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
- package/lib/message-port-stream.js +113 -16
- package/lib/reject-worker-calls.js +42 -0
- package/lib/routes-ui.js +37 -8778
- package/lib/schemas.js +26 -1
- package/lib/tools.js +68 -0
- package/lib/ui-routes/account-routes.js +40 -210
- package/lib/ui-routes/admin-config-routes.js +913 -487
- package/lib/ui-routes/admin-entities-routes.js +1 -0
- package/lib/ui-routes/auth-routes.js +1339 -0
- package/lib/ui-routes/dashboard-routes.js +188 -0
- package/lib/ui-routes/document-store-routes.js +800 -0
- package/lib/ui-routes/export-routes.js +217 -0
- package/lib/ui-routes/internals-routes.js +354 -0
- package/lib/ui-routes/network-config-routes.js +759 -0
- package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +371 -91
- package/lib/ui-routes/route-helpers.js +316 -0
- package/lib/ui-routes/smtp-test-routes.js +236 -0
- package/lib/ui-routes/unsubscribe-routes.js +234 -0
- package/lib/webhook-request.js +36 -0
- package/package.json +8 -8
- package/sbom.json +1 -1
- package/server.js +214 -16
- package/static/licenses.html +12 -12
- package/translations/messages.pot +129 -149
- package/views/dashboard.hbs +7 -26
- package/views/internals/index.hbs +15 -0
- package/views/tokens/index.hbs +9 -0
- package/workers/api.js +198 -4401
- package/workers/export.js +87 -54
- package/workers/imap.js +29 -13
- package/workers/submit.js +20 -11
- 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',
|
|
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',
|
|
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',
|
|
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
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
-
|
|
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(
|
|
2454
|
+
response.id = await this.packUid(destinationPath, uploadResponse.uid);
|
|
2396
2455
|
}
|
|
2397
2456
|
|
|
2398
|
-
response.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
|
|
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
|
-
|
|
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
|
-
//
|
|
3098
|
-
if (entry.childFolderCount
|
|
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
|
|
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 };
|