emailengine-app 2.61.1 → 2.61.3

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 (136) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/data/google-crawlers.json +1 -1
  3. package/lib/account/account-state.js +248 -0
  4. package/lib/account.js +45 -193
  5. package/lib/api-routes/account-routes.js +1023 -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 +1 -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 +4 -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 +10 -10
  35. package/sbom.json +1 -1
  36. package/static/js/app.js +5 -5
  37. package/static/licenses.html +79 -19
  38. package/translations/de.mo +0 -0
  39. package/translations/de.po +97 -86
  40. package/translations/en.mo +0 -0
  41. package/translations/en.po +80 -75
  42. package/translations/et.mo +0 -0
  43. package/translations/et.po +96 -86
  44. package/translations/fr.mo +0 -0
  45. package/translations/fr.po +97 -86
  46. package/translations/ja.mo +0 -0
  47. package/translations/ja.po +96 -86
  48. package/translations/messages.pot +105 -91
  49. package/translations/nl.mo +0 -0
  50. package/translations/nl.po +98 -86
  51. package/translations/pl.mo +0 -0
  52. package/translations/pl.po +96 -86
  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/lib/imapproxy/imap-core/test/client.js +0 -46
  78. package/lib/imapproxy/imap-core/test/fixtures/append.eml +0 -1196
  79. package/lib/imapproxy/imap-core/test/fixtures/chunks.js +0 -44
  80. package/lib/imapproxy/imap-core/test/fixtures/fix1.eml +0 -6
  81. package/lib/imapproxy/imap-core/test/fixtures/fix2.eml +0 -599
  82. package/lib/imapproxy/imap-core/test/fixtures/fix3.eml +0 -32
  83. package/lib/imapproxy/imap-core/test/fixtures/fix4.eml +0 -6
  84. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.eml +0 -599
  85. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.js +0 -2740
  86. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.json +0 -1411
  87. package/lib/imapproxy/imap-core/test/fixtures/mimetree.js +0 -85
  88. package/lib/imapproxy/imap-core/test/fixtures/nodemailer.eml +0 -582
  89. package/lib/imapproxy/imap-core/test/fixtures/ryan_finnie_mime_torture.eml +0 -599
  90. package/lib/imapproxy/imap-core/test/fixtures/simple.eml +0 -42
  91. package/lib/imapproxy/imap-core/test/fixtures/simple.json +0 -164
  92. package/lib/imapproxy/imap-core/test/imap-compile-stream-test.js +0 -671
  93. package/lib/imapproxy/imap-core/test/imap-compiler-test.js +0 -272
  94. package/lib/imapproxy/imap-core/test/imap-indexer-test.js +0 -236
  95. package/lib/imapproxy/imap-core/test/imap-parser-test.js +0 -922
  96. package/lib/imapproxy/imap-core/test/memory-notifier.js +0 -129
  97. package/lib/imapproxy/imap-core/test/prepare.sh +0 -74
  98. package/lib/imapproxy/imap-core/test/protocol-test.js +0 -1756
  99. package/lib/imapproxy/imap-core/test/search-test.js +0 -1356
  100. package/lib/imapproxy/imap-core/test/test-client.js +0 -152
  101. package/lib/imapproxy/imap-core/test/test-server.js +0 -623
  102. package/lib/imapproxy/imap-core/test/tools-test.js +0 -22
  103. package/test/api-test.js +0 -899
  104. package/test/autoreply-test.js +0 -327
  105. package/test/bounce-test.js +0 -151
  106. package/test/complaint-test.js +0 -256
  107. package/test/fixtures/autoreply/LICENSE +0 -27
  108. package/test/fixtures/autoreply/rfc3834-01.eml +0 -23
  109. package/test/fixtures/autoreply/rfc3834-02.eml +0 -24
  110. package/test/fixtures/autoreply/rfc3834-03.eml +0 -26
  111. package/test/fixtures/autoreply/rfc3834-04.eml +0 -48
  112. package/test/fixtures/autoreply/rfc3834-05.eml +0 -19
  113. package/test/fixtures/autoreply/rfc3834-06.eml +0 -59
  114. package/test/fixtures/bounces/163.eml +0 -2521
  115. package/test/fixtures/bounces/fastmail.eml +0 -242
  116. package/test/fixtures/bounces/gmail.eml +0 -252
  117. package/test/fixtures/bounces/hotmail.eml +0 -655
  118. package/test/fixtures/bounces/mailru.eml +0 -121
  119. package/test/fixtures/bounces/outlook.eml +0 -1107
  120. package/test/fixtures/bounces/postfix.eml +0 -101
  121. package/test/fixtures/bounces/rambler.eml +0 -116
  122. package/test/fixtures/bounces/workmail.eml +0 -142
  123. package/test/fixtures/bounces/yahoo.eml +0 -139
  124. package/test/fixtures/bounces/zoho.eml +0 -83
  125. package/test/fixtures/bounces/zonemta.eml +0 -100
  126. package/test/fixtures/complaints/LICENSE +0 -27
  127. package/test/fixtures/complaints/amazonses.eml +0 -72
  128. package/test/fixtures/complaints/dmarc.eml +0 -59
  129. package/test/fixtures/complaints/hotmail.eml +0 -49
  130. package/test/fixtures/complaints/optout.eml +0 -40
  131. package/test/fixtures/complaints/standard-arf.eml +0 -68
  132. package/test/fixtures/complaints/yahoo.eml +0 -68
  133. package/test/oauth2-apps-test.js +0 -301
  134. package/test/sendonly-test.js +0 -160
  135. package/test/test-config.js +0 -34
  136. package/test/webhooks-server.js +0 -39
@@ -0,0 +1,314 @@
1
+ 'use strict';
2
+
3
+ const { parentPort } = require('worker_threads');
4
+ const logger = require('../logger');
5
+ const { webhooks: Webhooks } = require('../webhooks');
6
+ const { getESClient } = require('../document-store');
7
+ const { getThread } = require('../threads');
8
+ const settings = require('../settings');
9
+
10
+ // Import notification-related constants
11
+ const { MESSAGE_NEW_NOTIFY, MESSAGE_DELETED_NOTIFY, MESSAGE_UPDATED_NOTIFY, EMAIL_BOUNCE_NOTIFY, MAILBOX_DELETED_NOTIFY } = require('../consts');
12
+
13
+ /**
14
+ * Events that should sync with the document store (ElasticSearch)
15
+ */
16
+ const DOCUMENT_SYNC_EVENTS = [MESSAGE_NEW_NOTIFY, MESSAGE_DELETED_NOTIFY, MESSAGE_UPDATED_NOTIFY, EMAIL_BOUNCE_NOTIFY, MAILBOX_DELETED_NOTIFY];
17
+
18
+ /**
19
+ * Default job options for notification queue
20
+ */
21
+ const DEFAULT_JOB_OPTIONS = {
22
+ removeOnComplete: true,
23
+ removeOnFail: true,
24
+ attempts: 10,
25
+ backoff: {
26
+ type: 'exponential',
27
+ delay: 5000
28
+ }
29
+ };
30
+
31
+ /**
32
+ * Job options for document store updates (more retry attempts)
33
+ */
34
+ const DOCUMENT_JOB_OPTIONS = Object.assign({}, DEFAULT_JOB_OPTIONS, { attempts: 16 });
35
+
36
+ /**
37
+ * Sends metrics data to the parent thread for aggregation
38
+ * @param {Object} meta - Metadata to include with the metric
39
+ * @param {Object} loggerInstance - Logger instance
40
+ * @param {string} key - Metric key identifier
41
+ * @param {string} method - Metric method (e.g., 'inc', 'dec')
42
+ * @param {...any} args - Additional arguments for the metric
43
+ */
44
+ function postMetrics(meta, loggerInstance, key, method, ...args) {
45
+ try {
46
+ parentPort.postMessage({
47
+ cmd: 'metrics',
48
+ key,
49
+ method,
50
+ args,
51
+ meta: meta || {}
52
+ });
53
+ } catch (err) {
54
+ loggerInstance.error({ msg: 'Failed to post metrics to parent', err });
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Handles notification delivery for email events
60
+ * Manages webhook delivery, document store sync, and queue processing
61
+ */
62
+ class NotificationHandler {
63
+ /**
64
+ * Creates a new NotificationHandler
65
+ * @param {Object} options - Handler options
66
+ * @param {string} options.account - Account identifier
67
+ * @param {Object} options.logger - Logger instance
68
+ * @param {Object} options.flowProducer - BullMQ flow producer for queue jobs
69
+ * @param {Object} options.documentsQueue - Queue for document store updates
70
+ */
71
+ constructor(options) {
72
+ this.account = options.account;
73
+ this.logger = options.logger;
74
+ this.flowProducer = options.flowProducer;
75
+ this.documentsQueue = options.documentsQueue;
76
+ }
77
+
78
+ /**
79
+ * Builds the base notification payload
80
+ * @param {Object} mailbox - Mailbox information
81
+ * @param {string} event - Event type constant
82
+ * @param {Object} data - Event data
83
+ * @param {string} serviceUrl - Service URL for callbacks
84
+ * @returns {Object} Base notification payload
85
+ */
86
+ buildPayload(mailbox, event, data, serviceUrl) {
87
+ const payload = {
88
+ serviceUrl,
89
+ account: this.account,
90
+ date: new Date().toISOString()
91
+ };
92
+
93
+ const path = (mailbox && mailbox.path) || (data && data.path);
94
+ if (path) {
95
+ payload.path = path;
96
+ }
97
+
98
+ if (mailbox && mailbox.listingEntry && mailbox.listingEntry.specialUse) {
99
+ payload.specialUse = mailbox.listingEntry.specialUse;
100
+ }
101
+
102
+ if (event) {
103
+ payload.event = event;
104
+ }
105
+
106
+ if (data) {
107
+ payload.data = data;
108
+ }
109
+
110
+ return payload;
111
+ }
112
+
113
+ /**
114
+ * Determines if an event should sync with the document store
115
+ * @param {string} event - Event type
116
+ * @param {boolean} canSync - Whether sync is allowed
117
+ * @returns {Promise<boolean>} Whether to add document queue job
118
+ */
119
+ async shouldSyncDocuments(event, canSync) {
120
+ if (!canSync || !this.documentsQueue) {
121
+ return false;
122
+ }
123
+
124
+ if (!DOCUMENT_SYNC_EVENTS.includes(event)) {
125
+ return false;
126
+ }
127
+
128
+ return await settings.get('documentStoreEnabled');
129
+ }
130
+
131
+ /**
132
+ * Generates a thread ID for new messages using ElasticSearch
133
+ * @param {Object} payload - Notification payload with message data
134
+ * @returns {Promise<void>}
135
+ */
136
+ async generateThreadId(payload) {
137
+ if (!payload.data || payload.data.threadId) {
138
+ return;
139
+ }
140
+
141
+ const { index, client } = await getESClient(logger);
142
+ if (!client) {
143
+ return;
144
+ }
145
+
146
+ try {
147
+ const thread = await getThread(client, index, this.account, payload.data, logger);
148
+ if (thread) {
149
+ payload.data.threadId = thread;
150
+ this.logger.info({
151
+ msg: 'Provisioned thread ID for a message',
152
+ account: this.account,
153
+ message: payload.data.id,
154
+ threadId: payload.data.threadId
155
+ });
156
+ }
157
+ } catch (err) {
158
+ if (this.logger.notifyError) {
159
+ this.logger.notifyError(err, event => {
160
+ event.setUser(this.account);
161
+ event.addMetadata('ee', { index });
162
+ });
163
+ }
164
+ this.logger.error({
165
+ msg: 'Failed to resolve thread',
166
+ account: this.account,
167
+ message: payload.data.id,
168
+ err
169
+ });
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Processes notification with both webhook and document store sync
175
+ * Uses BullMQ flow to ensure proper ordering
176
+ * @param {string} event - Event type
177
+ * @param {Object} payload - Notification payload
178
+ * @param {boolean} queueKeep - Whether to keep completed jobs
179
+ */
180
+ async processWithFlow(event, payload, queueKeep) {
181
+ const notifyPayload = await Webhooks.formatPayload(event, payload);
182
+
183
+ const queueFlow = [
184
+ {
185
+ name: event,
186
+ data: payload,
187
+ queueName: 'documents'
188
+ }
189
+ ];
190
+
191
+ await Webhooks.pushToQueue(event, notifyPayload, {
192
+ routesOnly: true,
193
+ queueFlow
194
+ });
195
+
196
+ const jobOptions = this.buildJobOptions(queueKeep);
197
+
198
+ await this.flowProducer.add(
199
+ {
200
+ name: event,
201
+ data: notifyPayload,
202
+ queueName: 'notify',
203
+ children: queueFlow
204
+ },
205
+ {
206
+ queuesOptions: {
207
+ notify: {
208
+ defaultJobOptions: jobOptions.notify
209
+ },
210
+ documents: {
211
+ defaultJobOptions: jobOptions.documents
212
+ }
213
+ }
214
+ }
215
+ );
216
+ }
217
+
218
+ /**
219
+ * Processes notification with webhook only (no document sync)
220
+ * @param {string} event - Event type
221
+ * @param {Object} payload - Notification payload
222
+ */
223
+ async processWebhookOnly(event, payload) {
224
+ const notifyPayload = await Webhooks.formatPayload(event, payload);
225
+ await Webhooks.pushToQueue(event, notifyPayload);
226
+ }
227
+
228
+ /**
229
+ * Processes notification with document store sync only (no webhook)
230
+ * @param {string} event - Event type
231
+ * @param {Object} payload - Notification payload
232
+ */
233
+ async processDocumentOnly(event, payload) {
234
+ await this.documentsQueue.add(event, payload, DOCUMENT_JOB_OPTIONS);
235
+ }
236
+
237
+ /**
238
+ * Builds job options based on queue keep setting
239
+ * @param {boolean} queueKeep - Whether to keep completed/failed jobs
240
+ * @returns {Object} Job options for notify and documents queues
241
+ */
242
+ buildJobOptions(queueKeep) {
243
+ const notifyOptions = Object.assign({}, DEFAULT_JOB_OPTIONS, {
244
+ removeOnComplete: queueKeep,
245
+ removeOnFail: queueKeep
246
+ });
247
+
248
+ const documentOptions = Object.assign({}, DOCUMENT_JOB_OPTIONS, {
249
+ removeOnComplete: queueKeep,
250
+ removeOnFail: queueKeep
251
+ });
252
+
253
+ return {
254
+ notify: notifyOptions,
255
+ documents: documentOptions
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Sends a notification for an email event
261
+ * Handles webhook delivery, document store sync, and metrics tracking
262
+ * @param {Object} mailbox - Mailbox information
263
+ * @param {string} event - Event type constant
264
+ * @param {Object} data - Event data payload
265
+ * @param {Object} extraOpts - Additional options
266
+ * @param {boolean} extraOpts.skipWebhook - Skip webhook delivery
267
+ * @param {boolean} extraOpts.canSync - Allow document store sync (default: true)
268
+ * @returns {Promise<void>}
269
+ */
270
+ async notify(mailbox, event, data, extraOpts) {
271
+ extraOpts = extraOpts || {};
272
+ const { skipWebhook, canSync = true } = extraOpts;
273
+
274
+ // Track event metrics
275
+ postMetrics({ account: this.account }, this.logger, 'events', 'inc', { event });
276
+
277
+ // Get service URL for notification payload
278
+ const serviceUrl = (await settings.get('serviceUrl')) || null;
279
+
280
+ // Build notification payload
281
+ const payload = this.buildPayload(mailbox, event, data, serviceUrl);
282
+
283
+ // Determine if we need to sync with document store
284
+ const addDocumentQueueJob = await this.shouldSyncDocuments(event, canSync);
285
+
286
+ // Generate thread ID for new messages if needed
287
+ if (addDocumentQueueJob && event === MESSAGE_NEW_NOTIFY) {
288
+ await this.generateThreadId(payload);
289
+ }
290
+
291
+ // Get queue retention setting
292
+ const queueKeep = (await settings.get('queueKeep')) || true;
293
+
294
+ // Process notification based on required destinations
295
+ if (!skipWebhook && addDocumentQueueJob) {
296
+ // Add both webhook and document jobs as a flow
297
+ await this.processWithFlow(event, payload, queueKeep);
298
+ } else if (!skipWebhook) {
299
+ // Webhook only
300
+ await this.processWebhookOnly(event, payload);
301
+ } else if (addDocumentQueueJob) {
302
+ // Document store only
303
+ await this.processDocumentOnly(event, payload);
304
+ }
305
+ }
306
+ }
307
+
308
+ module.exports = {
309
+ NotificationHandler,
310
+ DOCUMENT_SYNC_EVENTS,
311
+ DEFAULT_JOB_OPTIONS,
312
+ DOCUMENT_JOB_OPTIONS,
313
+ postMetrics
314
+ };
@@ -0,0 +1,326 @@
1
+ 'use strict';
2
+
3
+ const { metricsMeta } = require('../base-client');
4
+
5
+ const { OUTLOOK_MAX_BATCH_SIZE, OUTLOOK_MAX_RETRY_ATTEMPTS, OUTLOOK_RETRY_BASE_DELAY, OUTLOOK_RETRY_MAX_DELAY } = require('../../consts');
6
+
7
+ // Maximum number of operations in a single batch request to Microsoft Graph API
8
+ const MAX_BATCH_SIZE = OUTLOOK_MAX_BATCH_SIZE;
9
+
10
+ // MS Graph API error code mapping to internal error codes
11
+ // https://learn.microsoft.com/en-us/graph/errors
12
+ const GRAPH_ERROR_MAP = {
13
+ ErrorItemNotFound: { code: 'MessageNotFound', status: 404 },
14
+ ErrorInvalidIdMalformed: { code: 'InvalidMessageId', status: 400 },
15
+ ErrorAccessDenied: { code: 'AccessDenied', status: 403 },
16
+ ErrorQuotaExceeded: { code: 'QuotaExceeded', status: 429 },
17
+ ErrorExecuteSearchStaleData: { code: 'SearchCursorExpired', status: 400 },
18
+ ErrorMailboxNotEnabledForRESTAPI: { code: 'MailboxNotEnabled', status: 403 },
19
+ ErrorInvalidRecipients: { code: 'InvalidRecipients', status: 400 },
20
+ ErrorMessageSizeExceeded: { code: 'MessageTooLarge', status: 413 },
21
+ ErrorSendAsDenied: { code: 'SendAsDenied', status: 403 }
22
+ };
23
+
24
+ /**
25
+ * Creates an error from a Graph API error response
26
+ * @param {Object} graphError - The error object from Graph API
27
+ * @param {string} graphErrorCode - The error code from Graph API
28
+ * @returns {Error} A formatted error object
29
+ */
30
+ function createGraphError(graphError, graphErrorCode) {
31
+ const mappedError = GRAPH_ERROR_MAP[graphErrorCode];
32
+ if (!mappedError) {
33
+ return null;
34
+ }
35
+
36
+ const error = new Error(graphError?.message || graphErrorCode);
37
+ error.code = mappedError.code;
38
+ error.statusCode = mappedError.status;
39
+ error.graphErrorCode = graphErrorCode;
40
+ return error;
41
+ }
42
+
43
+ /**
44
+ * Makes authenticated requests to Microsoft Graph API
45
+ * Handles token management and error responses
46
+ *
47
+ * @param {Object} context - The client context (OutlookClient instance)
48
+ * @param {string} url - API endpoint URL
49
+ * @param {string} method - HTTP method
50
+ * @param {*} payload - Request payload
51
+ * @param {Object} options - Request options
52
+ * @returns {Promise<*>} API response
53
+ */
54
+ async function request(context, url, method, payload, options = {}) {
55
+ let result, accessToken;
56
+
57
+ try {
58
+ accessToken = await context.getToken();
59
+ } catch (err) {
60
+ context.logger.error({ msg: 'Failed to load access token', account: context.account, err });
61
+ throw err;
62
+ }
63
+
64
+ try {
65
+ if (!context.oAuth2Client) {
66
+ await context.getClient();
67
+ }
68
+
69
+ options.headers = options.headers || {};
70
+
71
+ // Build Prefer header with multiple preferences
72
+ // Request immutable IDs that don't change when messages are moved between folders
73
+ // https://learn.microsoft.com/en-us/graph/outlook-immutable-id
74
+ let preferValues = ['IdType="ImmutableId"'];
75
+
76
+ // If caller already set a Prefer header, merge it
77
+ if (options.headers.Prefer) {
78
+ preferValues.push(options.headers.Prefer);
79
+ }
80
+
81
+ options.headers.Prefer = preferValues.join(', ');
82
+
83
+ // Construct full API URL if not already absolute
84
+ let apiUrl = /^https:/.test(url) ? url : new URL(`/v1.0${url}`, context.oAuth2Client.apiBase).href;
85
+
86
+ result = await context.oAuth2Client.request(accessToken, apiUrl, method, payload, options);
87
+
88
+ // Track successful API request
89
+ metricsMeta({ account: context.account }, context.logger, 'oauth2ApiRequest', 'inc', {
90
+ status: 'success',
91
+ provider: 'outlook',
92
+ statusCode: '200'
93
+ });
94
+ } catch (err) {
95
+ // Track failed API request
96
+ const statusCode = String(err.oauthRequest?.status || 0);
97
+ metricsMeta({ account: context.account }, context.logger, 'oauth2ApiRequest', 'inc', {
98
+ status: 'failure',
99
+ provider: 'outlook',
100
+ statusCode
101
+ });
102
+
103
+ // Handle specific Graph API error codes using standardized mapping
104
+ const graphErrorCode = err.oauthRequest?.response?.error?.code;
105
+ const graphError = createGraphError(err.oauthRequest?.response?.error, graphErrorCode);
106
+
107
+ if (graphError) {
108
+ context.logger.debug({
109
+ msg: 'Graph API error mapped to internal code',
110
+ account: context.account,
111
+ graphErrorCode,
112
+ internalCode: graphError.code
113
+ });
114
+ throw graphError;
115
+ }
116
+
117
+ // Handle HTTP status codes
118
+ const status = err.oauthRequest?.status;
119
+ const isClientError = status >= 400 && status < 500;
120
+
121
+ switch (status) {
122
+ case 401:
123
+ context.logger.error({ msg: 'Failed to authenticate API request', account: context.account, accessToken, err });
124
+ throw err;
125
+
126
+ case 429:
127
+ // Rate limiting
128
+ context.logger.error({ msg: 'API request was throttled', account: context.account, err });
129
+ throw err;
130
+
131
+ default:
132
+ // Log client errors (4xx) at debug level - these are expected operational errors
133
+ // Log server errors (5xx) and other failures at error level
134
+ if (isClientError) {
135
+ context.logger.debug({ msg: 'API request failed with client error', account: context.account, err });
136
+ } else {
137
+ context.logger.error({ msg: 'Failed to run API request', account: context.account, err });
138
+ }
139
+ throw err;
140
+ }
141
+ }
142
+
143
+ return result;
144
+ }
145
+
146
+ /**
147
+ * Makes authenticated requests to Microsoft Graph API with automatic retry on rate limiting
148
+ * Implements exponential backoff using Retry-After header or default delays
149
+ *
150
+ * @param {Object} context - The client context (OutlookClient instance)
151
+ * @param {string} url - API endpoint URL
152
+ * @param {string} method - HTTP method
153
+ * @param {*} payload - Request payload
154
+ * @param {Object} options - Request options
155
+ * @param {number} options.maxRetries - Maximum number of retries (default: 3)
156
+ * @returns {Promise<*>} API response
157
+ */
158
+ async function requestWithRetry(context, url, method, payload, options = {}) {
159
+ const maxRetries = options.maxRetries ?? OUTLOOK_MAX_RETRY_ATTEMPTS;
160
+ let lastError;
161
+
162
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
163
+ try {
164
+ return await request(context, url, method, payload, options);
165
+ } catch (err) {
166
+ // Only retry on 429 (rate limit) errors
167
+ if (err.oauthRequest?.status !== 429 || attempt === maxRetries) {
168
+ throw err;
169
+ }
170
+
171
+ lastError = err;
172
+
173
+ // Use Retry-After header if available, otherwise use exponential backoff
174
+ const retryAfter = err.retryAfter || Math.min(OUTLOOK_RETRY_BASE_DELAY * Math.pow(2, attempt), OUTLOOK_RETRY_MAX_DELAY);
175
+
176
+ context.logger.info({
177
+ msg: 'Rate limited, retrying after delay',
178
+ account: context.account,
179
+ attempt: attempt + 1,
180
+ maxRetries,
181
+ retryAfterSeconds: retryAfter,
182
+ url
183
+ });
184
+
185
+ await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
186
+ }
187
+ }
188
+
189
+ throw lastError;
190
+ }
191
+
192
+ /**
193
+ * Creates common headers for batch requests
194
+ * @returns {Object} Headers object with Content-Type and ImmutableId preference
195
+ */
196
+ function getBatchHeaders() {
197
+ return {
198
+ 'Content-Type': 'application/json',
199
+ Prefer: 'IdType="ImmutableId"'
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Submits a batch request to the Graph API
205
+ *
206
+ * @param {Object} context - The client context (OutlookClient instance)
207
+ * @param {Array} requests - Array of batch request items
208
+ * @returns {Promise<Object>} Batch response with responses array
209
+ */
210
+ async function submitBatchRequest(context, requests) {
211
+ return await requestWithRetry(context, '/$batch', 'post', { requests });
212
+ }
213
+
214
+ /**
215
+ * Processes batch responses and categorizes them as successful or failed
216
+ *
217
+ * @param {Object} responseData - The batch response from Graph API
218
+ * @param {Map} messageMap - Map of request IDs to email IDs
219
+ * @param {Object} logger - Logger instance
220
+ * @param {string} account - Account identifier for logging
221
+ * @param {string} operation - Operation name for logging (e.g., 'delete', 'update', 'move')
222
+ * @returns {Object} Object with successIds and failedIds arrays
223
+ */
224
+ function processBatchResponses(responseData, messageMap, logger, account, operation) {
225
+ const successIds = [];
226
+ const failedIds = [];
227
+
228
+ for (const response of responseData?.responses || []) {
229
+ const emailId = messageMap.get(response.id);
230
+ if (response?.status >= 200 && response?.status < 300) {
231
+ if (emailId) {
232
+ successIds.push(emailId);
233
+ }
234
+ } else {
235
+ if (emailId) {
236
+ failedIds.push(emailId);
237
+ }
238
+ // Log individual batch item failures for debugging
239
+ logger.warn({
240
+ msg: 'Batch item failed',
241
+ account,
242
+ operation,
243
+ emailId,
244
+ status: response?.status,
245
+ error: response?.body?.error
246
+ });
247
+ }
248
+ }
249
+
250
+ return { successIds, failedIds };
251
+ }
252
+
253
+ /**
254
+ * Executes batch operations on messages with automatic chunking
255
+ *
256
+ * @param {Object} context - The client context (OutlookClient instance)
257
+ * @param {Array<string>} emailIds - Array of email IDs to process
258
+ * @param {Function} formatRequest - Function that takes (emailId, requestId) and returns a batch request object
259
+ * @param {string} operation - Operation name for logging
260
+ * @returns {Promise<Array<string>>} Array of successfully processed email IDs
261
+ */
262
+ async function executeBatchOperation(context, emailIds, formatRequest, operation) {
263
+ let batch = [];
264
+ let idGen = 0;
265
+ let successfulIds = [];
266
+ const messageMap = new Map();
267
+
268
+ const submitBatch = async () => {
269
+ if (batch.length === 0) {
270
+ return;
271
+ }
272
+
273
+ try {
274
+ const responseData = await submitBatchRequest(context, batch);
275
+ const { successIds } = processBatchResponses(responseData, messageMap, context.logger, context.account, operation);
276
+ successfulIds = successfulIds.concat(successIds);
277
+ } catch (err) {
278
+ context.logger.error({
279
+ msg: 'Failed to run batch operation',
280
+ account: context.account,
281
+ operation,
282
+ err
283
+ });
284
+ throw err;
285
+ } finally {
286
+ batch = [];
287
+ messageMap.clear();
288
+ }
289
+ };
290
+
291
+ for (const emailId of emailIds) {
292
+ const reqId = `msg_${++idGen}`;
293
+ messageMap.set(reqId, emailId);
294
+ batch.push(formatRequest(emailId, reqId));
295
+
296
+ if (batch.length >= MAX_BATCH_SIZE) {
297
+ await submitBatch();
298
+ }
299
+ }
300
+
301
+ // Submit remaining batch
302
+ if (batch.length > 0) {
303
+ await submitBatch();
304
+ }
305
+
306
+ return successfulIds;
307
+ }
308
+
309
+ module.exports = {
310
+ // Constants
311
+ GRAPH_ERROR_MAP,
312
+ MAX_BATCH_SIZE,
313
+
314
+ // Request functions
315
+ request,
316
+ requestWithRetry,
317
+
318
+ // Batch operation helpers
319
+ getBatchHeaders,
320
+ submitBatchRequest,
321
+ processBatchResponses,
322
+ executeBatchOperation,
323
+
324
+ // Error handling
325
+ createGraphError
326
+ };