emailengine-app 2.61.0 → 2.61.2

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 (137) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/data/google-crawlers.json +1 -1
  3. package/lib/account/account-state.js +248 -0
  4. package/lib/account.js +17 -178
  5. package/lib/api-routes/account-routes.js +1006 -0
  6. package/lib/api-routes/message-routes.js +1377 -0
  7. package/lib/consts.js +12 -2
  8. package/lib/email-client/base-client.js +282 -771
  9. package/lib/email-client/gmail/gmail-api.js +243 -0
  10. package/lib/email-client/gmail-client.js +145 -53
  11. package/lib/email-client/imap/mailbox.js +24 -698
  12. package/lib/email-client/imap/sync-operations.js +812 -0
  13. package/lib/email-client/imap-client.js +3 -1
  14. package/lib/email-client/message-builder.js +566 -0
  15. package/lib/email-client/notification-handler.js +314 -0
  16. package/lib/email-client/outlook/graph-api.js +326 -0
  17. package/lib/email-client/outlook-client.js +159 -113
  18. package/lib/email-client/smtp-pool-manager.js +196 -0
  19. package/lib/imapproxy/imap-server.js +3 -12
  20. package/lib/oauth/gmail.js +4 -4
  21. package/lib/oauth/mail-ru.js +30 -5
  22. package/lib/oauth/outlook.js +57 -3
  23. package/lib/oauth/pubsub/google.js +30 -11
  24. package/lib/oauth/scope-checker.js +202 -0
  25. package/lib/oauth2-apps.js +8 -4
  26. package/lib/redis-operations.js +484 -0
  27. package/lib/routes-ui.js +283 -2582
  28. package/lib/tools.js +5 -196
  29. package/lib/ui-routes/account-routes.js +1931 -0
  30. package/lib/ui-routes/admin-config-routes.js +1233 -0
  31. package/lib/ui-routes/admin-entities-routes.js +2367 -0
  32. package/lib/ui-routes/oauth-routes.js +992 -0
  33. package/lib/utils/network.js +237 -0
  34. package/package.json +12 -12
  35. package/sbom.json +1 -1
  36. package/static/js/app.js +5 -5
  37. package/static/licenses.html +91 -21
  38. package/translations/de.mo +0 -0
  39. package/translations/de.po +85 -82
  40. package/translations/en.mo +0 -0
  41. package/translations/en.po +63 -71
  42. package/translations/et.mo +0 -0
  43. package/translations/et.po +84 -82
  44. package/translations/fr.mo +0 -0
  45. package/translations/fr.po +85 -82
  46. package/translations/ja.mo +0 -0
  47. package/translations/ja.po +84 -82
  48. package/translations/messages.pot +67 -80
  49. package/translations/nl.mo +0 -0
  50. package/translations/nl.po +86 -82
  51. package/translations/pl.mo +0 -0
  52. package/translations/pl.po +84 -82
  53. package/views/account/security.hbs +4 -4
  54. package/views/accounts/account.hbs +13 -13
  55. package/views/accounts/register/imap-server.hbs +12 -12
  56. package/views/config/document-store/pre-processing/index.hbs +4 -2
  57. package/views/config/oauth/app.hbs +6 -7
  58. package/views/config/oauth/index.hbs +2 -2
  59. package/views/config/service.hbs +3 -4
  60. package/views/dashboard.hbs +5 -7
  61. package/views/error.hbs +22 -7
  62. package/views/gateways/gateway.hbs +2 -2
  63. package/views/partials/add_account_modal.hbs +7 -10
  64. package/views/partials/document_store_header.hbs +1 -1
  65. package/views/partials/editor_scope_info.hbs +0 -1
  66. package/views/partials/oauth_config_header.hbs +1 -1
  67. package/views/partials/side_menu.hbs +3 -3
  68. package/views/partials/webhook_form.hbs +2 -2
  69. package/views/templates/index.hbs +1 -1
  70. package/views/templates/template.hbs +8 -8
  71. package/views/tokens/index.hbs +6 -6
  72. package/views/tokens/new.hbs +1 -1
  73. package/views/webhooks/index.hbs +4 -4
  74. package/views/webhooks/webhook.hbs +7 -7
  75. package/workers/api.js +148 -2436
  76. package/workers/smtp.js +2 -1
  77. package/workers/webhooks.js +6 -0
  78. package/lib/imapproxy/imap-core/test/client.js +0 -46
  79. package/lib/imapproxy/imap-core/test/fixtures/append.eml +0 -1196
  80. package/lib/imapproxy/imap-core/test/fixtures/chunks.js +0 -44
  81. package/lib/imapproxy/imap-core/test/fixtures/fix1.eml +0 -6
  82. package/lib/imapproxy/imap-core/test/fixtures/fix2.eml +0 -599
  83. package/lib/imapproxy/imap-core/test/fixtures/fix3.eml +0 -32
  84. package/lib/imapproxy/imap-core/test/fixtures/fix4.eml +0 -6
  85. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.eml +0 -599
  86. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.js +0 -2740
  87. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.json +0 -1411
  88. package/lib/imapproxy/imap-core/test/fixtures/mimetree.js +0 -85
  89. package/lib/imapproxy/imap-core/test/fixtures/nodemailer.eml +0 -582
  90. package/lib/imapproxy/imap-core/test/fixtures/ryan_finnie_mime_torture.eml +0 -599
  91. package/lib/imapproxy/imap-core/test/fixtures/simple.eml +0 -42
  92. package/lib/imapproxy/imap-core/test/fixtures/simple.json +0 -164
  93. package/lib/imapproxy/imap-core/test/imap-compile-stream-test.js +0 -671
  94. package/lib/imapproxy/imap-core/test/imap-compiler-test.js +0 -272
  95. package/lib/imapproxy/imap-core/test/imap-indexer-test.js +0 -236
  96. package/lib/imapproxy/imap-core/test/imap-parser-test.js +0 -922
  97. package/lib/imapproxy/imap-core/test/memory-notifier.js +0 -129
  98. package/lib/imapproxy/imap-core/test/prepare.sh +0 -74
  99. package/lib/imapproxy/imap-core/test/protocol-test.js +0 -1756
  100. package/lib/imapproxy/imap-core/test/search-test.js +0 -1356
  101. package/lib/imapproxy/imap-core/test/test-client.js +0 -152
  102. package/lib/imapproxy/imap-core/test/test-server.js +0 -623
  103. package/lib/imapproxy/imap-core/test/tools-test.js +0 -22
  104. package/test/api-test.js +0 -899
  105. package/test/autoreply-test.js +0 -327
  106. package/test/bounce-test.js +0 -151
  107. package/test/complaint-test.js +0 -256
  108. package/test/fixtures/autoreply/LICENSE +0 -27
  109. package/test/fixtures/autoreply/rfc3834-01.eml +0 -23
  110. package/test/fixtures/autoreply/rfc3834-02.eml +0 -24
  111. package/test/fixtures/autoreply/rfc3834-03.eml +0 -26
  112. package/test/fixtures/autoreply/rfc3834-04.eml +0 -48
  113. package/test/fixtures/autoreply/rfc3834-05.eml +0 -19
  114. package/test/fixtures/autoreply/rfc3834-06.eml +0 -59
  115. package/test/fixtures/bounces/163.eml +0 -2521
  116. package/test/fixtures/bounces/fastmail.eml +0 -242
  117. package/test/fixtures/bounces/gmail.eml +0 -252
  118. package/test/fixtures/bounces/hotmail.eml +0 -655
  119. package/test/fixtures/bounces/mailru.eml +0 -121
  120. package/test/fixtures/bounces/outlook.eml +0 -1107
  121. package/test/fixtures/bounces/postfix.eml +0 -101
  122. package/test/fixtures/bounces/rambler.eml +0 -116
  123. package/test/fixtures/bounces/workmail.eml +0 -142
  124. package/test/fixtures/bounces/yahoo.eml +0 -139
  125. package/test/fixtures/bounces/zoho.eml +0 -83
  126. package/test/fixtures/bounces/zonemta.eml +0 -100
  127. package/test/fixtures/complaints/LICENSE +0 -27
  128. package/test/fixtures/complaints/amazonses.eml +0 -72
  129. package/test/fixtures/complaints/dmarc.eml +0 -59
  130. package/test/fixtures/complaints/hotmail.eml +0 -49
  131. package/test/fixtures/complaints/optout.eml +0 -40
  132. package/test/fixtures/complaints/standard-arf.eml +0 -68
  133. package/test/fixtures/complaints/yahoo.eml +0 -68
  134. package/test/oauth2-apps-test.js +0 -301
  135. package/test/sendonly-test.js +0 -160
  136. package/test/test-config.js +0 -34
  137. package/test/webhooks-server.js +0 -39
@@ -0,0 +1,812 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const { compareExisting, calculateFetchBackoff, readEnvValue } = require('../../tools');
5
+ const config = require('@zone-eu/wild-config');
6
+ const { DEFAULT_FETCH_BATCH_SIZE } = require('../../consts');
7
+
8
+ // Configurable batch size for fetching messages (default: 250)
9
+ const FETCH_BATCH_SIZE = Number(readEnvValue('EENGINE_FETCH_BATCH_SIZE') || config.service.fetchBatchSize) || DEFAULT_FETCH_BATCH_SIZE;
10
+
11
+ // Do not check for flag updates using full sync more often than this value (30 minutes)
12
+ const FULL_SYNC_DELAY = 30 * 60 * 1000;
13
+
14
+ /**
15
+ * Calculates the next range of sequence numbers to fetch based on the last fetched range
16
+ * @param {Number} totalMessages - Total number of messages in the mailbox
17
+ * @param {String} lastRange - Last fetched range in format "start:end" or "start:*"
18
+ * @returns {String|false} Next range to fetch or false if no more messages
19
+ */
20
+ function getFetchRange(totalMessages, lastRange) {
21
+ let lastEndMarker = lastRange ? lastRange.split(':').pop() : false;
22
+ if (lastEndMarker === '*') {
23
+ // Already fetched to the end
24
+ return false;
25
+ }
26
+ let lastSeq = lastRange ? Number(lastEndMarker) : 0;
27
+ let startSeq = lastSeq + 1;
28
+ if (startSeq > totalMessages) {
29
+ // No more messages to fetch
30
+ return false;
31
+ }
32
+ let endMarker = startSeq + FETCH_BATCH_SIZE - 1;
33
+ if (endMarker >= totalMessages) {
34
+ // Use * to fetch to the end
35
+ endMarker = '*';
36
+ }
37
+ return `${startSeq}:${endMarker}`;
38
+ }
39
+
40
+ /**
41
+ * Determines if a full sync should be skipped based on timing
42
+ * @param {Object} storedStatus - Stored mailbox status
43
+ * @returns {Boolean} True if full sync was performed recently
44
+ */
45
+ function isRecentFullSync(storedStatus) {
46
+ return storedStatus.lastFullSync && storedStatus.lastFullSync >= new Date(Date.now() - FULL_SYNC_DELAY);
47
+ }
48
+
49
+ /**
50
+ * Checks if UIDVALIDITY has changed (mailbox was recreated)
51
+ * @param {Object} storedStatus - Stored mailbox status
52
+ * @param {Object} mailboxStatus - Current mailbox status from IMAP
53
+ * @returns {Boolean} True if UIDVALIDITY changed
54
+ */
55
+ function hasUidValidityChanged(storedStatus, mailboxStatus) {
56
+ return 'uidValidity' in storedStatus && mailboxStatus.uidValidity !== storedStatus.uidValidity;
57
+ }
58
+
59
+ /**
60
+ * Checks if MODSEQ indicates no changes
61
+ * @param {Object} storedStatus - Stored mailbox status
62
+ * @param {Object} mailboxStatus - Current mailbox status from IMAP
63
+ * @returns {Boolean} True if MODSEQ is unchanged
64
+ */
65
+ function hasNoModseqChanges(storedStatus, mailboxStatus) {
66
+ return storedStatus.highestModseq && storedStatus.highestModseq === mailboxStatus.highestModseq;
67
+ }
68
+
69
+ /**
70
+ * Determines if partial sync can be used based on CONDSTORE support
71
+ * @param {Object} imapClient - IMAP client with enabled extensions
72
+ * @param {Object} storedStatus - Stored mailbox status
73
+ * @param {Object} mailboxStatus - Current mailbox status from IMAP
74
+ * @returns {Boolean} True if partial sync with CONDSTORE is appropriate
75
+ */
76
+ function canUseCondstorePartialSync(imapClient, storedStatus, mailboxStatus) {
77
+ return (
78
+ imapClient.enabled.has('CONDSTORE') &&
79
+ storedStatus.highestModseq < mailboxStatus.highestModseq &&
80
+ storedStatus.messages <= mailboxStatus.messages &&
81
+ mailboxStatus.uidNext - storedStatus.uidNext === mailboxStatus.messages - storedStatus.messages
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Determines if partial sync can be used based on message count
87
+ * @param {Object} storedStatus - Stored mailbox status
88
+ * @param {Object} mailboxStatus - Current mailbox status from IMAP
89
+ * @returns {Boolean} True if only new messages were added
90
+ */
91
+ function canUseSimplePartialSync(storedStatus, mailboxStatus) {
92
+ return storedStatus.messages < mailboxStatus.messages && mailboxStatus.uidNext - storedStatus.uidNext === mailboxStatus.messages - storedStatus.messages;
93
+ }
94
+
95
+ /**
96
+ * Determines if sync can be skipped due to no changes
97
+ * @param {Object} storedStatus - Stored mailbox status
98
+ * @param {Object} mailboxStatus - Current mailbox status from IMAP
99
+ * @returns {Boolean} True if no sync is needed
100
+ */
101
+ function canSkipSync(storedStatus, mailboxStatus) {
102
+ return storedStatus.messages === mailboxStatus.messages && storedStatus.uidNext === mailboxStatus.uidNext && isRecentFullSync(storedStatus);
103
+ }
104
+
105
+ /**
106
+ * Handles the synchronization of IMAP mailboxes
107
+ */
108
+ class SyncOperations {
109
+ /**
110
+ * Creates a new SyncOperations instance
111
+ * @param {Object} mailbox - The parent Mailbox instance
112
+ */
113
+ constructor(mailbox) {
114
+ this.mailbox = mailbox;
115
+ this.connection = mailbox.connection;
116
+ this.logger = mailbox.logger;
117
+ }
118
+
119
+ /**
120
+ * Performs full synchronization based on indexer type
121
+ * @returns {Promise} Resolves when sync is complete
122
+ */
123
+ async fullSync() {
124
+ const imapIndexer = this.mailbox.imapIndexer;
125
+
126
+ this.logger.trace({ msg: 'Running full sync', imapIndexer });
127
+
128
+ if (imapIndexer === 'fast') {
129
+ return this.runFastSync();
130
+ }
131
+ return this.runFullSync();
132
+ }
133
+
134
+ /**
135
+ * Performs partial synchronization based on indexer type
136
+ * @param {Object} storedStatus - Current stored mailbox status
137
+ * @returns {Promise} Resolves when sync is complete
138
+ */
139
+ async partialSync(storedStatus) {
140
+ const imapIndexer = this.mailbox.imapIndexer;
141
+
142
+ this.logger.trace({ msg: 'Running partial sync', imapIndexer });
143
+
144
+ if (imapIndexer === 'fast') {
145
+ return this.runFastSync(storedStatus);
146
+ }
147
+ return this.runPartialSync(storedStatus);
148
+ }
149
+
150
+ /**
151
+ * Determines the appropriate sync strategy based on mailbox state
152
+ * @param {Object} storedStatus - Stored mailbox status
153
+ * @param {Object} mailboxStatus - Current mailbox status from IMAP
154
+ * @returns {Object} Sync decision with type and reason
155
+ */
156
+ determineSyncStrategy(storedStatus, mailboxStatus) {
157
+ // No changes if MODSEQ hasn't changed
158
+ if (hasNoModseqChanges(storedStatus, mailboxStatus)) {
159
+ return { type: 'none', reason: 'modseq_unchanged' };
160
+ }
161
+
162
+ // No changes if mailbox is empty
163
+ if (storedStatus.messages === 0 && mailboxStatus.messages === 0) {
164
+ return { type: 'none', reason: 'empty_mailbox' };
165
+ }
166
+
167
+ // Partial sync if CONDSTORE indicates only new messages or flag changes
168
+ if (canUseCondstorePartialSync(this.connection.imapClient, storedStatus, mailboxStatus)) {
169
+ return { type: 'partial', reason: 'condstore_changes' };
170
+ }
171
+
172
+ // Partial sync if only new messages
173
+ if (canUseSimplePartialSync(storedStatus, mailboxStatus)) {
174
+ return { type: 'partial', reason: 'new_messages_only' };
175
+ }
176
+
177
+ // Skip if nothing changed and recent full sync
178
+ if (canSkipSync(storedStatus, mailboxStatus)) {
179
+ return { type: 'none', reason: 'recent_full_sync' };
180
+ }
181
+
182
+ // Full sync for all other cases
183
+ return { type: 'full', reason: 'changes_detected' };
184
+ }
185
+
186
+ /**
187
+ * Fast sync mode - only tracks new messages, doesn't maintain full message list
188
+ * More efficient for large mailboxes where we only care about new messages
189
+ * @param {Object} storedStatus - Current stored mailbox status
190
+ */
191
+ async runFastSync(storedStatus) {
192
+ storedStatus = storedStatus || (await this.mailbox.getStoredStatus());
193
+ let mailboxStatus = this.mailbox.getMailboxStatus();
194
+
195
+ let lock = await this.mailbox.getMailboxLock(null, { description: 'Fast sync' });
196
+ this.connection.syncing = true;
197
+ this.mailbox.syncing = true;
198
+ try {
199
+ if (!this.connection.imapClient) {
200
+ throw new Error('IMAP connection not available');
201
+ }
202
+
203
+ let knownUidNext = typeof storedStatus.uidNext === 'number' ? storedStatus.uidNext || 1 : 1;
204
+
205
+ if (knownUidNext && mailboxStatus.messages) {
206
+ // detected new emails
207
+ let fields = { uid: true, flags: true, modseq: true, emailId: true, labels: true, internalDate: true };
208
+
209
+ let imapClient = this.connection.imapClient;
210
+
211
+ // If we have not yet scanned this folder, then start by finding the earliest matching email
212
+ if (typeof storedStatus.uidNext !== 'number' && this.connection.notifyFrom && this.connection.notifyFrom < new Date()) {
213
+ // Find first message after notifyFrom date
214
+ let matchingMessages = await imapClient.search({ since: this.connection.notifyFrom }, { uid: true });
215
+ if (matchingMessages) {
216
+ let earliestUid = matchingMessages[0];
217
+ if (earliestUid) {
218
+ knownUidNext = earliestUid;
219
+ } else if (mailboxStatus.uidNext) {
220
+ // no match, start from newest
221
+ knownUidNext = mailboxStatus.uidNext;
222
+ }
223
+ }
224
+ }
225
+
226
+ let range = `${knownUidNext}:*`;
227
+ let opts = {
228
+ uid: true
229
+ };
230
+
231
+ // Fetch messages with retry logic
232
+ let fetchCompleted = false;
233
+ let fetchRetryCount = 0;
234
+
235
+ while (!fetchCompleted) {
236
+ try {
237
+ let messages = [];
238
+
239
+ // Fetch all messages in range
240
+ for await (let messageData of imapClient.fetch(range, fields, opts)) {
241
+ if (!messageData || !messageData.uid) {
242
+ //TODO: support partial responses
243
+ this.logger.debug({ msg: 'Partial FETCH response', code: 'partial_fetch', query: { range, fields, opts } });
244
+ continue;
245
+ }
246
+
247
+ // ignore Recent flag
248
+ messageData.flags.delete('\\Recent');
249
+
250
+ messages.push(messageData);
251
+ }
252
+ // ensure that messages are sorted by UID
253
+ messages = messages.sort((a, b) => a.uid - b.uid);
254
+
255
+ // Process each new message
256
+ for (let messageData of messages) {
257
+ // Update uidNext if this is a new message
258
+ let updated = await this.connection.redis.hUpdateBigger(
259
+ this.mailbox.getMailboxKey(),
260
+ 'uidNext',
261
+ messageData.uid + 1,
262
+ messageData.uid + 1
263
+ );
264
+
265
+ if (updated) {
266
+ // new email! Queue for processing
267
+ await this.connection.redis.zadd(
268
+ this.mailbox.getNotificationsKey(),
269
+ messageData.uid,
270
+ JSON.stringify({
271
+ uid: messageData.uid,
272
+ flags: messageData.flags,
273
+ internalDate:
274
+ (messageData.internalDate &&
275
+ typeof messageData.internalDate.toISOString === 'function' &&
276
+ messageData.internalDate.toISOString()) ||
277
+ null
278
+ })
279
+ );
280
+ }
281
+ }
282
+
283
+ try {
284
+ // clear failure flag
285
+ await this.connection.redis.hdel(this.connection.getAccountKey(), 'syncError');
286
+ } catch (err) {
287
+ // ignore
288
+ }
289
+ fetchCompleted = true;
290
+ } catch (err) {
291
+ try {
292
+ // set failure flag
293
+ await this.connection.redis.hSetExists(
294
+ this.connection.getAccountKey(),
295
+ 'syncError',
296
+ JSON.stringify({
297
+ path: this.mailbox.path,
298
+ time: new Date().toISOString(),
299
+ error: {
300
+ error: err.message,
301
+ responseStatus: err.responseStatus,
302
+ responseText: err.responseText
303
+ }
304
+ })
305
+ );
306
+ } catch (err) {
307
+ // ignore
308
+ }
309
+
310
+ // Retry with exponential backoff
311
+ if (!imapClient.usable) {
312
+ // nothing to do here, connection closed
313
+ this.logger.error({ msg: `FETCH failed, connection already closed, not retrying`, err });
314
+ return;
315
+ }
316
+
317
+ const fetchRetryDelay = calculateFetchBackoff(++fetchRetryCount);
318
+ this.logger.error({ msg: `FETCH failed, retrying in ${Math.round(fetchRetryDelay / 1000)}s`, err });
319
+ await new Promise(r => setTimeout(r, fetchRetryDelay));
320
+
321
+ if (!imapClient.usable) {
322
+ // nothing to do here, connection closed
323
+ this.logger.error({ msg: `FETCH failed, connection already closed, not retrying`, err });
324
+ return;
325
+ }
326
+ }
327
+ }
328
+ }
329
+
330
+ await this.mailbox.updateStoredStatus(this.mailbox.getMailboxStatus());
331
+
332
+ await this.mailbox.publishSyncedEvents(storedStatus);
333
+ } finally {
334
+ lock.release();
335
+ this.connection.syncing = false;
336
+ this.mailbox.syncing = false;
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Partial sync - fetches only changed messages using MODSEQ or UID range
342
+ * Used for incremental updates when we know something changed
343
+ * @param {Object} storedStatus - Current stored mailbox status
344
+ */
345
+ async runPartialSync(storedStatus) {
346
+ storedStatus = storedStatus || (await this.mailbox.getStoredStatus());
347
+ let mailboxStatus = this.mailbox.getMailboxStatus();
348
+
349
+ let lock = await this.mailbox.getMailboxLock(null, { description: 'Partial sync' });
350
+ this.connection.syncing = true;
351
+ this.mailbox.syncing = true;
352
+ try {
353
+ if (!this.connection.imapClient) {
354
+ throw new Error('IMAP connection not available');
355
+ }
356
+
357
+ let fields = { uid: true, flags: true, modseq: true, emailId: true, labels: true, internalDate: true };
358
+ let range = '1:*';
359
+ let opts = {
360
+ uid: true
361
+ };
362
+
363
+ // Use CONDSTORE if available for efficient change detection
364
+ if (this.connection.imapClient.enabled.has('CONDSTORE') && storedStatus.highestModseq) {
365
+ // Only fetch messages changed since last known MODSEQ
366
+ opts.changedSince = storedStatus.highestModseq;
367
+ } else if (storedStatus.uidNext) {
368
+ // Fall back to fetching new messages only
369
+ range = `${storedStatus.uidNext}:*`;
370
+ }
371
+
372
+ if (mailboxStatus.messages) {
373
+ // only fetch messages if there are some
374
+ let fetchCompleted = false;
375
+ let fetchRetryCount = 0;
376
+ while (!fetchCompleted) {
377
+ // Get fresh imapClient reference inside retry loop
378
+ let imapClient = this.connection.imapClient;
379
+ if (!imapClient || !imapClient.usable) {
380
+ this.logger.error({ msg: 'IMAP client not available for partial sync' });
381
+ throw new Error('IMAP connection not available');
382
+ }
383
+
384
+ try {
385
+ // Fetch and process each message
386
+ for await (let messageData of imapClient.fetch(range, fields, opts)) {
387
+ if (!messageData || !messageData.uid) {
388
+ //TODO: support partial responses
389
+ this.logger.debug({ msg: 'Partial FETCH response', code: 'partial_fetch', query: { range, fields, opts } });
390
+ continue;
391
+ }
392
+
393
+ // ignore Recent flag
394
+ messageData.flags.delete('\\Recent');
395
+
396
+ let storedMessage = await this.mailbox.entryListGet(messageData.uid, { uid: true });
397
+
398
+ let changes;
399
+ if (!storedMessage) {
400
+ // New message
401
+ let seq = await this.mailbox.entryListSet(messageData);
402
+ if (seq) {
403
+ // Queue for processing
404
+ await this.connection.redis.zadd(
405
+ this.mailbox.getNotificationsKey(),
406
+ messageData.uid,
407
+ JSON.stringify({
408
+ uid: messageData.uid,
409
+ flags: messageData.flags,
410
+ internalDate:
411
+ (messageData.internalDate &&
412
+ typeof messageData.internalDate.toISOString === 'function' &&
413
+ messageData.internalDate.toISOString()) ||
414
+ null
415
+ })
416
+ );
417
+ }
418
+ } else if ((changes = compareExisting(storedMessage.entry, messageData))) {
419
+ // Existing message with changes
420
+ let seq = await this.mailbox.entryListSet(messageData);
421
+ if (seq) {
422
+ await this.mailbox.processChanges(messageData, changes);
423
+ }
424
+ }
425
+ }
426
+ try {
427
+ // clear failure flag
428
+ await this.connection.redis.hdel(this.connection.getAccountKey(), 'syncError');
429
+ } catch (err) {
430
+ // ignore
431
+ }
432
+ fetchCompleted = true;
433
+ } catch (err) {
434
+ try {
435
+ // set failure flag
436
+ await this.connection.redis.hSetExists(
437
+ this.connection.getAccountKey(),
438
+ 'syncError',
439
+ JSON.stringify({
440
+ path: this.mailbox.path,
441
+ time: new Date().toISOString(),
442
+ error: {
443
+ error: err.message,
444
+ responseStatus: err.responseStatus,
445
+ responseText: err.responseText
446
+ }
447
+ })
448
+ );
449
+ } catch (err) {
450
+ // ignore
451
+ }
452
+
453
+ // Retry with exponential backoff
454
+ if (!imapClient.usable) {
455
+ // nothing to do here, connection closed
456
+ this.logger.error({ msg: `FETCH failed, connection already closed, not retrying` });
457
+ return;
458
+ }
459
+
460
+ const fetchRetryDelay = calculateFetchBackoff(++fetchRetryCount);
461
+ this.logger.error({ msg: `FETCH failed, retrying in ${Math.round(fetchRetryDelay / 1000)}s` });
462
+ await new Promise(r => setTimeout(r, fetchRetryDelay));
463
+
464
+ if (!imapClient.usable) {
465
+ // nothing to do here, connection closed
466
+ this.logger.error({ msg: `FETCH failed, connection already closed, not retrying` });
467
+ return;
468
+ }
469
+ }
470
+ }
471
+ }
472
+
473
+ await this.mailbox.updateStoredStatus(this.mailbox.getMailboxStatus());
474
+
475
+ await this.mailbox.publishSyncedEvents(storedStatus);
476
+ } finally {
477
+ lock.release();
478
+ this.connection.syncing = false;
479
+ this.mailbox.syncing = false;
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Full sync - fetches all messages and detects additions, deletions, and changes
485
+ * Most thorough but slowest sync method
486
+ */
487
+ async runFullSync() {
488
+ let fields = { uid: true, flags: true, modseq: true, emailId: true, labels: true, internalDate: true };
489
+ let opts = {};
490
+
491
+ let lock = await this.mailbox.getMailboxLock(null, { description: 'Full sync' });
492
+ this.connection.syncing = true;
493
+ this.mailbox.syncing = true;
494
+ try {
495
+ // Generate unique ID for this sync loop to track batch ordering
496
+ const loopId = crypto.randomUUID();
497
+
498
+ // Wait for next tick to ensure ImapFlow has processed all untagged responses from SELECT
499
+ await new Promise(resolve => setImmediate(resolve));
500
+
501
+ let mailboxStatus = this.mailbox.getMailboxStatus();
502
+
503
+ this.logger.debug({
504
+ msg: 'Starting full sync',
505
+ code: 'full_sync_start',
506
+ loopId,
507
+ mailboxStatus,
508
+ imapClientExists: mailboxStatus.messages
509
+ });
510
+
511
+ // Track highest sequence number seen
512
+ let seqMax = 0;
513
+ let changes;
514
+
515
+ // Get current message count for deletion detection
516
+ let storedMaxSeqOld = await this.connection.redis.zcard(this.mailbox.getMessagesKey());
517
+
518
+ let responseCounters = {
519
+ empty: 0,
520
+ partial: 0,
521
+ messages: 0
522
+ };
523
+
524
+ if (mailboxStatus.messages) {
525
+ this.logger.debug({
526
+ msg: 'Running FETCH',
527
+ code: 'run_fetch',
528
+ query: { fields, opts },
529
+ expectedMessages: mailboxStatus.messages,
530
+ mailbox: mailboxStatus,
531
+ maxBatchSize: FETCH_BATCH_SIZE,
532
+ expectedBatches: Math.ceil(mailboxStatus.messages / FETCH_BATCH_SIZE)
533
+ });
534
+
535
+ // Process messages in batches to avoid memory issues
536
+ let range = false;
537
+ let lastHighestUid = 0;
538
+ let batchNumber = 0;
539
+ // process messages in batches
540
+ while ((range = getFetchRange(mailboxStatus.messages, range))) {
541
+ batchNumber++;
542
+ this.logger.debug({
543
+ msg: 'Processing batch',
544
+ code: 'fetch_batch',
545
+ loopId,
546
+ batchNumber,
547
+ range,
548
+ totalMessages: mailboxStatus.messages,
549
+ previousRange: batchNumber > 1 ? 'calculated' : 'initial'
550
+ });
551
+ let fetchCompleted = false;
552
+ let fetchRetryCount = 0;
553
+ while (!fetchCompleted) {
554
+ // Get fresh imapClient reference inside retry loop
555
+ // This ensures we use the current connection state
556
+ const imapClient = this.connection.imapClient;
557
+ if (!imapClient || !imapClient.usable) {
558
+ this.logger.error({ msg: 'IMAP client not available for FETCH' });
559
+ throw new Error('IMAP connection not available');
560
+ }
561
+
562
+ try {
563
+ this.logger.debug({
564
+ msg: 'Starting FETCH command',
565
+ code: 'fetch_start',
566
+ loopId,
567
+ batchNumber,
568
+ range,
569
+ retryCount: fetchRetryCount,
570
+ totalMessages: mailboxStatus.messages
571
+ });
572
+
573
+ for await (let messageData of imapClient.fetch(range, fields, opts)) {
574
+ if (!messageData) {
575
+ this.logger.debug({ msg: 'Empty FETCH response', code: 'empty_fetch', query: { range, fields, opts } });
576
+ responseCounters.empty++;
577
+ continue;
578
+ }
579
+
580
+ if (!messageData.uid || (fields.flags && !messageData.flags)) {
581
+ // TODO: support partial responses
582
+ // For now, without UID or FLAGS there's nothing to do
583
+ this.logger.debug({
584
+ msg: 'Partial FETCH response',
585
+ code: 'partial_fetch',
586
+ query: { range, fields, opts },
587
+ responseKeys: Object.keys(messageData)
588
+ });
589
+ responseCounters.partial++;
590
+ continue;
591
+ }
592
+
593
+ if (messageData.uid <= lastHighestUid) {
594
+ // already processed in the previous batch
595
+ // probably an older email was deleted which shifted message entries
596
+ continue;
597
+ }
598
+ lastHighestUid = messageData.uid;
599
+
600
+ responseCounters.messages++;
601
+
602
+ if (fields.internalDate && !messageData.internalDate) {
603
+ this.logger.debug({
604
+ msg: 'Missing INTERNALDATE',
605
+ code: 'fetch_date_missing',
606
+ query: { range, fields, opts },
607
+ responseKeys: Object.keys(messageData)
608
+ });
609
+ }
610
+
611
+ // ignore Recent flag
612
+ messageData.flags.delete('\\Recent');
613
+
614
+ if (messageData.seq > seqMax) {
615
+ seqMax = messageData.seq;
616
+ }
617
+
618
+ let storedMessage = await this.mailbox.entryListGet(messageData.uid, { uid: true });
619
+ if (!storedMessage) {
620
+ // New message
621
+ let seq = await this.mailbox.entryListSet(messageData);
622
+ if (seq) {
623
+ await this.connection.redis.zadd(
624
+ this.mailbox.getNotificationsKey(),
625
+ messageData.uid,
626
+ JSON.stringify({
627
+ uid: messageData.uid,
628
+ flags: messageData.flags,
629
+ internalDate:
630
+ (messageData.internalDate &&
631
+ typeof messageData.internalDate.toISOString === 'function' &&
632
+ messageData.internalDate.toISOString()) ||
633
+ null
634
+ })
635
+ );
636
+ }
637
+ } else {
638
+ // Check for deleted messages between stored and current sequence
639
+ let diff = storedMessage.seq - messageData.seq;
640
+ if (diff) {
641
+ this.logger.trace({ msg: 'Deleted range', inloop: true, diff, start: messageData.seq });
642
+ }
643
+ // Process deletions
644
+ for (let i = diff - 1; i >= 0; i--) {
645
+ let seq = messageData.seq + i;
646
+ let deletedEntry = await this.mailbox.entryListExpunge(seq);
647
+ if (deletedEntry) {
648
+ await this.mailbox.processDeleted(deletedEntry);
649
+ }
650
+ }
651
+
652
+ // Check for changes
653
+ if ((changes = compareExisting(storedMessage.entry, messageData))) {
654
+ let seq = await this.mailbox.entryListSet(messageData);
655
+ if (seq) {
656
+ await this.mailbox.processChanges(messageData, changes);
657
+ }
658
+ }
659
+ }
660
+ }
661
+
662
+ try {
663
+ // clear failure flag
664
+ await this.connection.redis.hdel(this.connection.getAccountKey(), 'syncError');
665
+ } catch (err) {
666
+ // ignore
667
+ }
668
+
669
+ this.logger.debug({
670
+ msg: 'FETCH completed successfully',
671
+ code: 'fetch_success',
672
+ loopId,
673
+ batchNumber,
674
+ range,
675
+ retryCount: fetchRetryCount
676
+ });
677
+
678
+ fetchCompleted = true;
679
+ } catch (err) {
680
+ this.logger.error({
681
+ msg: 'FETCH failed',
682
+ code: 'fetch_error',
683
+ loopId,
684
+ batchNumber,
685
+ range,
686
+ retryCount: fetchRetryCount,
687
+ totalMessages: mailboxStatus.messages,
688
+ error: err.message,
689
+ responseStatus: err.responseStatus,
690
+ responseText: err.responseText
691
+ });
692
+
693
+ if (!imapClient.usable) {
694
+ // nothing to do here, connection closed
695
+ this.logger.error({ msg: `FETCH failed, connection already closed, not retrying`, loopId });
696
+ return;
697
+ }
698
+
699
+ try {
700
+ // set failure flag
701
+ await this.connection.redis.hSetExists(
702
+ this.connection.getAccountKey(),
703
+ 'syncError',
704
+ JSON.stringify({
705
+ path: this.mailbox.path,
706
+ time: new Date().toISOString(),
707
+ error: {
708
+ error: err.message,
709
+ responseStatus: err.responseStatus,
710
+ responseText: err.responseText
711
+ }
712
+ })
713
+ );
714
+ } catch (err) {
715
+ // ignore
716
+ }
717
+
718
+ // Retry with exponential backoff
719
+ const fetchRetryDelay = calculateFetchBackoff(++fetchRetryCount);
720
+ this.logger.error({ msg: `FETCH failed, retrying in ${Math.round(fetchRetryDelay / 1000)}s`, loopId, batchNumber });
721
+ await new Promise(r => setTimeout(r, fetchRetryDelay));
722
+
723
+ if (!imapClient.usable) {
724
+ // nothing to do here, connection closed
725
+ this.logger.error({ msg: `FETCH failed, connection already closed, not retrying`, loopId });
726
+ return;
727
+ }
728
+
729
+ // Verify we're still on the correct mailbox after the delay
730
+ // Another operation might have changed the mailbox while we were waiting
731
+ const currentMailbox = this.connection.imapClient.mailbox;
732
+ if (!currentMailbox || currentMailbox.path !== this.mailbox.path) {
733
+ this.logger.error({
734
+ msg: 'Mailbox changed during retry delay, aborting sync',
735
+ expectedPath: this.mailbox.path,
736
+ currentPath: currentMailbox ? currentMailbox.path : 'none',
737
+ loopId
738
+ });
739
+ throw new Error('Mailbox changed during sync operation');
740
+ }
741
+
742
+ // Refresh mailbox status in case it changed
743
+ const oldMailboxMessages = mailboxStatus.messages;
744
+ mailboxStatus = this.mailbox.getMailboxStatus();
745
+
746
+ this.logger.debug({
747
+ msg: 'Refreshed mailbox status after error',
748
+ code: 'mailbox_status_refresh',
749
+ loopId,
750
+ batchNumber,
751
+ oldMessages: oldMailboxMessages,
752
+ newMessages: mailboxStatus.messages,
753
+ range
754
+ });
755
+ }
756
+ }
757
+ }
758
+ }
759
+
760
+ // Delete any messages that weren't seen in this sync
761
+ let storedMaxSeq = await this.connection.redis.zcard(this.mailbox.getMessagesKey());
762
+ let diff = storedMaxSeq - seqMax;
763
+ if (diff) {
764
+ this.logger.trace({
765
+ msg: 'Deleted range',
766
+ inloop: false,
767
+ diff,
768
+ start: seqMax + 1,
769
+ messagesKey: this.mailbox.getMessagesKey(),
770
+ zcard: storedMaxSeq,
771
+ zcardOld: storedMaxSeqOld,
772
+ responseCounters
773
+ });
774
+ }
775
+
776
+ // Process remaining deletions
777
+ for (let i = diff - 1; i >= 0; i--) {
778
+ let seq = seqMax + i + 1;
779
+ let deletedEntry = await this.mailbox.entryListExpunge(seq);
780
+ if (deletedEntry) {
781
+ await this.mailbox.processDeleted(deletedEntry);
782
+ }
783
+ }
784
+
785
+ // Update status with full sync timestamp
786
+ let status = this.mailbox.getMailboxStatus();
787
+ status.lastFullSync = new Date();
788
+
789
+ await this.mailbox.updateStoredStatus(status);
790
+ let storedStatus = await this.mailbox.getStoredStatus();
791
+
792
+ await this.mailbox.publishSyncedEvents(storedStatus);
793
+ } finally {
794
+ this.connection.syncing = false;
795
+ this.mailbox.syncing = false;
796
+ lock.release();
797
+ }
798
+ }
799
+ }
800
+
801
+ module.exports = {
802
+ SyncOperations,
803
+ getFetchRange,
804
+ isRecentFullSync,
805
+ hasUidValidityChanged,
806
+ hasNoModseqChanges,
807
+ canUseCondstorePartialSync,
808
+ canUseSimplePartialSync,
809
+ canSkipSync,
810
+ FETCH_BATCH_SIZE,
811
+ FULL_SYNC_DELAY
812
+ };