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.
- package/.github/workflows/deploy.yml +8 -3
- package/.github/workflows/release.yaml +6 -0
- package/CHANGELOG.md +59 -0
- package/Gruntfile.js +3 -1
- package/config/default.toml +2 -0
- package/data/google-crawlers.json +7 -1
- package/getswagger.sh +40 -4
- package/gettext-extract.js +163 -0
- package/lib/account.js +135 -72
- package/lib/api-routes/account-routes.js +684 -106
- package/lib/api-routes/blocklist-routes.js +344 -0
- package/lib/api-routes/chat-routes.js +32 -14
- package/lib/api-routes/delivery-test-routes.js +346 -0
- package/lib/api-routes/export-routes.js +28 -14
- package/lib/api-routes/gateway-routes.js +427 -0
- package/lib/api-routes/license-routes.js +156 -0
- package/lib/api-routes/mailbox-routes.js +344 -0
- package/lib/api-routes/message-routes.js +221 -187
- package/lib/api-routes/oauth2-app-routes.js +697 -0
- package/lib/api-routes/outbox-routes.js +185 -0
- package/lib/api-routes/pubsub-routes.js +102 -0
- package/lib/api-routes/route-helpers.js +58 -0
- package/lib/api-routes/settings-routes.js +357 -0
- package/lib/api-routes/stats-routes.js +111 -0
- package/lib/api-routes/submit-routes.js +461 -0
- package/lib/api-routes/template-routes.js +60 -75
- package/lib/api-routes/token-routes.js +297 -0
- package/lib/api-routes/webhook-route-routes.js +181 -0
- package/lib/autodetect-imap-settings.js +0 -2
- package/lib/consts.js +5 -0
- package/lib/email-client/base-client.js +28 -6
- package/lib/email-client/gmail-client.js +133 -112
- package/lib/email-client/imap/mailbox.js +34 -11
- package/lib/email-client/imap/subconnection.js +20 -13
- package/lib/email-client/imap/sync-operations.js +131 -3
- package/lib/email-client/imap-client.js +152 -75
- package/lib/email-client/notification-handler.js +1 -4
- package/lib/email-client/outlook-client.js +134 -75
- package/lib/export.js +97 -20
- package/lib/feature-flags.js +2 -2
- package/lib/gateway.js +4 -9
- package/lib/get-raw-email.js +5 -5
- 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 -24
- package/lib/imapproxy/imap-core/lib/imap-server.js +24 -0
- package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
- package/lib/logger.js +24 -21
- package/lib/message-port-stream.js +113 -16
- package/lib/metrics-collector.js +0 -2
- package/lib/oauth2-apps.js +13 -4
- package/lib/outbox.js +24 -40
- package/lib/redis-operations.js +1 -1
- package/lib/reject-worker-calls.js +42 -0
- package/lib/routes-ui.js +37 -8778
- package/lib/schemas.js +429 -84
- package/lib/sentry.js +139 -0
- package/lib/settings.js +9 -3
- package/lib/stream-encrypt.js +1 -1
- package/lib/templates.js +1 -1
- package/lib/tokens.js +5 -3
- package/lib/tools.js +70 -4
- package/lib/ui-routes/account-routes.js +45 -212
- package/lib/ui-routes/admin-config-routes.js +928 -489
- 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} +369 -91
- package/lib/ui-routes/route-helpers.js +314 -0
- package/lib/ui-routes/smtp-test-routes.js +236 -0
- package/lib/ui-routes/unsubscribe-routes.js +232 -0
- package/lib/webhook-request.js +36 -0
- package/lib/webhooks.js +8 -4
- package/package.json +13 -12
- package/sbom.json +1 -1
- package/server.js +222 -39
- package/static/licenses.html +160 -300
- package/translations/messages.pot +112 -132
- package/update-info.sh +19 -1
- package/views/config/logging.hbs +48 -0
- package/views/dashboard.hbs +7 -26
- package/views/internals/index.hbs +15 -0
- package/views/tokens/index.hbs +9 -0
- package/workers/api.js +200 -4424
- package/workers/documents.js +2 -22
- package/workers/export.js +103 -104
- package/workers/imap-proxy.js +3 -23
- package/workers/imap.js +32 -36
- package/workers/smtp.js +2 -22
- package/workers/submit.js +26 -35
- 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:
|
|
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
|
-
//
|
|
2103
|
-
|
|
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',
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
212
|
+
imapClient.on('exists', event => {
|
|
213
|
+
try {
|
|
214
|
+
if (!event || !event.path) {
|
|
215
|
+
return; //?
|
|
216
|
+
}
|
|
217
217
|
|
|
218
|
-
|
|
218
|
+
this.logger.info({ msg: 'Exists notification', account: this.account, event });
|
|
219
219
|
|
|
220
|
-
|
|
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',
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
226
|
+
imapClient.on('flags', event => {
|
|
227
|
+
try {
|
|
228
|
+
if (!event || !event.path) {
|
|
229
|
+
return; //?
|
|
230
|
+
}
|
|
227
231
|
|
|
228
|
-
|
|
232
|
+
this.logger.info({ msg: 'Flags notification', account: this.account, event });
|
|
229
233
|
|
|
230
|
-
|
|
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',
|
|
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;
|
|
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
|
-
|
|
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 });
|
|
@@ -1805,11 +1858,13 @@ class IMAPClient extends BaseClient {
|
|
|
1805
1858
|
|
|
1806
1859
|
this.checkIMAPConnection(connectionOptions);
|
|
1807
1860
|
|
|
1808
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
2473
|
+
response.id = await this.packUid(destinationPath, uploadResponse.uid);
|
|
2396
2474
|
}
|
|
2397
2475
|
|
|
2398
|
-
response.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
|
|
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,
|
|
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',
|