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.
- package/CHANGELOG.md +17 -0
- package/data/google-crawlers.json +1 -1
- package/lib/account/account-state.js +248 -0
- package/lib/account.js +45 -193
- package/lib/api-routes/account-routes.js +1023 -0
- package/lib/api-routes/message-routes.js +1377 -0
- package/lib/consts.js +12 -2
- package/lib/email-client/base-client.js +282 -771
- package/lib/email-client/gmail/gmail-api.js +243 -0
- package/lib/email-client/gmail-client.js +145 -53
- package/lib/email-client/imap/mailbox.js +24 -698
- package/lib/email-client/imap/sync-operations.js +812 -0
- package/lib/email-client/imap-client.js +1 -1
- package/lib/email-client/message-builder.js +566 -0
- package/lib/email-client/notification-handler.js +314 -0
- package/lib/email-client/outlook/graph-api.js +326 -0
- package/lib/email-client/outlook-client.js +159 -113
- package/lib/email-client/smtp-pool-manager.js +196 -0
- package/lib/imapproxy/imap-server.js +3 -12
- package/lib/oauth/gmail.js +4 -4
- package/lib/oauth/mail-ru.js +30 -5
- package/lib/oauth/outlook.js +57 -3
- package/lib/oauth/pubsub/google.js +30 -11
- package/lib/oauth/scope-checker.js +202 -0
- package/lib/oauth2-apps.js +8 -4
- package/lib/redis-operations.js +484 -0
- package/lib/routes-ui.js +283 -2582
- package/lib/tools.js +4 -196
- package/lib/ui-routes/account-routes.js +1931 -0
- package/lib/ui-routes/admin-config-routes.js +1233 -0
- package/lib/ui-routes/admin-entities-routes.js +2367 -0
- package/lib/ui-routes/oauth-routes.js +992 -0
- package/lib/utils/network.js +237 -0
- package/package.json +10 -10
- package/sbom.json +1 -1
- package/static/js/app.js +5 -5
- package/static/licenses.html +79 -19
- package/translations/de.mo +0 -0
- package/translations/de.po +97 -86
- package/translations/en.mo +0 -0
- package/translations/en.po +80 -75
- package/translations/et.mo +0 -0
- package/translations/et.po +96 -86
- package/translations/fr.mo +0 -0
- package/translations/fr.po +97 -86
- package/translations/ja.mo +0 -0
- package/translations/ja.po +96 -86
- package/translations/messages.pot +105 -91
- package/translations/nl.mo +0 -0
- package/translations/nl.po +98 -86
- package/translations/pl.mo +0 -0
- package/translations/pl.po +96 -86
- package/views/account/security.hbs +4 -4
- package/views/accounts/account.hbs +13 -13
- package/views/accounts/register/imap-server.hbs +12 -12
- package/views/config/document-store/pre-processing/index.hbs +4 -2
- package/views/config/oauth/app.hbs +6 -7
- package/views/config/oauth/index.hbs +2 -2
- package/views/config/service.hbs +3 -4
- package/views/dashboard.hbs +5 -7
- package/views/error.hbs +22 -7
- package/views/gateways/gateway.hbs +2 -2
- package/views/partials/add_account_modal.hbs +7 -10
- package/views/partials/document_store_header.hbs +1 -1
- package/views/partials/editor_scope_info.hbs +0 -1
- package/views/partials/oauth_config_header.hbs +1 -1
- package/views/partials/side_menu.hbs +3 -3
- package/views/partials/webhook_form.hbs +2 -2
- package/views/templates/index.hbs +1 -1
- package/views/templates/template.hbs +8 -8
- package/views/tokens/index.hbs +6 -6
- package/views/tokens/new.hbs +1 -1
- package/views/webhooks/index.hbs +4 -4
- package/views/webhooks/webhook.hbs +7 -7
- package/workers/api.js +148 -2436
- package/workers/smtp.js +2 -1
- package/lib/imapproxy/imap-core/test/client.js +0 -46
- package/lib/imapproxy/imap-core/test/fixtures/append.eml +0 -1196
- package/lib/imapproxy/imap-core/test/fixtures/chunks.js +0 -44
- package/lib/imapproxy/imap-core/test/fixtures/fix1.eml +0 -6
- package/lib/imapproxy/imap-core/test/fixtures/fix2.eml +0 -599
- package/lib/imapproxy/imap-core/test/fixtures/fix3.eml +0 -32
- package/lib/imapproxy/imap-core/test/fixtures/fix4.eml +0 -6
- package/lib/imapproxy/imap-core/test/fixtures/mimetorture.eml +0 -599
- package/lib/imapproxy/imap-core/test/fixtures/mimetorture.js +0 -2740
- package/lib/imapproxy/imap-core/test/fixtures/mimetorture.json +0 -1411
- package/lib/imapproxy/imap-core/test/fixtures/mimetree.js +0 -85
- package/lib/imapproxy/imap-core/test/fixtures/nodemailer.eml +0 -582
- package/lib/imapproxy/imap-core/test/fixtures/ryan_finnie_mime_torture.eml +0 -599
- package/lib/imapproxy/imap-core/test/fixtures/simple.eml +0 -42
- package/lib/imapproxy/imap-core/test/fixtures/simple.json +0 -164
- package/lib/imapproxy/imap-core/test/imap-compile-stream-test.js +0 -671
- package/lib/imapproxy/imap-core/test/imap-compiler-test.js +0 -272
- package/lib/imapproxy/imap-core/test/imap-indexer-test.js +0 -236
- package/lib/imapproxy/imap-core/test/imap-parser-test.js +0 -922
- package/lib/imapproxy/imap-core/test/memory-notifier.js +0 -129
- package/lib/imapproxy/imap-core/test/prepare.sh +0 -74
- package/lib/imapproxy/imap-core/test/protocol-test.js +0 -1756
- package/lib/imapproxy/imap-core/test/search-test.js +0 -1356
- package/lib/imapproxy/imap-core/test/test-client.js +0 -152
- package/lib/imapproxy/imap-core/test/test-server.js +0 -623
- package/lib/imapproxy/imap-core/test/tools-test.js +0 -22
- package/test/api-test.js +0 -899
- package/test/autoreply-test.js +0 -327
- package/test/bounce-test.js +0 -151
- package/test/complaint-test.js +0 -256
- package/test/fixtures/autoreply/LICENSE +0 -27
- package/test/fixtures/autoreply/rfc3834-01.eml +0 -23
- package/test/fixtures/autoreply/rfc3834-02.eml +0 -24
- package/test/fixtures/autoreply/rfc3834-03.eml +0 -26
- package/test/fixtures/autoreply/rfc3834-04.eml +0 -48
- package/test/fixtures/autoreply/rfc3834-05.eml +0 -19
- package/test/fixtures/autoreply/rfc3834-06.eml +0 -59
- package/test/fixtures/bounces/163.eml +0 -2521
- package/test/fixtures/bounces/fastmail.eml +0 -242
- package/test/fixtures/bounces/gmail.eml +0 -252
- package/test/fixtures/bounces/hotmail.eml +0 -655
- package/test/fixtures/bounces/mailru.eml +0 -121
- package/test/fixtures/bounces/outlook.eml +0 -1107
- package/test/fixtures/bounces/postfix.eml +0 -101
- package/test/fixtures/bounces/rambler.eml +0 -116
- package/test/fixtures/bounces/workmail.eml +0 -142
- package/test/fixtures/bounces/yahoo.eml +0 -139
- package/test/fixtures/bounces/zoho.eml +0 -83
- package/test/fixtures/bounces/zonemta.eml +0 -100
- package/test/fixtures/complaints/LICENSE +0 -27
- package/test/fixtures/complaints/amazonses.eml +0 -72
- package/test/fixtures/complaints/dmarc.eml +0 -59
- package/test/fixtures/complaints/hotmail.eml +0 -49
- package/test/fixtures/complaints/optout.eml +0 -40
- package/test/fixtures/complaints/standard-arf.eml +0 -68
- package/test/fixtures/complaints/yahoo.eml +0 -68
- package/test/oauth2-apps-test.js +0 -301
- package/test/sendonly-test.js +0 -160
- package/test/test-config.js +0 -34
- 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
|
+
};
|