emailengine-app 2.68.1 → 2.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.github/workflows/deploy.yml +2 -0
  2. package/.github/workflows/release.yaml +4 -0
  3. package/CHANGELOG.md +40 -0
  4. package/config/default.toml +2 -0
  5. package/data/google-crawlers.json +7 -1
  6. package/lib/account.js +62 -25
  7. package/lib/api-routes/account-routes.js +493 -75
  8. package/lib/api-routes/blocklist-routes.js +337 -0
  9. package/lib/api-routes/delivery-test-routes.js +321 -0
  10. package/lib/api-routes/export-routes.js +1 -12
  11. package/lib/api-routes/gateway-routes.js +376 -0
  12. package/lib/api-routes/license-routes.js +142 -0
  13. package/lib/api-routes/mailbox-routes.js +318 -0
  14. package/lib/api-routes/message-routes.js +21 -129
  15. package/lib/api-routes/oauth2-app-routes.js +631 -0
  16. package/lib/api-routes/outbox-routes.js +173 -0
  17. package/lib/api-routes/pubsub-routes.js +98 -0
  18. package/lib/api-routes/route-helpers.js +45 -0
  19. package/lib/api-routes/settings-routes.js +331 -0
  20. package/lib/api-routes/stats-routes.js +77 -0
  21. package/lib/api-routes/submit-routes.js +472 -0
  22. package/lib/api-routes/template-routes.js +7 -55
  23. package/lib/api-routes/token-routes.js +297 -0
  24. package/lib/api-routes/webhook-route-routes.js +152 -0
  25. package/lib/email-client/gmail-client.js +14 -0
  26. package/lib/email-client/imap/mailbox.js +34 -11
  27. package/lib/email-client/imap/subconnection.js +20 -12
  28. package/lib/email-client/imap/sync-operations.js +130 -2
  29. package/lib/email-client/imap-client.js +116 -58
  30. package/lib/email-client/outlook-client.js +85 -13
  31. package/lib/export.js +60 -19
  32. package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
  33. package/lib/imapproxy/imap-core/lib/imap-command.js +6 -1
  34. package/lib/imapproxy/imap-core/lib/imap-connection.js +106 -23
  35. package/lib/imapproxy/imap-core/lib/imap-server.js +24 -0
  36. package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
  37. package/lib/message-port-stream.js +113 -16
  38. package/lib/reject-worker-calls.js +42 -0
  39. package/lib/routes-ui.js +37 -8778
  40. package/lib/schemas.js +26 -1
  41. package/lib/tools.js +68 -0
  42. package/lib/ui-routes/account-routes.js +40 -210
  43. package/lib/ui-routes/admin-config-routes.js +913 -487
  44. package/lib/ui-routes/admin-entities-routes.js +1 -0
  45. package/lib/ui-routes/auth-routes.js +1339 -0
  46. package/lib/ui-routes/dashboard-routes.js +188 -0
  47. package/lib/ui-routes/document-store-routes.js +800 -0
  48. package/lib/ui-routes/export-routes.js +217 -0
  49. package/lib/ui-routes/internals-routes.js +354 -0
  50. package/lib/ui-routes/network-config-routes.js +759 -0
  51. package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +371 -91
  52. package/lib/ui-routes/route-helpers.js +316 -0
  53. package/lib/ui-routes/smtp-test-routes.js +236 -0
  54. package/lib/ui-routes/unsubscribe-routes.js +234 -0
  55. package/lib/webhook-request.js +36 -0
  56. package/package.json +8 -8
  57. package/sbom.json +1 -1
  58. package/server.js +214 -16
  59. package/static/licenses.html +12 -12
  60. package/translations/messages.pot +129 -149
  61. package/views/dashboard.hbs +7 -26
  62. package/views/internals/index.hbs +15 -0
  63. package/views/tokens/index.hbs +9 -0
  64. package/workers/api.js +198 -4401
  65. package/workers/export.js +87 -54
  66. package/workers/imap.js +29 -13
  67. package/workers/submit.js +20 -11
  68. package/workers/webhooks.js +6 -20
@@ -0,0 +1,297 @@
1
+ 'use strict';
2
+
3
+ const Joi = require('joi');
4
+ const { redis } = require('../db');
5
+ const { Account } = require('../account');
6
+ const getSecret = require('../get-secret');
7
+ const tokens = require('../tokens');
8
+ const { failAction } = require('../tools');
9
+ const { handleError } = require('./route-helpers');
10
+ const { accountIdSchema, tokenRestrictionsSchema, ipSchema, tokenIdSchema } = require('../schemas');
11
+
12
+ async function init(args) {
13
+ const { server, call, CORS_CONFIG } = args;
14
+
15
+ server.route({
16
+ method: 'POST',
17
+ path: '/v1/token',
18
+
19
+ async handler(request) {
20
+ let accountObject = new Account({
21
+ redis,
22
+ account: request.payload.account,
23
+ call,
24
+ secret: await getSecret(),
25
+ timeout: request.headers['x-ee-timeout']
26
+ });
27
+
28
+ try {
29
+ // throws if account does not exist
30
+ await accountObject.loadAccountData();
31
+
32
+ let token = await tokens.provision(Object.assign({}, request.payload, { remoteAddress: request.app.ip }));
33
+
34
+ return { token };
35
+ } catch (err) {
36
+ handleError(request, err);
37
+ }
38
+ },
39
+
40
+ options: {
41
+ description: 'Provision an access token',
42
+ notes: 'Provisions a new access token for an account',
43
+ tags: ['api', 'Access Tokens'],
44
+
45
+ plugins: {},
46
+
47
+ auth: {
48
+ strategy: 'api-token',
49
+ mode: 'required'
50
+ },
51
+ cors: CORS_CONFIG,
52
+
53
+ validate: {
54
+ options: {
55
+ stripUnknown: false,
56
+ abortEarly: false,
57
+ convert: true
58
+ },
59
+ failAction,
60
+
61
+ payload: Joi.object({
62
+ account: accountIdSchema.required(),
63
+
64
+ description: Joi.string().empty('').trim().max(1024).required().example('Token description').description('Token description'),
65
+
66
+ scopes: Joi.array()
67
+ .items(Joi.string().valid('api', 'smtp', 'imap-proxy').label('TokenScope'))
68
+ .single()
69
+ .default(['api'])
70
+ .required()
71
+ .description(
72
+ 'Token permission scopes: "api" for REST API access, "smtp" for SMTP submission, "imap-proxy" for IMAP proxy authentication'
73
+ )
74
+ .label('Scopes'),
75
+
76
+ metadata: Joi.string()
77
+ .empty('')
78
+ .max(1024 * 1024)
79
+ .custom((value, helpers) => {
80
+ try {
81
+ // check if parsing fails
82
+ JSON.parse(value);
83
+ return value;
84
+ } catch (err) {
85
+ return helpers.message('Metadata must be a valid JSON string');
86
+ }
87
+ })
88
+ .example('{"example": "value"}')
89
+ .description('Related metadata in JSON format')
90
+ .label('JsonMetaData'),
91
+
92
+ restrictions: tokenRestrictionsSchema,
93
+
94
+ ip: ipSchema.description('IP address of the requester').label('TokenIP')
95
+ }).label('CreateToken')
96
+ },
97
+
98
+ response: {
99
+ schema: Joi.object({
100
+ token: Joi.string().length(64).hex().required().example('123456').description('Access token')
101
+ }).label('CreateTokenResponse'),
102
+ failAction: 'log'
103
+ }
104
+ }
105
+ });
106
+
107
+ server.route({
108
+ method: 'DELETE',
109
+ path: '/v1/token/{token}',
110
+
111
+ async handler(request) {
112
+ try {
113
+ return { deleted: await tokens.delete(request.params.token, { remoteAddress: request.app.ip }) };
114
+ } catch (err) {
115
+ handleError(request, err);
116
+ }
117
+ },
118
+ options: {
119
+ description: 'Remove a token',
120
+ notes: 'Delete an access token',
121
+ tags: ['api', 'Access Tokens'],
122
+
123
+ plugins: {},
124
+
125
+ auth: {
126
+ strategy: 'api-token',
127
+ mode: 'required'
128
+ },
129
+ cors: CORS_CONFIG,
130
+
131
+ validate: {
132
+ options: {
133
+ stripUnknown: false,
134
+ abortEarly: false,
135
+ convert: true
136
+ },
137
+ failAction,
138
+
139
+ params: Joi.object({
140
+ token: Joi.string().length(64).hex().required().example('123456').description('Access token')
141
+ }).label('DeleteTokenRequest')
142
+ },
143
+
144
+ response: {
145
+ schema: Joi.object({
146
+ deleted: Joi.boolean().truthy('Y', 'true', '1').falsy('N', 'false', 0).default(true).description('Was the token deleted')
147
+ }).label('DeleteTokenRequestResponse'),
148
+ failAction: 'log'
149
+ }
150
+ }
151
+ });
152
+
153
+ server.route({
154
+ method: 'GET',
155
+ path: '/v1/tokens',
156
+
157
+ async handler(request) {
158
+ try {
159
+ // TODO: allow paging
160
+ return { tokens: (await tokens.list(null, 0, 1000)).tokens };
161
+ } catch (err) {
162
+ handleError(request, err);
163
+ }
164
+ },
165
+
166
+ options: {
167
+ description: 'List root tokens',
168
+ notes: 'Lists access tokens registered for root access',
169
+ tags: ['api', 'Access Tokens'],
170
+
171
+ plugins: {},
172
+
173
+ auth: {
174
+ strategy: 'api-token',
175
+ mode: 'required'
176
+ },
177
+ cors: CORS_CONFIG,
178
+
179
+ validate: {
180
+ options: {
181
+ stripUnknown: false,
182
+ abortEarly: false,
183
+ convert: true
184
+ },
185
+ failAction
186
+ },
187
+
188
+ response: {
189
+ schema: Joi.object({
190
+ tokens: Joi.array()
191
+ .items(
192
+ Joi.object({
193
+ account: accountIdSchema.required(),
194
+ description: Joi.string().empty('').trim().max(1024).required().example('Token description').description('Token description'),
195
+ metadata: Joi.string()
196
+ .empty('')
197
+ .max(1024 * 1024)
198
+ .custom((value, helpers) => {
199
+ try {
200
+ // check if parsing fails
201
+ JSON.parse(value);
202
+ return value;
203
+ } catch (err) {
204
+ return helpers.message('Metadata must be a valid JSON string');
205
+ }
206
+ })
207
+ .example('{"example": "value"}')
208
+ .description('Related metadata in JSON format')
209
+ .label('JsonMetaData'),
210
+ ip: ipSchema.description('IP address of the requester').label('TokenIP'),
211
+ id: tokenIdSchema
212
+ }).label('RootTokensItem')
213
+ )
214
+ .label('RootTokensEntries')
215
+ }).label('RootTokensResponse'),
216
+ failAction: 'log'
217
+ }
218
+ }
219
+ });
220
+
221
+ server.route({
222
+ method: 'GET',
223
+ path: '/v1/tokens/account/{account}',
224
+
225
+ async handler(request) {
226
+ try {
227
+ // TODO: allow paging
228
+ return { tokens: (await tokens.list(request.params.account, 0, 1000)).tokens };
229
+ } catch (err) {
230
+ handleError(request, err);
231
+ }
232
+ },
233
+
234
+ options: {
235
+ description: 'List account tokens',
236
+ notes: 'Lists access tokens registered for an account',
237
+ tags: ['api', 'Access Tokens'],
238
+
239
+ plugins: {},
240
+
241
+ auth: {
242
+ strategy: 'api-token',
243
+ mode: 'required'
244
+ },
245
+ cors: CORS_CONFIG,
246
+
247
+ validate: {
248
+ options: {
249
+ stripUnknown: false,
250
+ abortEarly: false,
251
+ convert: true
252
+ },
253
+ failAction,
254
+ params: Joi.object({
255
+ account: accountIdSchema.required()
256
+ })
257
+ },
258
+
259
+ response: {
260
+ schema: Joi.object({
261
+ tokens: Joi.array()
262
+ .items(
263
+ Joi.object({
264
+ account: accountIdSchema.required(),
265
+ description: Joi.string().empty('').trim().max(1024).required().example('Token description').description('Token description'),
266
+ metadata: Joi.string()
267
+ .empty('')
268
+ .max(1024 * 1024)
269
+ .custom((value, helpers) => {
270
+ try {
271
+ // check if parsing fails
272
+ JSON.parse(value);
273
+ return value;
274
+ } catch (err) {
275
+ return helpers.message('Metadata must be a valid JSON string');
276
+ }
277
+ })
278
+ .example('{"example": "value"}')
279
+ .description('Related metadata in JSON format')
280
+ .label('JsonMetaData'),
281
+
282
+ restrictions: tokenRestrictionsSchema,
283
+
284
+ ip: ipSchema.description('IP address of the requester').label('TokenIP'),
285
+
286
+ id: tokenIdSchema
287
+ }).label('AccountTokensItem')
288
+ )
289
+ .label('AccountTokensEntries')
290
+ }).label('AccountsTokensResponse'),
291
+ failAction: 'log'
292
+ }
293
+ }
294
+ });
295
+ }
296
+
297
+ module.exports = init;
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ const Joi = require('joi');
4
+ const { webhooks: Webhooks } = require('../webhooks');
5
+ const { failAction } = require('../tools');
6
+ const { handleError } = require('./route-helpers');
7
+ const { settingsSchema } = require('../schemas');
8
+
9
+ async function init(args) {
10
+ const { server, CORS_CONFIG } = args;
11
+
12
+ server.route({
13
+ method: 'GET',
14
+ path: '/v1/webhookRoutes',
15
+
16
+ async handler(request) {
17
+ try {
18
+ return await Webhooks.list(request.query.page, request.query.pageSize);
19
+ } catch (err) {
20
+ handleError(request, err);
21
+ }
22
+ },
23
+
24
+ options: {
25
+ description: 'List webhook routes',
26
+ notes: 'List custom webhook routes',
27
+ tags: ['api', 'Webhooks'],
28
+
29
+ plugins: {},
30
+
31
+ auth: {
32
+ strategy: 'api-token',
33
+ mode: 'required'
34
+ },
35
+ cors: CORS_CONFIG,
36
+
37
+ validate: {
38
+ options: {
39
+ stripUnknown: false,
40
+ abortEarly: false,
41
+ convert: true
42
+ },
43
+ failAction,
44
+
45
+ query: Joi.object({
46
+ page: Joi.number()
47
+ .integer()
48
+ .min(0)
49
+ .max(1024 * 1024)
50
+ .default(0)
51
+ .example(0)
52
+ .description('Page number (zero indexed, so use 0 for first page)')
53
+ .label('PageNumber'),
54
+ pageSize: Joi.number().integer().min(1).max(1000).default(20).example(20).description('How many entries per page').label('PageSize')
55
+ }).label('WebhookRoutesListRequest')
56
+ },
57
+
58
+ response: {
59
+ schema: Joi.object({
60
+ total: Joi.number().integer().example(120).description('How many matching entries').label('TotalNumber'),
61
+ page: Joi.number().integer().example(0).description('Current page (0-based index)').label('PageNumber'),
62
+ pages: Joi.number().integer().example(24).description('Total page count').label('PagesNumber'),
63
+
64
+ webhooks: Joi.array()
65
+ .items(
66
+ Joi.object({
67
+ id: Joi.string().max(256).required().example('AAABgS-UcAYAAAABAA').description('Webhook ID'),
68
+ name: Joi.string().max(256).example('Send to Slack').description('Name of the route').label('WebhookRouteName').required(),
69
+ description: Joi.string()
70
+ .allow('')
71
+ .max(1024)
72
+ .example('Something about the route')
73
+ .description('Optional description of the webhook route')
74
+ .label('WebhookRouteDescription'),
75
+ created: Joi.date().iso().example('2021-02-17T13:43:18.860Z').description('The time this route was created'),
76
+ updated: Joi.date().iso().example('2021-02-17T13:43:18.860Z').description('The time this route was last updated'),
77
+ enabled: Joi.boolean().example(true).description('Is the route enabled').label('WebhookRouteEnabled'),
78
+ targetUrl: settingsSchema.webhooks,
79
+ tcount: Joi.number().integer().example(123).description('How many times this route has been applied')
80
+ }).label('WebhookRoutesListEntry')
81
+ )
82
+ .label('WebhookRoutesList')
83
+ }).label('WebhookRoutesListResponse'),
84
+ failAction: 'log'
85
+ }
86
+ }
87
+ });
88
+
89
+ server.route({
90
+ method: 'GET',
91
+ path: '/v1/webhookRoutes/webhookRoute/{webhookRoute}',
92
+
93
+ async handler(request) {
94
+ try {
95
+ return await Webhooks.get(request.params.webhookRoute);
96
+ } catch (err) {
97
+ handleError(request, err);
98
+ }
99
+ },
100
+
101
+ options: {
102
+ description: 'Get webhook route information',
103
+ notes: 'Retrieve webhook route content and information',
104
+ tags: ['api', 'Webhooks'],
105
+
106
+ plugins: {},
107
+
108
+ auth: {
109
+ strategy: 'api-token',
110
+ mode: 'required'
111
+ },
112
+ cors: CORS_CONFIG,
113
+
114
+ validate: {
115
+ options: {
116
+ stripUnknown: false,
117
+ abortEarly: false,
118
+ convert: true
119
+ },
120
+ failAction,
121
+ params: Joi.object({
122
+ webhookRoute: Joi.string().max(256).required().example('example').description('Webhook ID')
123
+ }).label('GetWebhookRouteRequest')
124
+ },
125
+
126
+ response: {
127
+ schema: Joi.object({
128
+ id: Joi.string().max(256).required().example('AAABgS-UcAYAAAABAA').description('Webhook ID'),
129
+ name: Joi.string().max(256).example('Send to Slack').description('Name of the route').label('WebhookRouteName').required(),
130
+ description: Joi.string()
131
+ .allow('')
132
+ .max(1024)
133
+ .example('Something about the route')
134
+ .description('Optional description of the webhook route')
135
+ .label('WebhookRouteDescription'),
136
+ created: Joi.date().iso().example('2021-02-17T13:43:18.860Z').description('The time this route was created'),
137
+ updated: Joi.date().iso().example('2021-02-17T13:43:18.860Z').description('The time this route was last updated'),
138
+ enabled: Joi.boolean().example(true).description('Is the route enabled').label('WebhookRouteEnabled'),
139
+ targetUrl: settingsSchema.webhooks,
140
+ tcount: Joi.number().integer().example(123).description('How many times this route has been applied'),
141
+ content: Joi.object({
142
+ fn: Joi.string().example('return true;').description('Filter function'),
143
+ map: Joi.string().example('payload.ts = Date.now(); return payload;').description('Mapping function')
144
+ }).label('WebhookRouteContent')
145
+ }).label('WebhookRouteResponse'),
146
+ failAction: 'log'
147
+ }
148
+ }
149
+ });
150
+ }
151
+
152
+ module.exports = init;
@@ -2700,6 +2700,20 @@ class GmailClient extends BaseClient {
2700
2700
  queryParts.push(search.gmailRaw);
2701
2701
  }
2702
2702
 
2703
+ // Label filters - "has" matches messages with the label, "not" excludes them
2704
+ if (search.labels && typeof search.labels === 'object') {
2705
+ for (let label of [].concat(search.labels.has || [])) {
2706
+ if (label) {
2707
+ queryParts.push(`label:${this.formatSearchTerm(label)}`);
2708
+ }
2709
+ }
2710
+ for (let label of [].concat(search.labels.not || [])) {
2711
+ if (label) {
2712
+ queryParts.push(`-label:${this.formatSearchTerm(label)}`);
2713
+ }
2714
+ }
2715
+ }
2716
+
2703
2717
  // body search
2704
2718
  if (search.body && typeof search.body === 'string') {
2705
2719
  queryParts.push(`${this.formatSearchTerm(search.body)}`);
@@ -23,7 +23,6 @@ const {
23
23
  MESSAGE_DELETED_NOTIFY,
24
24
  MESSAGE_UPDATED_NOTIFY,
25
25
  MESSAGE_MISSING_NOTIFY,
26
- MAILBOX_RESET_NOTIFY,
27
26
  MAILBOX_NEW_NOTIFY,
28
27
  EMAIL_BOUNCE_NOTIFY,
29
28
  EMAIL_COMPLAINT_NOTIFY,
@@ -41,6 +40,7 @@ const {
41
40
  canUseCondstorePartialSync,
42
41
  canUseSimplePartialSync,
43
42
  canSkipSync,
43
+ shouldSeedLostIndex,
44
44
  FULL_SYNC_DELAY
45
45
  } = require('./sync-operations');
46
46
 
@@ -154,6 +154,8 @@ class Mailbox {
154
154
  let data = await this.connection.redis.hgetall(this.getMailboxKey());
155
155
  data = data || {};
156
156
 
157
+ let hasStoredState = Object.keys(data).length > 0;
158
+
157
159
  // Log diagnostic info if stored uidValidity is invalid or missing
158
160
  if (!validUidValidity(data.uidValidity)) {
159
161
  this.logger.warn({
@@ -162,12 +164,16 @@ class Mailbox {
162
164
  redisKey: this.getMailboxKey(),
163
165
  rawUidValidity: data.uidValidity,
164
166
  rawUidValidityType: typeof data.uidValidity,
165
- hasData: Object.keys(data).length > 0,
167
+ hasData: hasStoredState,
166
168
  storedKeys: Object.keys(data)
167
169
  });
168
170
  }
169
171
 
170
172
  return {
173
+ // True when the mailbox hash held any fields at all. Redis eviction removes
174
+ // whole keys, so this distinguishes "state lost" from "individual fields
175
+ // never persisted" (e.g. uidNext on servers that omit UIDNEXT from SELECT)
176
+ hasStoredState,
171
177
  path: data.path || this.path,
172
178
  uidValidity: validUidValidity(data.uidValidity) ? BigInt(data.uidValidity) : false,
173
179
  highestModseq: data.highestModseq && !isNaN(data.highestModseq) ? BigInt(data.highestModseq) : false,
@@ -1934,6 +1940,16 @@ class Mailbox {
1934
1940
  return this.syncOps.partialSync(storedStatus);
1935
1941
  }
1936
1942
 
1943
+ /**
1944
+ * Silently rebuilds the message index after lost or invalidated sync state
1945
+ * Delegates to SyncOperations
1946
+ * @param {Object} mailboxStatus - Current mailbox status from IMAP
1947
+ * @param {Object} [options] - Reseed options (reason, prevUidValidity)
1948
+ */
1949
+ async seedMailboxIndex(mailboxStatus, options) {
1950
+ return this.syncOps.seedMailboxIndex(mailboxStatus, options);
1951
+ }
1952
+
1937
1953
  /**
1938
1954
  * Processes queued notification events after sync
1939
1955
  * Fetches full message details and sends notifications
@@ -2060,6 +2076,15 @@ class Mailbox {
2060
2076
  try {
2061
2077
  let storedStatus = await this.getStoredStatus();
2062
2078
 
2079
+ // Lost-index recovery: the account has synced in a previous session but this folder
2080
+ // has no stored state at all (e.g. Redis evicted the whole hash) while the server
2081
+ // still has messages. Rebuild the baseline silently instead of replaying every
2082
+ // message as a new email.
2083
+ if (shouldSeedLostIndex(storedStatus, mailboxStatus, this.previouslyConnected)) {
2084
+ await this.seedMailboxIndex(mailboxStatus, { reason: 'syncStateLost' });
2085
+ return false;
2086
+ }
2087
+
2063
2088
  // Store initial UID on first sync
2064
2089
  if (storedStatus.uidNext === false && typeof mailboxStatus.uidNext === 'number') {
2065
2090
  // update first UID
@@ -2088,19 +2113,17 @@ class Mailbox {
2088
2113
  });
2089
2114
 
2090
2115
  this.logger.debug({ msg: 'Mailbox reset', path: this.listingEntry.path });
2091
- await this.connection.notify(this, MAILBOX_RESET_NOTIFY, {
2092
- path: this.listingEntry.path,
2093
- name: this.listingEntry.name,
2094
- specialUse: this.listingEntry.specialUse || false,
2095
- uidValidity: validUidValidity(mailboxStatus.uidValidity) ? mailboxStatus.uidValidity.toString() : false,
2096
- prevUidValidity: validUidValidity(storedStatus.uidValidity) ? storedStatus.uidValidity.toString() : false
2097
- });
2098
2116
 
2099
2117
  // do not advertise messages as new
2100
2118
  this.listingEntry.isNew = true;
2101
2119
 
2102
- // generates blank stored status as the Redis key was deleted
2103
- storedStatus = await this.getStoredStatus();
2120
+ // Rebuild the index silently from the recreated mailbox and emit a single
2121
+ // mailboxReset (with prev/current UIDVALIDITY) instead of replaying every message.
2122
+ await this.seedMailboxIndex(mailboxStatus, {
2123
+ reason: 'uidValidityChange',
2124
+ prevUidValidity: validUidValidity(storedStatus.uidValidity) ? storedStatus.uidValidity.toString() : false
2125
+ });
2126
+ return false;
2104
2127
  }
2105
2128
 
2106
2129
  // Determine sync strategy using helper functions
@@ -210,24 +210,32 @@ class Subconnection extends EventEmitter {
210
210
  let response = await imapClient.connect();
211
211
 
212
212
  // Process untagged EXISTS responses
213
- imapClient.on('exists', async event => {
214
- if (!event || !event.path) {
215
- return; //?
216
- }
213
+ imapClient.on('exists', event => {
214
+ try {
215
+ if (!event || !event.path) {
216
+ return; //?
217
+ }
217
218
 
218
- this.logger.info({ msg: 'Exists notification', account: this.account, event });
219
+ this.logger.info({ msg: 'Exists notification', account: this.account, event });
219
220
 
220
- this.requestSync();
221
+ this.requestSync();
222
+ } catch (err) {
223
+ this.logger.error({ msg: 'Exists notification handling failed', account: this.account, err });
224
+ }
221
225
  });
222
226
 
223
- imapClient.on('flags', async event => {
224
- if (!event || !event.path) {
225
- return; //?
226
- }
227
+ imapClient.on('flags', event => {
228
+ try {
229
+ if (!event || !event.path) {
230
+ return; //?
231
+ }
227
232
 
228
- this.logger.info({ msg: 'Flags notification', account: this.account, event });
233
+ this.logger.info({ msg: 'Flags notification', account: this.account, event });
229
234
 
230
- this.requestSync();
235
+ this.requestSync();
236
+ } catch (err) {
237
+ this.logger.error({ msg: 'Flags notification handling failed', account: this.account, err });
238
+ }
231
239
  });
232
240
 
233
241
  return response;