@3sixty/mailbox-connector-gmail 0.0.0-stage → 0.20.0-next.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 (38) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +76 -2
  3. package/dist/es/connectorSchema/gmailEmailConnectorConfigSchema.js +51 -0
  4. package/dist/es/connectorSchema/gmailEmailConnectorConfigSchema.js.map +1 -0
  5. package/dist/es/connectorSchema/gmailEmailConnectorStateSchema.js +45 -0
  6. package/dist/es/connectorSchema/gmailEmailConnectorStateSchema.js.map +1 -0
  7. package/dist/es/gmailEmailConnector.js +773 -0
  8. package/dist/es/gmailEmailConnector.js.map +1 -0
  9. package/dist/es/index.js +10 -0
  10. package/dist/es/index.js.map +1 -0
  11. package/dist/es/models/IGmailEmailConnectorConfig.js +4 -0
  12. package/dist/es/models/IGmailEmailConnectorConfig.js.map +1 -0
  13. package/dist/es/models/IGmailEmailConnectorConstructorOptions.js +2 -0
  14. package/dist/es/models/IGmailEmailConnectorConstructorOptions.js.map +1 -0
  15. package/dist/es/models/IGmailEmailConnectorState.js +4 -0
  16. package/dist/es/models/IGmailEmailConnectorState.js.map +1 -0
  17. package/dist/es/schema.js +17 -0
  18. package/dist/es/schema.js.map +1 -0
  19. package/dist/types/connectorSchema/gmailEmailConnectorConfigSchema.d.ts +5 -0
  20. package/dist/types/connectorSchema/gmailEmailConnectorStateSchema.d.ts +7 -0
  21. package/dist/types/gmailEmailConnector.d.ts +64 -0
  22. package/dist/types/index.d.ts +7 -0
  23. package/dist/types/models/IGmailEmailConnectorConfig.d.ts +56 -0
  24. package/dist/types/models/IGmailEmailConnectorConstructorOptions.d.ts +19 -0
  25. package/dist/types/models/IGmailEmailConnectorState.d.ts +39 -0
  26. package/dist/types/schema.d.ts +7 -0
  27. package/docs/changelog.md +0 -0
  28. package/docs/examples.md +174 -0
  29. package/docs/reference/classes/GmailEmailConnector.md +218 -0
  30. package/docs/reference/functions/initSchema.md +12 -0
  31. package/docs/reference/index.md +20 -0
  32. package/docs/reference/interfaces/IGmailEmailConnectorConfig.md +110 -0
  33. package/docs/reference/interfaces/IGmailEmailConnectorConstructorOptions.md +33 -0
  34. package/docs/reference/interfaces/IGmailEmailConnectorState.md +63 -0
  35. package/docs/reference/variables/GmailEmailConnectorConfigSchema.md +5 -0
  36. package/docs/reference/variables/GmailEmailConnectorStateSchema.md +7 -0
  37. package/locales/en.json +41 -0
  38. package/package.json +57 -5
@@ -0,0 +1,773 @@
1
+ import { BaseError, Coerce, ComponentFactory, Converter, GeneralError, Guards, Is, Mutex } from "@3sixty/core";
2
+ import { MailHelper } from "@3sixty/mailbox-models";
3
+ import { google } from "googleapis";
4
+ import { initSchema } from "./schema.js";
5
+ /**
6
+ * Gmail email protocol connector. Monitors a mailbox for new messages using the Gmail API.
7
+ */
8
+ export class GmailEmailConnector {
9
+ /**
10
+ * The class name.
11
+ */
12
+ static CLASS_NAME = "GmailEmailConnector";
13
+ /**
14
+ * The protocol namespace identifier.
15
+ */
16
+ static NAMESPACE = "gmail";
17
+ /**
18
+ * The OAuth 2.0 scopes the connector requires on the mailbox.
19
+ * @internal
20
+ */
21
+ static _SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"];
22
+ /**
23
+ * The OAuth 2.0 error codes which mean the mailbox cannot authenticate as it is configured.
24
+ * @internal
25
+ */
26
+ static _OAUTH_FAILURE_CODES = [
27
+ "invalid_grant",
28
+ "invalid_client",
29
+ "unauthorized_client"
30
+ ];
31
+ /**
32
+ * The OAuth 2.0 error code which means the stored refresh token itself is no longer valid.
33
+ * The other failure codes describe the client rather than the token, so they must not cause
34
+ * a working token to be discarded.
35
+ * @internal
36
+ */
37
+ static _OAUTH_TOKEN_REJECTED_CODE = "invalid_grant";
38
+ /**
39
+ * The Gmail API failure reasons which report a quota or rate limit rather than a credential
40
+ * problem. They share the 403 status with the permission failures, so they are named here to
41
+ * keep a throttled poll from being treated as an authentication failure.
42
+ * @internal
43
+ */
44
+ static _QUOTA_FAILURE_REASONS = [
45
+ "rateLimitExceeded",
46
+ "userRateLimitExceeded",
47
+ "dailyLimitExceeded",
48
+ "quotaExceeded",
49
+ "backendError"
50
+ ];
51
+ /**
52
+ * The Google credentials property holding the refresh token.
53
+ * @internal
54
+ */
55
+ static _REFRESH_TOKEN_KEY = "refresh_token";
56
+ /**
57
+ * The Google consent URL property requesting a refresh token.
58
+ * @internal
59
+ */
60
+ static _ACCESS_TYPE_KEY = "access_type";
61
+ /**
62
+ * The Google consent URL property preselecting the mailbox account.
63
+ * @internal
64
+ */
65
+ static _LOGIN_HINT_KEY = "login_hint";
66
+ /**
67
+ * The Google property naming where a consent flow returns to.
68
+ * @internal
69
+ */
70
+ static _REDIRECT_URI_KEY = "redirect_uri";
71
+ /**
72
+ * The logging component.
73
+ * @internal
74
+ */
75
+ _logging;
76
+ /**
77
+ * The task scheduler component used for polling.
78
+ * @internal
79
+ */
80
+ _taskScheduler;
81
+ /**
82
+ * The resolved configuration for this connector instance.
83
+ * @internal
84
+ */
85
+ _config;
86
+ /**
87
+ * The OAuth 2.0 client used to authenticate Gmail API calls.
88
+ * @internal
89
+ */
90
+ _authClient;
91
+ /**
92
+ * The Gmail API client, created on the first poll and reused afterwards.
93
+ * @internal
94
+ */
95
+ _gmail;
96
+ /**
97
+ * The identifier of the currently active instance being polled.
98
+ * @internal
99
+ */
100
+ _instanceId;
101
+ /**
102
+ * Set when retrieve has been called and cleared when retrieveStop is called.
103
+ * @internal
104
+ */
105
+ _retrieving;
106
+ /**
107
+ * The connector state for the instance currently being polled.
108
+ * @internal
109
+ */
110
+ _state;
111
+ /**
112
+ * The fixed URI the consent flow returns to, supplied by the owning component on retrieve.
113
+ * @internal
114
+ */
115
+ _callbackUri;
116
+ /**
117
+ * The value the consent flow carries so the response correlates back to the mailbox,
118
+ * supplied by the owning component on retrieve.
119
+ * @internal
120
+ */
121
+ _correlationState;
122
+ /**
123
+ * The auth callback given by retrieve, used to report the outcome of the consent flow.
124
+ * @internal
125
+ */
126
+ _authCallback;
127
+ /**
128
+ * Create a new instance of GmailEmailConnector.
129
+ * @param options The options for the connector.
130
+ * @throws GeneralError if neither a service account key nor an OAuth client is supplied.
131
+ */
132
+ constructor(options) {
133
+ Guards.object(GmailEmailConnector.CLASS_NAME, "options", options);
134
+ Guards.object(GmailEmailConnector.CLASS_NAME, "options.config", options.config);
135
+ Guards.stringValue(GmailEmailConnector.CLASS_NAME, "options.config.emailAddress", options.config.emailAddress);
136
+ // The refresh token is deliberately not required, a mailbox can be created with just the
137
+ // OAuth client and the connector then drives the consent flow through the auth callback.
138
+ if (!Is.stringValue(options.config.serviceAccountKey) &&
139
+ !(Is.stringValue(options.config.clientId) && Is.stringValue(options.config.clientSecret))) {
140
+ throw new GeneralError(GmailEmailConnector.CLASS_NAME, "missingOAuthCredentials");
141
+ }
142
+ this._logging = ComponentFactory.getIfExists(options.loggingComponentType);
143
+ this._taskScheduler = ComponentFactory.get(options.taskSchedulerComponentType ?? "task-scheduler");
144
+ this._config = {
145
+ emailAddress: options.config.emailAddress,
146
+ clientId: options.config.clientId,
147
+ clientSecret: options.config.clientSecret,
148
+ serviceAccountKey: options.config.serviceAccountKey,
149
+ labelIds: Is.arrayValue(options.config.labelIds) ? options.config.labelIds : ["INBOX"],
150
+ maxMessagesPerPoll: Math.max(1, options.config.maxMessagesPerPoll ?? 50),
151
+ maxDeliveredIdHistory: Math.max(1, options.config.maxDeliveredIdHistory ?? 200),
152
+ pollingIntervalMinutes: Math.max(1, options.config.pollingIntervalMinutes ?? 2),
153
+ mutexTimeoutMs: options.config.mutexTimeoutMs
154
+ };
155
+ this._authClient = this.createAuthClient();
156
+ this._retrieving = false;
157
+ // A connector registers its own schemas so the owning component can recognise the
158
+ // secure properties of the configuration and state it persists.
159
+ initSchema();
160
+ }
161
+ /**
162
+ * Get the class name.
163
+ * @returns The class name.
164
+ */
165
+ className() {
166
+ return GmailEmailConnector.CLASS_NAME;
167
+ }
168
+ /**
169
+ * Start the internal polling loop for the given instance.
170
+ * @param instanceId The identifier of the mailbox instance being polled.
171
+ * @param state The current connector state for the instance.
172
+ * @param authCallback Callback invoked when authentication fails during a poll cycle.
173
+ * @param retrievalCallback Callback invoked with retrieved messages after each poll cycle.
174
+ * @param options Options supplied by the owning component, carrying the consent callback URI.
175
+ * @returns A promise that resolves when the polling loop has been started.
176
+ */
177
+ async retrieve(instanceId, state, authCallback, retrievalCallback, options) {
178
+ Guards.object(GmailEmailConnector.CLASS_NAME, "options", options);
179
+ // The callback URI is only needed to produce a consent URL, so a mailbox which already
180
+ // holds credentials still polls without one.
181
+ await this.retrieveStop();
182
+ this._retrieving = true;
183
+ this._instanceId = instanceId;
184
+ this._state = state;
185
+ this._callbackUri = options.callbackUri;
186
+ this._correlationState = options.correlationState;
187
+ this._authCallback = authCallback;
188
+ // The refresh token is issued by the consent flow rather than configured, so it arrives
189
+ // with the state and has to be handed to the client the constructor built.
190
+ if (Is.stringValue(state?.refreshToken)) {
191
+ this._authClient.setCredentials({
192
+ [GmailEmailConnector._REFRESH_TOKEN_KEY]: state.refreshToken
193
+ });
194
+ }
195
+ await this._taskScheduler.addTask(instanceId, [{ intervalMinutes: this._config.pollingIntervalMinutes }], async () => {
196
+ await this.poll(authCallback, retrievalCallback);
197
+ });
198
+ }
199
+ /**
200
+ * Start the consent flow for a mailbox which has no refresh token yet.
201
+ * @param instanceId The identifier of the mailbox instance being authenticated.
202
+ * @param state The current connector state for the instance.
203
+ * @param options Options supplied by the owning component, carrying the consent callback URI
204
+ * and the state which correlates the consent response back to the mailbox.
205
+ * @returns The auth state carrying the consent URL to open, or undefined when the mailbox can
206
+ * already authenticate itself from a service account key or a refresh token in its state.
207
+ */
208
+ async initiateAuth(instanceId, state, options) {
209
+ Guards.object(GmailEmailConnector.CLASS_NAME, "options", options);
210
+ Guards.stringValue(GmailEmailConnector.CLASS_NAME, "options.callbackUri", options.callbackUri);
211
+ if (!this.needsConsent(state)) {
212
+ return undefined;
213
+ }
214
+ return this.buildAuthState(options.callbackUri, options.correlationState);
215
+ }
216
+ /**
217
+ * Complete the consent flow with the authorisation code handed to the redirect URI.
218
+ * The issued credentials are applied to this connector instance so the next poll uses them,
219
+ * and the refresh token is placed in the state reported through the auth callback given by
220
+ * retrieve, so the owning component vaults it and lifts the authentication halt.
221
+ * @param instanceId The identifier of the mailbox instance being authenticated.
222
+ * @param authPayload The redirect query, carrying the authorisation code as its code property.
223
+ * @param options Options supplied by the owning component, carrying the consent callback URI.
224
+ * @returns A promise that resolves when the flow has been completed.
225
+ * @throws GeneralError if the authorisation code could not be exchanged for tokens.
226
+ */
227
+ async completeAuth(instanceId, authPayload, options) {
228
+ Guards.object(GmailEmailConnector.CLASS_NAME, "options", options);
229
+ Guards.stringValue(GmailEmailConnector.CLASS_NAME, "options.callbackUri", options.callbackUri);
230
+ const payload = Is.object(authPayload) ? authPayload : undefined;
231
+ const code = Coerce.string(payload?.code);
232
+ Guards.stringValue(GmailEmailConnector.CLASS_NAME, "code", code);
233
+ // The authorisation code can only be exchanged once, so the connector refuses before
234
+ // spending it if it has no callback to report the issued token through.
235
+ if (Is.empty(this._authCallback)) {
236
+ throw new GeneralError(GmailEmailConnector.CLASS_NAME, "authFlowNotStarted");
237
+ }
238
+ let refreshToken;
239
+ try {
240
+ // Google requires the same redirect the consent URL was produced with.
241
+ const response = await this._authClient.getToken({
242
+ code,
243
+ [GmailEmailConnector._REDIRECT_URI_KEY]: options.callbackUri
244
+ });
245
+ this._authClient.setCredentials(response.tokens);
246
+ refreshToken = Coerce.string(response.tokens.refresh_token);
247
+ }
248
+ catch (error) {
249
+ throw new GeneralError(GmailEmailConnector.CLASS_NAME, "authCodeExchangeFailed", undefined, BaseError.fromError(error));
250
+ }
251
+ if (!Is.stringValue(refreshToken)) {
252
+ throw new GeneralError(GmailEmailConnector.CLASS_NAME, "noRefreshTokenIssued");
253
+ }
254
+ await this.guardConsentedAccount();
255
+ const state = this.applyRefreshToken(refreshToken);
256
+ // Continue the workflow through the auth callback, which clears the halt and persists the
257
+ // state, where the state schema marks the token as a vaulted field. A restarted connector
258
+ // is then handed the state with the token already in place.
259
+ await this._authCallback(instanceId, state, false);
260
+ }
261
+ /**
262
+ * Stop the polling loop for this connector.
263
+ * @returns A promise that resolves when the polling loop has been stopped.
264
+ */
265
+ async retrieveStop() {
266
+ if (Is.stringValue(this._instanceId)) {
267
+ this._retrieving = false;
268
+ await this._taskScheduler.removeTask(this._instanceId);
269
+ this._instanceId = undefined;
270
+ this._state = undefined;
271
+ this._correlationState = undefined;
272
+ this._authCallback = undefined;
273
+ }
274
+ }
275
+ /**
276
+ * Create the authentication client for the configured credentials.
277
+ * @returns The authentication client.
278
+ * @throws GeneralError if the service account key is not valid JSON containing a key pair.
279
+ * @internal
280
+ */
281
+ createAuthClient() {
282
+ if (Is.stringValue(this._config.serviceAccountKey)) {
283
+ const key = Coerce.object(this._config.serviceAccountKey);
284
+ const clientEmail = key?.client_email;
285
+ const privateKey = key?.private_key;
286
+ if (!Is.stringValue(clientEmail) || !Is.stringValue(privateKey)) {
287
+ throw new GeneralError(GmailEmailConnector.CLASS_NAME, "invalidServiceAccountKey");
288
+ }
289
+ return new google.auth.JWT({
290
+ email: clientEmail,
291
+ key: privateKey,
292
+ scopes: GmailEmailConnector._SCOPES,
293
+ subject: this._config.emailAddress
294
+ });
295
+ }
296
+ // The redirect URI is not set here, it is passed explicitly on the calls which need it so
297
+ // the value always matches the one the owning component supplied.
298
+ // The refresh token is not set here, it lives in the connector state and is applied once
299
+ // retrieve or the consent flow supplies it.
300
+ return new google.auth.OAuth2({
301
+ clientId: this._config.clientId,
302
+ clientSecret: this._config.clientSecret
303
+ });
304
+ }
305
+ /**
306
+ * Apply an issued refresh token to the state and the authentication client for later polls.
307
+ * @param refreshToken The refresh token to apply.
308
+ * @returns The state the token was applied to.
309
+ * @internal
310
+ */
311
+ applyRefreshToken(refreshToken) {
312
+ const state = this._state ?? {};
313
+ state.refreshToken = refreshToken;
314
+ this._state = state;
315
+ this._authClient.setCredentials({
316
+ [GmailEmailConnector._REFRESH_TOKEN_KEY]: refreshToken
317
+ });
318
+ return state;
319
+ }
320
+ /**
321
+ * Verify the account which gave consent is the mailbox the connector is configured for.
322
+ * @throws GeneralError if the consent was given for a different account.
323
+ * @internal
324
+ */
325
+ async guardConsentedAccount() {
326
+ // The correlating state travels through the browser, so consent can come back from an
327
+ // account other than the one the mailbox names. Binding the mailbox to it would ingest
328
+ // somebody else's email, so the issued credentials are checked before they are reported.
329
+ const profile = await google
330
+ .gmail({ version: "v1", auth: this._authClient })
331
+ .users.getProfile({ userId: "me" });
332
+ const consentedAddress = Coerce.string(profile.data.emailAddress);
333
+ if (consentedAddress?.toLowerCase() !== this._config.emailAddress.toLowerCase()) {
334
+ throw new GeneralError(GmailEmailConnector.CLASS_NAME, "consentAccountMismatch", {
335
+ emailAddress: this._config.emailAddress
336
+ });
337
+ }
338
+ }
339
+ /**
340
+ * Whether the connector is still waiting for the consent flow to supply a refresh token.
341
+ * @param state The connector state holding any token the consent flow has already issued.
342
+ * @returns True if consent is required before the mailbox can be polled.
343
+ * @internal
344
+ */
345
+ needsConsent(state) {
346
+ return !Is.stringValue(this._config.serviceAccountKey) && !Is.stringValue(state?.refreshToken);
347
+ }
348
+ /**
349
+ * Build the auth state describing how the mailbox can be authenticated.
350
+ * @param callbackUri The URI the consent flow returns to.
351
+ * @param correlationState The value the consent flow carries back so the response correlates
352
+ * to the mailbox it belongs to.
353
+ * @returns The auth state, or undefined if no consent URL can be produced.
354
+ * @internal
355
+ */
356
+ buildAuthState(callbackUri, correlationState) {
357
+ if (Is.stringValue(this._config.serviceAccountKey)) {
358
+ return undefined;
359
+ }
360
+ if (!Is.stringValue(callbackUri) || !Is.stringValue(correlationState)) {
361
+ return undefined;
362
+ }
363
+ return {
364
+ authUrl: this._authClient.generateAuthUrl({
365
+ [GmailEmailConnector._ACCESS_TYPE_KEY]: "offline",
366
+ [GmailEmailConnector._LOGIN_HINT_KEY]: this._config.emailAddress,
367
+ [GmailEmailConnector._REDIRECT_URI_KEY]: callbackUri,
368
+ prompt: "consent",
369
+ scope: GmailEmailConnector._SCOPES,
370
+ // The callback URI is fixed for the deployment, so the owning component supplies
371
+ // the value which correlates the response back to the mailbox and its partition.
372
+ state: correlationState
373
+ })
374
+ };
375
+ }
376
+ /**
377
+ * Get the Gmail API client, refreshing the access token if it has expired.
378
+ * @returns The Gmail API client.
379
+ * @internal
380
+ */
381
+ async connect() {
382
+ // Asking for an access token exchanges the refresh token or the service account key for a
383
+ // new one whenever the cached token has expired, so no manual intervention is needed.
384
+ await this._authClient.getAccessToken();
385
+ this._gmail ??= google.gmail({ version: "v1", auth: this._authClient });
386
+ return this._gmail;
387
+ }
388
+ /**
389
+ * Determine whether an error reports that the stored credentials were rejected.
390
+ * @param error The error thrown by the Gmail API or the token endpoint.
391
+ * @returns True if the error requires the mailbox to be re-authenticated.
392
+ * @internal
393
+ */
394
+ isAuthFailure(error) {
395
+ const status = this.errorStatus(error);
396
+ if (status === 401) {
397
+ return true;
398
+ }
399
+ // Gmail returns 403 both for a mailbox the credentials may not read and for a poll which
400
+ // exceeded a quota, so a throttled cycle is reported as a retrieval error instead.
401
+ if (status === 403) {
402
+ return !this.isQuotaFailure(error);
403
+ }
404
+ const failureCode = this.oauthFailureCode(error);
405
+ return (Is.stringValue(failureCode) && GmailEmailConnector._OAUTH_FAILURE_CODES.includes(failureCode));
406
+ }
407
+ /**
408
+ * Determine whether an error reports that the stored refresh token is no longer valid.
409
+ * @param error The error thrown by the Gmail API or the token endpoint.
410
+ * @returns True if the refresh token has expired or been revoked and has to be replaced.
411
+ * @internal
412
+ */
413
+ isRefreshTokenRejected(error) {
414
+ return this.oauthFailureCode(error) === GmailEmailConnector._OAUTH_TOKEN_REJECTED_CODE;
415
+ }
416
+ /**
417
+ * Determine whether an error reports a quota or rate limit rather than a credential problem.
418
+ * @param error The error thrown by the Gmail API.
419
+ * @returns True if the request was throttled.
420
+ * @internal
421
+ */
422
+ isQuotaFailure(error) {
423
+ const details = Is.object(error)
424
+ ? error
425
+ : undefined;
426
+ const data = Is.object(details?.response?.data)
427
+ ? details?.response?.data
428
+ : undefined;
429
+ const reasons = [details?.errors, data?.error?.errors]
430
+ .flatMap(entry => (Is.arrayValue(entry) ? entry : []))
431
+ .map(entry => Coerce.string(entry.reason))
432
+ .filter((reason) => Is.stringValue(reason));
433
+ return reasons.some(reason => GmailEmailConnector._QUOTA_FAILURE_REASONS.includes(reason));
434
+ }
435
+ /**
436
+ * Determine whether an error reports that the stored history identifier is too old to use.
437
+ * @param error The error thrown by the Gmail history API.
438
+ * @returns True if the history identifier has aged out of the mailbox history.
439
+ * @internal
440
+ */
441
+ isHistoryExpired(error) {
442
+ return this.errorStatus(error) === 404;
443
+ }
444
+ /**
445
+ * Extract the HTTP status code from a Gmail API error.
446
+ * @param error The error to inspect.
447
+ * @returns The status code, or undefined if the error carries none.
448
+ * @internal
449
+ */
450
+ errorStatus(error) {
451
+ const details = Is.object(error)
452
+ ? error
453
+ : undefined;
454
+ return (Coerce.number(details?.status) ??
455
+ Coerce.number(details?.code) ??
456
+ Coerce.number(details?.response?.status));
457
+ }
458
+ /**
459
+ * Extract the OAuth 2.0 failure code from a token endpoint error.
460
+ * @param error The error to inspect.
461
+ * @returns The failure code, or undefined if the error carries none.
462
+ * @internal
463
+ */
464
+ oauthFailureCode(error) {
465
+ const details = Is.object(error)
466
+ ? error
467
+ : undefined;
468
+ const data = Is.object(details?.response?.data)
469
+ ? details?.response?.data
470
+ : undefined;
471
+ if (Is.stringValue(data?.error)) {
472
+ return data.error;
473
+ }
474
+ // When the refresh request itself is rejected the library surfaces the code in the message.
475
+ const message = Coerce.string(details?.message);
476
+ return GmailEmailConnector._OAUTH_FAILURE_CODES.find(code => message?.includes(code));
477
+ }
478
+ /**
479
+ * Poll the mailbox for new messages.
480
+ * @param authCallback Callback invoked when authentication fails.
481
+ * @param retrievalCallback Callback invoked with retrieved messages.
482
+ * @internal
483
+ */
484
+ async poll(authCallback, retrievalCallback) {
485
+ if (Is.empty(this._state) || !Is.stringValue(this._instanceId)) {
486
+ return;
487
+ }
488
+ // Capture the instance id and state for this poll cycle, retrieveStop can clear the
489
+ // instance fields while a poll is in progress and the mutex must still be released
490
+ // under the key it was locked with.
491
+ const instanceId = this._instanceId;
492
+ const state = this._state;
493
+ await Mutex.lock(instanceId, {
494
+ timeoutMs: this._config.mutexTimeoutMs,
495
+ throwOnTimeout: true
496
+ });
497
+ try {
498
+ if (this.needsConsent(state)) {
499
+ await this._logging?.log({
500
+ level: "warn",
501
+ source: GmailEmailConnector.CLASS_NAME,
502
+ ts: Date.now(),
503
+ message: "consentRequired",
504
+ data: { mailboxId: instanceId }
505
+ });
506
+ // Hand the consent URL back through the auth callback and wait for the flow to
507
+ // complete, the mailbox has no refresh token to authenticate the Gmail API with.
508
+ await authCallback(instanceId, state, true, this.buildAuthState(this._callbackUri, this._correlationState));
509
+ return;
510
+ }
511
+ const gmail = await this.connect();
512
+ const completed = state.initialSyncComplete === true
513
+ ? await this.syncHistory(gmail, instanceId, state, retrievalCallback)
514
+ : await this.syncMessageList(gmail, instanceId, state, retrievalCallback);
515
+ if (completed) {
516
+ // Flush the sync progress even when no message was delivered, a cycle which only
517
+ // moved the page token or the history id on would otherwise repeat forever.
518
+ await retrievalCallback(instanceId, undefined, state);
519
+ }
520
+ }
521
+ catch (error) {
522
+ const pollError = new GeneralError(GmailEmailConnector.CLASS_NAME, "pollFailed", undefined, BaseError.fromError(error));
523
+ if (this.isAuthFailure(error)) {
524
+ // Only a token the provider rejected is cleared from the state. A client which is
525
+ // misconfigured, or a mailbox the credentials may not read, still halts the mailbox
526
+ // but must not discard a refresh token which is otherwise valid.
527
+ if (this.isRefreshTokenRejected(error)) {
528
+ state.refreshToken = undefined;
529
+ }
530
+ await authCallback(instanceId, state, true, this.buildAuthState(this._callbackUri, this._correlationState), pollError);
531
+ }
532
+ else {
533
+ await retrievalCallback(instanceId, undefined, state, pollError);
534
+ }
535
+ }
536
+ finally {
537
+ Mutex.unlock(instanceId);
538
+ }
539
+ }
540
+ /**
541
+ * Walk the existing mailbox contents one page per poll cycle, oldest message first.
542
+ * @param gmail The Gmail API client.
543
+ * @param instanceId The identifier of the mailbox instance being polled.
544
+ * @param state The connector state for the instance.
545
+ * @param retrievalCallback Callback invoked for each parsed email.
546
+ * @returns True if the page was fully processed, false if processing was aborted.
547
+ * @internal
548
+ */
549
+ async syncMessageList(gmail, instanceId, state, retrievalCallback) {
550
+ if (!Is.stringValue(state.historyId)) {
551
+ // Record where the mailbox history stands before the first page is listed, so the
552
+ // incremental sync which follows picks up everything that arrives in the meantime.
553
+ const profile = await gmail.users.getProfile({ userId: this._config.emailAddress });
554
+ state.historyId = Coerce.string(profile.data.historyId);
555
+ }
556
+ state.syncPageTokens ??= {};
557
+ state.syncCompletedLabelIds ??= [];
558
+ // The Gmail message list requires a message to carry every label it is given, so each
559
+ // configured label is walked with its own call and its own cursor.
560
+ for (const labelId of this._config.labelIds) {
561
+ if (!state.syncCompletedLabelIds.includes(labelId)) {
562
+ const continued = await this.syncLabelPage(gmail, instanceId, labelId, state, retrievalCallback);
563
+ if (!continued) {
564
+ return false;
565
+ }
566
+ }
567
+ }
568
+ if (state.syncCompletedLabelIds.length === this._config.labelIds.length) {
569
+ state.initialSyncComplete = true;
570
+ state.syncPageTokens = undefined;
571
+ state.syncCompletedLabelIds = undefined;
572
+ }
573
+ return true;
574
+ }
575
+ /**
576
+ * Walk a single page of one label's existing messages, oldest message first.
577
+ * @param gmail The Gmail API client.
578
+ * @param instanceId The identifier of the mailbox instance being polled.
579
+ * @param labelId The label to list.
580
+ * @param state The connector state for the instance.
581
+ * @param retrievalCallback Callback invoked for each parsed email.
582
+ * @returns True if the page was fully processed, false if processing was aborted.
583
+ * @internal
584
+ */
585
+ async syncLabelPage(gmail, instanceId, labelId, state, retrievalCallback) {
586
+ const list = await gmail.users.messages.list({
587
+ userId: this._config.emailAddress,
588
+ labelIds: [labelId],
589
+ maxResults: this._config.maxMessagesPerPoll,
590
+ pageToken: state.syncPageTokens?.[labelId],
591
+ includeSpamTrash: false
592
+ });
593
+ // The Gmail API returns the newest message first, deliver them in arrival order.
594
+ const messageIds = (list.data.messages ?? [])
595
+ .map(message => message.id)
596
+ .filter((id) => Is.stringValue(id))
597
+ .reverse();
598
+ const delivered = await this.deliverMessages(gmail, instanceId, messageIds, state, retrievalCallback);
599
+ if (!delivered) {
600
+ return false;
601
+ }
602
+ // Only move the cursor on once the whole page has been delivered, a page which failed
603
+ // part way through is listed again on the next cycle.
604
+ const nextPageToken = Coerce.string(list.data.nextPageToken);
605
+ state.syncPageTokens ??= {};
606
+ state.syncCompletedLabelIds ??= [];
607
+ if (Is.stringValue(nextPageToken)) {
608
+ state.syncPageTokens[labelId] = nextPageToken;
609
+ }
610
+ else {
611
+ delete state.syncPageTokens[labelId];
612
+ state.syncCompletedLabelIds = [...state.syncCompletedLabelIds, labelId];
613
+ }
614
+ return true;
615
+ }
616
+ /**
617
+ * Deliver the messages added to the mailbox since the last processed history record.
618
+ * @param gmail The Gmail API client.
619
+ * @param instanceId The identifier of the mailbox instance being polled.
620
+ * @param state The connector state for the instance.
621
+ * @param retrievalCallback Callback invoked for each parsed email.
622
+ * @returns True if all history records were processed, false if processing was aborted.
623
+ * @internal
624
+ */
625
+ async syncHistory(gmail, instanceId, state, retrievalCallback) {
626
+ const startHistoryId = state.historyId;
627
+ if (!Is.stringValue(startHistoryId)) {
628
+ // Nothing to resume from, fall back to walking the message list.
629
+ state.initialSyncComplete = false;
630
+ return true;
631
+ }
632
+ let pageToken;
633
+ let mailboxHistoryId;
634
+ let morePages = true;
635
+ while (morePages) {
636
+ if (!this._retrieving) {
637
+ return false;
638
+ }
639
+ let response;
640
+ try {
641
+ // The history is mailbox wide and only accepts one label, so it is read unfiltered
642
+ // and the added messages are matched against the configured labels below.
643
+ response = await gmail.users.history.list({
644
+ userId: this._config.emailAddress,
645
+ startHistoryId,
646
+ historyTypes: ["messageAdded"],
647
+ pageToken
648
+ });
649
+ }
650
+ catch (error) {
651
+ if (!this.isHistoryExpired(error)) {
652
+ throw error;
653
+ }
654
+ await this._logging?.log({
655
+ level: "warn",
656
+ source: GmailEmailConnector.CLASS_NAME,
657
+ ts: Date.now(),
658
+ message: "historyExpired"
659
+ });
660
+ // The stored history id has aged out of the mailbox history, so walk the message
661
+ // list again from the start. Already delivered identifiers stop it duplicating.
662
+ state.historyId = undefined;
663
+ state.syncPageTokens = undefined;
664
+ state.syncCompletedLabelIds = undefined;
665
+ state.initialSyncComplete = false;
666
+ return true;
667
+ }
668
+ for (const record of response.data.history ?? []) {
669
+ const messageIds = (record.messagesAdded ?? [])
670
+ .filter(added => this.matchesLabels(added.message?.labelIds))
671
+ .map(added => added.message?.id)
672
+ .filter((id) => Is.stringValue(id));
673
+ const delivered = await this.deliverMessages(gmail, instanceId, messageIds, state, retrievalCallback);
674
+ if (!delivered) {
675
+ return false;
676
+ }
677
+ // Only move the cursor on once every message in the record has been delivered, a
678
+ // record which failed part way through is replayed on the next cycle.
679
+ state.historyId = Coerce.string(record.id) ?? state.historyId;
680
+ }
681
+ pageToken = Coerce.string(response.data.nextPageToken);
682
+ mailboxHistoryId = Coerce.string(response.data.historyId);
683
+ morePages = Is.stringValue(pageToken);
684
+ }
685
+ if (Is.stringValue(mailboxHistoryId)) {
686
+ // Every record was processed, so move to the head of the mailbox history. This also
687
+ // skips the change types the connector does not act on.
688
+ state.historyId = mailboxHistoryId;
689
+ }
690
+ return true;
691
+ }
692
+ /**
693
+ * Fetch and deliver each of the given messages in order.
694
+ * Messages already recorded in the state are skipped.
695
+ * @param gmail The Gmail API client.
696
+ * @param instanceId The identifier of the mailbox instance being polled.
697
+ * @param messageIds The identifiers of the messages to deliver, in arrival order.
698
+ * @param state The connector state for the instance.
699
+ * @param retrievalCallback Callback invoked for each parsed email; return false to abort.
700
+ * @returns True if all messages were processed, false if processing was aborted.
701
+ * @internal
702
+ */
703
+ async deliverMessages(gmail, instanceId, messageIds, state, retrievalCallback) {
704
+ for (const messageId of messageIds) {
705
+ if (!this._retrieving) {
706
+ return false;
707
+ }
708
+ const alreadyDelivered = state.deliveredMessageIds?.includes(messageId) ?? false;
709
+ if (!alreadyDelivered) {
710
+ const email = await this.fetchEmail(gmail, messageId);
711
+ if (Is.notEmpty(email)) {
712
+ await this._logging?.log({
713
+ level: "info",
714
+ source: GmailEmailConnector.CLASS_NAME,
715
+ ts: Date.now(),
716
+ message: "emailReceived",
717
+ data: { subject: email.subject }
718
+ });
719
+ // Record the delivery before the callback runs so the identifier is part of the
720
+ // state the caller persists, then wind it back if the caller reports a failure.
721
+ const previousDelivered = state.deliveredMessageIds;
722
+ this.recordDelivered(state, messageId);
723
+ const shouldContinue = await retrievalCallback(instanceId, email, state);
724
+ if (!shouldContinue) {
725
+ state.deliveredMessageIds = previousDelivered;
726
+ return false;
727
+ }
728
+ }
729
+ }
730
+ }
731
+ return true;
732
+ }
733
+ /**
734
+ * Fetch a single message and parse it into an email.
735
+ * @param gmail The Gmail API client.
736
+ * @param messageId The identifier of the message to fetch.
737
+ * @returns The parsed email, or undefined if the message carried no content.
738
+ * @internal
739
+ */
740
+ async fetchEmail(gmail, messageId) {
741
+ const response = await gmail.users.messages.get({
742
+ userId: this._config.emailAddress,
743
+ id: messageId,
744
+ format: "raw"
745
+ });
746
+ const raw = Coerce.string(response.data.raw);
747
+ if (!Is.stringValue(raw)) {
748
+ return undefined;
749
+ }
750
+ return MailHelper.parseEmail(Converter.bytesToUtf8(Converter.base64UrlToBytes(raw)), response.data.labelIds ?? undefined);
751
+ }
752
+ /**
753
+ * Whether a message carries any of the configured labels.
754
+ * @param labelIds The labels on the message.
755
+ * @returns True if the message should be delivered.
756
+ * @internal
757
+ */
758
+ matchesLabels(labelIds) {
759
+ return (labelIds ?? []).some(labelId => this._config.labelIds.includes(labelId));
760
+ }
761
+ /**
762
+ * Record a message identifier as delivered, trimming the oldest entries.
763
+ * @param state The connector state to update.
764
+ * @param messageId The identifier of the delivered message.
765
+ * @internal
766
+ */
767
+ recordDelivered(state, messageId) {
768
+ // Replace rather than mutate, the previous array is kept so the record can be wound back.
769
+ const delivered = [...(state.deliveredMessageIds ?? []), messageId];
770
+ state.deliveredMessageIds = delivered.slice(-this._config.maxDeliveredIdHistory);
771
+ }
772
+ }
773
+ //# sourceMappingURL=gmailEmailConnector.js.map