@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.
- package/LICENSE +201 -0
- package/README.md +76 -2
- package/dist/es/connectorSchema/gmailEmailConnectorConfigSchema.js +51 -0
- package/dist/es/connectorSchema/gmailEmailConnectorConfigSchema.js.map +1 -0
- package/dist/es/connectorSchema/gmailEmailConnectorStateSchema.js +45 -0
- package/dist/es/connectorSchema/gmailEmailConnectorStateSchema.js.map +1 -0
- package/dist/es/gmailEmailConnector.js +773 -0
- package/dist/es/gmailEmailConnector.js.map +1 -0
- package/dist/es/index.js +10 -0
- package/dist/es/index.js.map +1 -0
- package/dist/es/models/IGmailEmailConnectorConfig.js +4 -0
- package/dist/es/models/IGmailEmailConnectorConfig.js.map +1 -0
- package/dist/es/models/IGmailEmailConnectorConstructorOptions.js +2 -0
- package/dist/es/models/IGmailEmailConnectorConstructorOptions.js.map +1 -0
- package/dist/es/models/IGmailEmailConnectorState.js +4 -0
- package/dist/es/models/IGmailEmailConnectorState.js.map +1 -0
- package/dist/es/schema.js +17 -0
- package/dist/es/schema.js.map +1 -0
- package/dist/types/connectorSchema/gmailEmailConnectorConfigSchema.d.ts +5 -0
- package/dist/types/connectorSchema/gmailEmailConnectorStateSchema.d.ts +7 -0
- package/dist/types/gmailEmailConnector.d.ts +64 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/models/IGmailEmailConnectorConfig.d.ts +56 -0
- package/dist/types/models/IGmailEmailConnectorConstructorOptions.d.ts +19 -0
- package/dist/types/models/IGmailEmailConnectorState.d.ts +39 -0
- package/dist/types/schema.d.ts +7 -0
- package/docs/changelog.md +0 -0
- package/docs/examples.md +174 -0
- package/docs/reference/classes/GmailEmailConnector.md +218 -0
- package/docs/reference/functions/initSchema.md +12 -0
- package/docs/reference/index.md +20 -0
- package/docs/reference/interfaces/IGmailEmailConnectorConfig.md +110 -0
- package/docs/reference/interfaces/IGmailEmailConnectorConstructorOptions.md +33 -0
- package/docs/reference/interfaces/IGmailEmailConnectorState.md +63 -0
- package/docs/reference/variables/GmailEmailConnectorConfigSchema.md +5 -0
- package/docs/reference/variables/GmailEmailConnectorStateSchema.md +7 -0
- package/locales/en.json +41 -0
- 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
|