emailengine-app 2.63.4 → 2.64.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/CHANGELOG.md +56 -0
- package/data/google-crawlers.json +1 -1
- package/eslint.config.js +2 -0
- package/lib/account.js +6 -2
- package/lib/consts.js +17 -1
- package/lib/email-client/gmail/gmail-api.js +1 -12
- package/lib/email-client/imap-client.js +5 -3
- package/lib/email-client/outlook/graph-api.js +7 -13
- package/lib/email-client/outlook-client.js +363 -167
- package/lib/imapproxy/imap-server.js +1 -0
- package/lib/oauth/gmail.js +12 -1
- package/lib/oauth/pubsub/google.js +253 -85
- package/lib/oauth2-apps.js +554 -377
- package/lib/routes-ui.js +186 -91
- package/lib/schemas.js +18 -1
- package/lib/ui-routes/account-routes.js +1 -1
- package/lib/ui-routes/admin-entities-routes.js +3 -3
- package/lib/ui-routes/oauth-routes.js +9 -3
- package/package.json +9 -9
- package/sbom.json +1 -1
- package/server.js +54 -22
- package/static/licenses.html +27 -27
- package/translations/de.mo +0 -0
- package/translations/de.po +54 -42
- package/translations/en.mo +0 -0
- package/translations/en.po +55 -43
- package/translations/et.mo +0 -0
- package/translations/et.po +54 -42
- package/translations/fr.mo +0 -0
- package/translations/fr.po +54 -42
- package/translations/ja.mo +0 -0
- package/translations/ja.po +54 -42
- package/translations/messages.pot +74 -52
- package/translations/nl.mo +0 -0
- package/translations/nl.po +54 -42
- package/translations/pl.mo +0 -0
- package/translations/pl.po +54 -42
- package/views/config/oauth/app.hbs +12 -0
- package/views/config/oauth/index.hbs +2 -0
- package/views/config/oauth/subscriptions.hbs +175 -0
- package/views/error.hbs +4 -4
- package/views/partials/oauth_tabs.hbs +8 -0
- package/workers/api.js +174 -96
- package/workers/documents.js +1 -0
- package/workers/imap.js +30 -47
- package/workers/smtp.js +1 -0
- package/workers/submit.js +1 -0
- package/workers/webhooks.js +42 -30
package/lib/oauth/gmail.js
CHANGED
|
@@ -481,7 +481,8 @@ class GmailOauth {
|
|
|
481
481
|
Authorization: `Bearer ${accessToken}`,
|
|
482
482
|
'User-Agent': `${packageData.name}/${packageData.version} (+${packageData.homepage})`
|
|
483
483
|
},
|
|
484
|
-
dispatcher: httpAgent.retry
|
|
484
|
+
dispatcher: httpAgent.retry,
|
|
485
|
+
signal: options.signal || undefined
|
|
485
486
|
};
|
|
486
487
|
|
|
487
488
|
if (payload && method !== 'get') {
|
|
@@ -535,6 +536,16 @@ class GmailOauth {
|
|
|
535
536
|
reqTime
|
|
536
537
|
};
|
|
537
538
|
|
|
539
|
+
// Capture Retry-After header for rate limiting (429) responses
|
|
540
|
+
if (res.status === 429) {
|
|
541
|
+
let retryAfterHeader = res.headers.get('retry-after');
|
|
542
|
+
if (retryAfterHeader) {
|
|
543
|
+
let parsed = parseInt(retryAfterHeader, 10);
|
|
544
|
+
err.retryAfter = isNaN(parsed) ? null : parsed;
|
|
545
|
+
err.oauthRequest.retryAfter = err.retryAfter;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
538
549
|
try {
|
|
539
550
|
err.oauthRequest.response = await res.json();
|
|
540
551
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { redis } = require('../../db');
|
|
4
|
-
const { REDIS_PREFIX } = require('../../consts');
|
|
4
|
+
const { REDIS_PREFIX, TRANSIENT_NETWORK_CODES } = require('../../consts');
|
|
5
5
|
const logger = require('../../logger').child({
|
|
6
6
|
component: 'google-subscriber'
|
|
7
7
|
});
|
|
@@ -16,12 +16,28 @@ class PubSubInstance {
|
|
|
16
16
|
this.app = opts.app;
|
|
17
17
|
|
|
18
18
|
this.stopped = false;
|
|
19
|
+
this._loopTimer = null;
|
|
20
|
+
this._immediateHandle = null;
|
|
21
|
+
this._abortController = null;
|
|
22
|
+
// Initialize to true so the first successful pull clears any stale
|
|
23
|
+
// pubSubFlag left in Redis by a previously crashed process
|
|
24
|
+
this._hadPubSubFlag = true;
|
|
25
|
+
// Track whether we have already reported an error this session
|
|
26
|
+
// (separate from _hadPubSubFlag which tracks stale flag clearing)
|
|
27
|
+
this._pubSubFlagSetThisSession = false;
|
|
28
|
+
this._lastLoopError = null;
|
|
29
|
+
this._consecutiveErrors = 0;
|
|
30
|
+
this._recoveryAttempts = 0;
|
|
19
31
|
|
|
20
32
|
this.checkSchemaVersions()
|
|
21
33
|
.catch(err => {
|
|
22
|
-
logger.error({ msg: 'Failed to process
|
|
34
|
+
logger.error({ msg: 'Failed to process schema versions', err });
|
|
23
35
|
})
|
|
24
|
-
.finally(() =>
|
|
36
|
+
.finally(() => {
|
|
37
|
+
if (!this.stopped) {
|
|
38
|
+
this.startLoop();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
25
41
|
}
|
|
26
42
|
|
|
27
43
|
getPubsubAppKey() {
|
|
@@ -33,26 +49,46 @@ class PubSubInstance {
|
|
|
33
49
|
return;
|
|
34
50
|
}
|
|
35
51
|
this.run()
|
|
36
|
-
.then(
|
|
37
|
-
this.
|
|
52
|
+
.then(messageCount => {
|
|
53
|
+
if (this.stopped) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
this._lastLoopError = null;
|
|
57
|
+
this._consecutiveErrors = 0;
|
|
58
|
+
this._recoveryAttempts = 0;
|
|
59
|
+
if (messageCount > 0) {
|
|
60
|
+
this._immediateHandle = setImmediate(() => this.startLoop());
|
|
61
|
+
} else {
|
|
62
|
+
// Add a small delay when no messages were received to prevent
|
|
63
|
+
// tight loops if Google returns empty responses immediately
|
|
64
|
+
this._loopTimer = setTimeout(() => this.startLoop(), 1000);
|
|
65
|
+
}
|
|
38
66
|
})
|
|
39
67
|
.catch(err => {
|
|
40
|
-
|
|
68
|
+
if (this.stopped || err.name === 'AbortError') {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
let errKey = [err.message, err.code, err.statusCode].filter(val => val).join('|');
|
|
72
|
+
if (this._lastLoopError !== errKey) {
|
|
73
|
+
logger.error({ msg: 'Failed to process subscription loop', app: this.app, err });
|
|
74
|
+
this._lastLoopError = errKey;
|
|
41
75
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
message: `Failed to process subscription loop`,
|
|
76
|
+
this._setPubSubFlag({
|
|
77
|
+
message: 'Failed to process subscription loop',
|
|
45
78
|
description: [err.message, err.reason, err.code].filter(val => val).join('; ')
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
79
|
+
}).catch(() => {});
|
|
80
|
+
}
|
|
81
|
+
this._consecutiveErrors++;
|
|
82
|
+
let retryDelay = err.retryDelay || this._backoffMs(this._consecutiveErrors);
|
|
83
|
+
this._loopTimer = setTimeout(() => this.startLoop(), retryDelay);
|
|
49
84
|
});
|
|
50
85
|
}
|
|
51
86
|
|
|
52
87
|
async checkSchemaVersions() {
|
|
53
88
|
let subscriberApps = await redis.smembers(this.getPubsubAppKey());
|
|
54
89
|
let currentSchemaId = 3;
|
|
55
|
-
for (let subscriberApp of subscriberApps
|
|
90
|
+
for (let subscriberApp of subscriberApps) {
|
|
91
|
+
if (this.stopped) return;
|
|
56
92
|
let schemaVersion = Number(await redis.hget(`${REDIS_PREFIX}oapp:h:${subscriberApp}`, '__schemaVersion')) || 0;
|
|
57
93
|
if (schemaVersion < currentSchemaId) {
|
|
58
94
|
// migrate
|
|
@@ -73,7 +109,7 @@ class PubSubInstance {
|
|
|
73
109
|
}
|
|
74
110
|
|
|
75
111
|
async processPulledMessage(messageId, data) {
|
|
76
|
-
logger.
|
|
112
|
+
logger.debug({ msg: 'Processing subscription message', source: 'google', app: this.app, messageId, data });
|
|
77
113
|
|
|
78
114
|
let payload;
|
|
79
115
|
try {
|
|
@@ -83,14 +119,23 @@ class PubSubInstance {
|
|
|
83
119
|
return;
|
|
84
120
|
}
|
|
85
121
|
|
|
86
|
-
if (!payload
|
|
122
|
+
if (!payload?.emailAddress || !payload?.historyId) {
|
|
123
|
+
logger.warn({
|
|
124
|
+
msg: 'Ignoring Pub/Sub message with missing required fields',
|
|
125
|
+
source: 'google',
|
|
126
|
+
app: this.app,
|
|
127
|
+
messageId,
|
|
128
|
+
hasPayload: !!payload,
|
|
129
|
+
hasEmailAddress: !!payload?.emailAddress,
|
|
130
|
+
hasHistoryId: !!payload?.historyId
|
|
131
|
+
});
|
|
87
132
|
return;
|
|
88
133
|
}
|
|
89
134
|
|
|
90
135
|
let subscriberApps = await redis.smembers(this.getPubsubAppKey());
|
|
91
136
|
let accountIds = new Set();
|
|
92
|
-
for (let subscriberApp of subscriberApps
|
|
93
|
-
let accountId = await redis.hget(`${REDIS_PREFIX}oapp:h:${subscriberApp}`, payload.emailAddress
|
|
137
|
+
for (let subscriberApp of subscriberApps) {
|
|
138
|
+
let accountId = await redis.hget(`${REDIS_PREFIX}oapp:h:${subscriberApp}`, payload.emailAddress.toLowerCase());
|
|
94
139
|
if (accountId) {
|
|
95
140
|
accountIds.add(accountId);
|
|
96
141
|
}
|
|
@@ -101,11 +146,7 @@ class PubSubInstance {
|
|
|
101
146
|
return;
|
|
102
147
|
}
|
|
103
148
|
|
|
104
|
-
|
|
105
|
-
await this.parent.call({ cmd: 'externalNotify', accounts: Array.from(accountIds), historyId: Number(payload.historyId) || null });
|
|
106
|
-
} catch (err) {
|
|
107
|
-
logger.error({ msg: 'Failed to notify about changes', app: this.app, messageId, emailAddress: payload.emailAddress, err });
|
|
108
|
-
}
|
|
149
|
+
await this.parent.call({ cmd: 'externalNotify', accounts: [...accountIds], historyId: Number(payload.historyId) || null });
|
|
109
150
|
}
|
|
110
151
|
|
|
111
152
|
async run() {
|
|
@@ -118,6 +159,17 @@ class PubSubInstance {
|
|
|
118
159
|
return;
|
|
119
160
|
}
|
|
120
161
|
|
|
162
|
+
// Check if subscription needs initial setup (use cached data first)
|
|
163
|
+
await this.getApp();
|
|
164
|
+
if (!this.appData.pubSubSubscription) {
|
|
165
|
+
// Force refresh to confirm subscription is really missing
|
|
166
|
+
await this.getApp(true);
|
|
167
|
+
if (!this.appData.pubSubSubscription) {
|
|
168
|
+
await this.attemptRecovery('Subscription not configured');
|
|
169
|
+
return; // re-enter pull loop via startLoop
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
121
173
|
let accessToken = await this.getAccessToken();
|
|
122
174
|
if (!accessToken) {
|
|
123
175
|
logger.error({ msg: 'Failed to retrieve access token', app: this.app });
|
|
@@ -128,15 +180,28 @@ class PubSubInstance {
|
|
|
128
180
|
let acknowledgeUrl = `https://pubsub.googleapis.com/v1/${this.appData.pubSubSubscription}:acknowledge`;
|
|
129
181
|
|
|
130
182
|
try {
|
|
131
|
-
let
|
|
132
|
-
|
|
133
|
-
|
|
183
|
+
let pullStartTime = Date.now();
|
|
184
|
+
|
|
185
|
+
this._abortController = new AbortController();
|
|
186
|
+
let pullTimeoutId = setTimeout(() => this._abortController.abort(), 5 * 60 * 1000);
|
|
187
|
+
let pullRes;
|
|
188
|
+
try {
|
|
189
|
+
pullRes = await this.client.request(
|
|
190
|
+
accessToken,
|
|
191
|
+
pullUrl,
|
|
192
|
+
'POST',
|
|
193
|
+
{ returnImmediately: false, maxMessages: 100 },
|
|
194
|
+
{ signal: this._abortController.signal }
|
|
195
|
+
);
|
|
196
|
+
} finally {
|
|
197
|
+
clearTimeout(pullTimeoutId);
|
|
198
|
+
this._abortController = null;
|
|
199
|
+
}
|
|
134
200
|
if (this.stopped) {
|
|
135
|
-
// ignore if stopped
|
|
136
201
|
return;
|
|
137
202
|
}
|
|
138
203
|
|
|
139
|
-
let reqTime = Date.now() -
|
|
204
|
+
let reqTime = Date.now() - pullStartTime;
|
|
140
205
|
|
|
141
206
|
logger.debug({
|
|
142
207
|
msg: 'Pulled subscription messages',
|
|
@@ -146,6 +211,9 @@ class PubSubInstance {
|
|
|
146
211
|
reqTime
|
|
147
212
|
});
|
|
148
213
|
|
|
214
|
+
// Collect ackIds for batch acknowledgement
|
|
215
|
+
let ackIds = [];
|
|
216
|
+
|
|
149
217
|
for (let receivedMessage of pullRes?.receivedMessages || []) {
|
|
150
218
|
// Check stopped flag at start of each message for faster shutdown
|
|
151
219
|
if (this.stopped) {
|
|
@@ -153,76 +221,143 @@ class PubSubInstance {
|
|
|
153
221
|
return;
|
|
154
222
|
}
|
|
155
223
|
|
|
156
|
-
let
|
|
224
|
+
let messageId = receivedMessage?.message?.messageId;
|
|
225
|
+
|
|
157
226
|
try {
|
|
158
|
-
await this.processPulledMessage(
|
|
159
|
-
receivedMessage?.message?.messageId,
|
|
160
|
-
Buffer.from(receivedMessage?.message?.data || '', 'base64').toString()
|
|
161
|
-
);
|
|
162
|
-
processingSuccess = true;
|
|
227
|
+
await this.processPulledMessage(messageId, Buffer.from(receivedMessage?.message?.data || '', 'base64').toString());
|
|
163
228
|
} catch (err) {
|
|
164
|
-
// Processing failed -
|
|
165
|
-
logger.error({
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
229
|
+
// Processing failed - skip ACK so message will be redelivered
|
|
230
|
+
logger.error({ msg: 'Failed to process subscription message', app: this.app, messageId, err });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (receivedMessage?.ackId) {
|
|
235
|
+
ackIds.push(receivedMessage.ackId);
|
|
171
236
|
}
|
|
237
|
+
}
|
|
172
238
|
|
|
173
|
-
|
|
174
|
-
|
|
239
|
+
// Batch ACK all successfully processed messages
|
|
240
|
+
if (ackIds.length > 0) {
|
|
241
|
+
// Refresh access token if the pull took a long time (token may be near expiration)
|
|
242
|
+
let elapsed = Date.now() - pullStartTime;
|
|
243
|
+
if (elapsed > 4 * 60 * 1000) {
|
|
175
244
|
try {
|
|
176
245
|
accessToken = await this.getAccessToken();
|
|
177
|
-
if (!accessToken) {
|
|
178
|
-
logger.error({
|
|
179
|
-
msg: 'Failed to ack subscription message. No access token',
|
|
180
|
-
app: this.app,
|
|
181
|
-
messageId: receivedMessage?.message?.messageId
|
|
182
|
-
});
|
|
183
|
-
} else {
|
|
184
|
-
await this.client.request(accessToken, acknowledgeUrl, 'POST', { ackIds: [receivedMessage?.ackId] }, { returnText: true });
|
|
185
|
-
logger.debug({
|
|
186
|
-
msg: 'Acked subscription message',
|
|
187
|
-
app: this.app,
|
|
188
|
-
messageId: receivedMessage?.message?.messageId
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
246
|
} catch (err) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
247
|
+
logger.warn({ msg: 'Failed to refresh access token before ACK, using original', app: this.app, err });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
await this.client.request(accessToken, acknowledgeUrl, 'POST', { ackIds }, { returnText: true });
|
|
253
|
+
logger.debug({ msg: 'Batch acked subscription messages', app: this.app, count: ackIds.length });
|
|
254
|
+
} catch (err) {
|
|
255
|
+
logger.warn({ msg: 'Batch ACK failed, retrying once', app: this.app, count: ackIds.length, err });
|
|
256
|
+
try {
|
|
257
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
258
|
+
await this.client.request(accessToken, acknowledgeUrl, 'POST', { ackIds }, { returnText: true });
|
|
259
|
+
logger.info({ msg: 'Batch ACK retry succeeded', app: this.app, count: ackIds.length });
|
|
260
|
+
} catch (retryErr) {
|
|
261
|
+
logger.error({ msg: 'Batch ACK retry also failed, messages will be redelivered', app: this.app, count: ackIds.length, err: retryErr });
|
|
199
262
|
}
|
|
200
263
|
}
|
|
201
264
|
}
|
|
202
265
|
|
|
203
|
-
|
|
266
|
+
if (this._hadPubSubFlag) {
|
|
267
|
+
try {
|
|
268
|
+
await oauth2Apps.setMeta(this.app, { pubSubFlag: null });
|
|
269
|
+
this._hadPubSubFlag = false;
|
|
270
|
+
} catch (metaErr) {
|
|
271
|
+
logger.error({ msg: 'Failed to clear pubSubFlag after successful pull', app: this.app, err: metaErr });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return ackIds.length;
|
|
204
276
|
} catch (err) {
|
|
277
|
+
// Manual timeout abort or shutdown - not an error, just restart the loop
|
|
278
|
+
if (err.name === 'AbortError') {
|
|
279
|
+
throw err;
|
|
280
|
+
}
|
|
205
281
|
// Transient network errors are expected for long-polling connections
|
|
206
|
-
if (
|
|
207
|
-
[
|
|
208
|
-
'ENOTFOUND',
|
|
209
|
-
'EAI_AGAIN',
|
|
210
|
-
'ETIMEDOUT',
|
|
211
|
-
'ECONNRESET',
|
|
212
|
-
'ECONNREFUSED',
|
|
213
|
-
'UND_ERR_SOCKET',
|
|
214
|
-
'UND_ERR_CONNECT_TIMEOUT',
|
|
215
|
-
'UND_ERR_HEADERS_TIMEOUT'
|
|
216
|
-
].includes(err.code)
|
|
217
|
-
) {
|
|
282
|
+
if (TRANSIENT_NETWORK_CODES.has(err.code)) {
|
|
218
283
|
logger.warn({ msg: 'Transient error pulling subscription messages', app: this.app, code: err.code });
|
|
219
|
-
|
|
284
|
+
err.retryDelay = 5000;
|
|
285
|
+
throw err;
|
|
286
|
+
}
|
|
287
|
+
// Rate limited by Google API - respect Retry-After header
|
|
288
|
+
if (err.statusCode === 429) {
|
|
289
|
+
let retryDelay = (err.retryAfter ? err.retryAfter * 1000 : null) || 30000;
|
|
290
|
+
logger.warn({ msg: 'Rate limited by Google Pub/Sub API', app: this.app, retryAfterSec: err.retryAfter });
|
|
291
|
+
err.retryDelay = retryDelay;
|
|
292
|
+
throw err;
|
|
293
|
+
}
|
|
294
|
+
// Detect deleted subscription (expired after 31 days of inactivity) and try to recreate
|
|
295
|
+
if (err.statusCode === 404 || err?.oauthRequest?.response?.error?.code === 404) {
|
|
296
|
+
await this.attemptRecovery('Subscription not found (404)');
|
|
297
|
+
return; // re-enter the pull loop
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Authentication or authorization failure -- set operator-visible flag and let startLoop retry with backoff.
|
|
301
|
+
// 401: token refresh happens automatically via getServiceAccessToken() on next run().
|
|
302
|
+
// 403: permanent until operator fixes IAM permissions; retrying ensurePubsub wastes API calls.
|
|
303
|
+
if (err.statusCode === 401 || err.statusCode === 403) {
|
|
304
|
+
logger.error({
|
|
305
|
+
msg: 'Authentication/authorization error pulling subscription messages',
|
|
306
|
+
app: this.app,
|
|
307
|
+
statusCode: err.statusCode,
|
|
308
|
+
err
|
|
309
|
+
});
|
|
310
|
+
if (!this._pubSubFlagSetThisSession) {
|
|
311
|
+
await this._setPubSubFlag({
|
|
312
|
+
message:
|
|
313
|
+
err.statusCode === 401
|
|
314
|
+
? 'Service account access token expired or revoked'
|
|
315
|
+
: 'Insufficient permissions to pull from the subscription',
|
|
316
|
+
description: [err.message, err.reason, err.code].filter(val => val).join('; ')
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
throw err;
|
|
220
320
|
}
|
|
321
|
+
|
|
221
322
|
logger.error({ msg: 'Failed to pull subscription messages', app: this.app, err });
|
|
222
323
|
throw err;
|
|
223
324
|
}
|
|
224
325
|
}
|
|
225
326
|
|
|
327
|
+
async attemptRecovery(reason) {
|
|
328
|
+
this._recoveryAttempts++;
|
|
329
|
+
if (this._recoveryAttempts > 5) {
|
|
330
|
+
throw new Error(`Recovery attempted ${this._recoveryAttempts} times without a successful pull; backing off`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
logger.info({ msg: 'Attempting subscription recovery', app: this.app, reason, attempt: this._recoveryAttempts });
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
if (this.stopped) return;
|
|
337
|
+
await this.getApp(true);
|
|
338
|
+
if (this.stopped) return;
|
|
339
|
+
await oauth2Apps.ensurePubsub(this.appData);
|
|
340
|
+
if (this.stopped) return;
|
|
341
|
+
|
|
342
|
+
// Verify recovery actually created a subscription
|
|
343
|
+
await this.getApp(true);
|
|
344
|
+
if (!this.appData.pubSubSubscription) {
|
|
345
|
+
throw new Error('Pub/Sub setup completed but subscription is still missing');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (this.stopped) return;
|
|
349
|
+
await this.getClient(true);
|
|
350
|
+
if (this.stopped) return;
|
|
351
|
+
await oauth2Apps.setMeta(this.app, { pubSubFlag: null });
|
|
352
|
+
this._hadPubSubFlag = false;
|
|
353
|
+
this._pubSubFlagSetThisSession = false;
|
|
354
|
+
logger.info({ msg: 'Successfully recovered Pub/Sub subscription', app: this.app, reason });
|
|
355
|
+
} catch (recoveryErr) {
|
|
356
|
+
logger.warn({ msg: 'Subscription recovery failed', app: this.app, reason, err: recoveryErr });
|
|
357
|
+
throw recoveryErr;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
226
361
|
async getClient(force) {
|
|
227
362
|
if (this.client && !force) {
|
|
228
363
|
return this.client;
|
|
@@ -236,7 +371,14 @@ class PubSubInstance {
|
|
|
236
371
|
if (this.appData && !force) {
|
|
237
372
|
return this.appData;
|
|
238
373
|
}
|
|
239
|
-
|
|
374
|
+
let result = await oauth2Apps.get(this.app);
|
|
375
|
+
if (!result) {
|
|
376
|
+
logger.info({ msg: 'App data not found in getApp, removing subscription instance', app: this.app });
|
|
377
|
+
this.stopped = true;
|
|
378
|
+
this.parent.remove(this.app);
|
|
379
|
+
throw new Error('App no longer exists');
|
|
380
|
+
}
|
|
381
|
+
this.appData = result;
|
|
240
382
|
return this.appData;
|
|
241
383
|
}
|
|
242
384
|
|
|
@@ -244,6 +386,19 @@ class PubSubInstance {
|
|
|
244
386
|
await this.getClient();
|
|
245
387
|
return await oauth2Apps.getServiceAccessToken(this.appData, this.client);
|
|
246
388
|
}
|
|
389
|
+
|
|
390
|
+
_setPubSubFlag(flag) {
|
|
391
|
+
this._hadPubSubFlag = true;
|
|
392
|
+
this._pubSubFlagSetThisSession = true;
|
|
393
|
+
return oauth2Apps.setMeta(this.app, { pubSubFlag: flag }).catch(metaErr => {
|
|
394
|
+
logger.error({ msg: 'Failed to update pubSubFlag', app: this.app, err: metaErr });
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
_backoffMs(attempts) {
|
|
399
|
+
let base = Math.min(3000 * Math.pow(2, Math.min(attempts, 20)), 5 * 60 * 1000);
|
|
400
|
+
return Math.floor(base * (0.5 + Math.random() * 0.5));
|
|
401
|
+
}
|
|
247
402
|
}
|
|
248
403
|
|
|
249
404
|
class GooglePubSub {
|
|
@@ -258,25 +413,38 @@ class GooglePubSub {
|
|
|
258
413
|
}
|
|
259
414
|
|
|
260
415
|
async start() {
|
|
261
|
-
|
|
416
|
+
// Backfill apps with baseScopes === 'pubsub' that are missing from the subscribers set
|
|
417
|
+
let apps = await oauth2Apps.backfillPubSubApps();
|
|
262
418
|
for (let app of apps) {
|
|
263
|
-
this.
|
|
419
|
+
await this.update(app);
|
|
264
420
|
}
|
|
265
421
|
}
|
|
266
422
|
|
|
267
423
|
async update(app) {
|
|
268
|
-
if (
|
|
269
|
-
this.
|
|
424
|
+
if (this.pubSubInstances.has(app)) {
|
|
425
|
+
this.remove(app);
|
|
270
426
|
}
|
|
427
|
+
this.pubSubInstances.set(app, new PubSubInstance(this, { app }));
|
|
271
428
|
}
|
|
272
429
|
|
|
273
|
-
|
|
430
|
+
remove(app) {
|
|
274
431
|
if (this.pubSubInstances.has(app)) {
|
|
275
432
|
const instance = this.pubSubInstances.get(app);
|
|
276
|
-
instance.stopped = true;
|
|
433
|
+
instance.stopped = true;
|
|
434
|
+
clearTimeout(instance._loopTimer);
|
|
435
|
+
clearImmediate(instance._immediateHandle);
|
|
436
|
+
if (instance._abortController) {
|
|
437
|
+
instance._abortController.abort();
|
|
438
|
+
}
|
|
277
439
|
this.pubSubInstances.delete(app);
|
|
278
440
|
}
|
|
279
441
|
}
|
|
442
|
+
|
|
443
|
+
stopAll() {
|
|
444
|
+
for (let app of [...this.pubSubInstances.keys()]) {
|
|
445
|
+
this.remove(app);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
280
448
|
}
|
|
281
449
|
|
|
282
|
-
module.exports = { GooglePubSub };
|
|
450
|
+
module.exports = { GooglePubSub, PubSubInstance };
|