@kubun/connector-google-mail 0.17.2 → 0.18.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/lib/actions.d.ts +1 -1
- package/lib/actions.js +8 -5
- package/lib/api.d.ts +13 -1
- package/lib/api.js +133 -106
- package/lib/model.d.ts +6 -49
- package/lib/model.js +30 -79
- package/lib/provider.js +84 -88
- package/package.json +11 -9
package/lib/actions.d.ts
CHANGED
package/lib/actions.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
+
import { carryField, refuseCutLists } from '@kubun/connector';
|
|
1
2
|
import { createDraft, createLabel, importMessageAttachment, modifyLabels, sendMessage, updateLabel } from './api.js';
|
|
2
3
|
import { GoogleMailError } from './errors.js';
|
|
3
4
|
// Gmail takes a label color only as the textColor/backgroundColor pair, so a
|
|
4
5
|
// write that changes one of them carries the other from the target document.
|
|
5
6
|
// With no companion to carry (absent or empty), the color cannot be written at all: the write is
|
|
6
|
-
// rejected before any request, rather than sent without the change.
|
|
7
|
-
|
|
7
|
+
// rejected before any request, rather than sent without the change. A
|
|
8
|
+
// companion trimmed at sync is refused too (`carryField`), never sent.
|
|
9
|
+
function withColorPair(params, existing, context) {
|
|
8
10
|
const changesOne = 'textColor' in params !== 'backgroundColor' in params;
|
|
9
11
|
if (!changesOne) return params;
|
|
10
12
|
const missing = 'textColor' in params ? 'backgroundColor' : 'textColor';
|
|
11
|
-
const companion = existing
|
|
13
|
+
const companion = carryField(existing, missing, context);
|
|
12
14
|
if (typeof companion !== 'string' || companion === '') {
|
|
13
15
|
throw new GoogleMailError(`Google Mail: a label color needs both textColor and backgroundColor, and the label has no ${missing} to keep`, {
|
|
14
16
|
kind: 'rejected'
|
|
@@ -58,12 +60,13 @@ export function createGoogleMailActionHandler(config) {
|
|
|
58
60
|
sourceFetchHLC: mintActionFetchHLC()
|
|
59
61
|
};
|
|
60
62
|
},
|
|
61
|
-
async update (model, sourceID, params, existing) {
|
|
63
|
+
async update (model, sourceID, params, existing, context) {
|
|
64
|
+
refuseCutLists(params, context);
|
|
62
65
|
if (model === 'GoogleMailLabel') {
|
|
63
66
|
const record = await updateLabel({
|
|
64
67
|
accessToken: config.credential.accessToken,
|
|
65
68
|
labelID: sourceID,
|
|
66
|
-
params: withColorPair(params, existing),
|
|
69
|
+
params: withColorPair(params, existing, context),
|
|
67
70
|
fetch: config.fetch
|
|
68
71
|
});
|
|
69
72
|
return {
|
package/lib/api.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type EntityRecord, type WriteAttachment } from '@kubun/connector';
|
|
2
|
+
import { type GoogleAPIClient } from '@kubun/provider-google';
|
|
2
3
|
export declare const MAIL_BASE_URL = "https://gmail.googleapis.com/gmail/v1";
|
|
3
|
-
export declare function
|
|
4
|
+
export declare function mailAPIClient(accessToken: string, fetch?: typeof globalThis.fetch): GoogleAPIClient;
|
|
4
5
|
export declare function decodeBase64Url(data: string): string;
|
|
5
6
|
export declare function decodeBase64UrlBytes(data: string): Uint8Array;
|
|
6
7
|
export declare function encodeBase64Url(text: string): string;
|
|
@@ -102,6 +103,17 @@ export declare function getHeader(headers: Array<{
|
|
|
102
103
|
}> | undefined, name: string): string | undefined;
|
|
103
104
|
export declare function extractEmail(headerValue: string): string;
|
|
104
105
|
export declare function extractEmails(headerValue: string | undefined): Array<string>;
|
|
106
|
+
export type MailParty = {
|
|
107
|
+
name?: string;
|
|
108
|
+
email?: string;
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Parses one mailbox out of a header value: `"Alice A" <alice@example.com>`,
|
|
112
|
+
* `Alice A <alice@example.com>`, or a bare `alice@example.com`. A value that
|
|
113
|
+
* names no address at all -- malformed or empty -- returns `{}` rather than
|
|
114
|
+
* a name with no email, or an email this parser only guessed at.
|
|
115
|
+
*/
|
|
116
|
+
export declare function parseMailParty(value: string): MailParty;
|
|
105
117
|
export declare function mapMessageToEntity(message: GmailMessageResource): EntityRecord;
|
|
106
118
|
export declare function getMessage(config: {
|
|
107
119
|
accessToken: string;
|
package/lib/api.js
CHANGED
|
@@ -1,22 +1,14 @@
|
|
|
1
1
|
import { connectorHTTPError, markWriteSent } from '@kubun/connector';
|
|
2
|
+
import { createGoogleAPIClient } from '@kubun/provider-google';
|
|
2
3
|
import { b64uFromUTF, b64uToUTF, fromB64U } from '@sozai/codec';
|
|
3
4
|
export const MAIL_BASE_URL = 'https://gmail.googleapis.com/gmail/v1';
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
// Build the error thrown by a Gmail call, tagged with the HTTP status and its
|
|
13
|
-
// outcome classification (`kind`, see `classifyHTTPStatus`) — a definite
|
|
14
|
-
// rejection dead-letters a write instead of re-driving it. The status is also
|
|
15
|
-
// the seam fetchEntity/reconcile (provider.ts) use to turn a 404 into a
|
|
16
|
-
// `present: false` result (deletion detected) instead of a throw.
|
|
17
|
-
async function googleMailAPIError(response) {
|
|
18
|
-
const detail = await parseGoogleError(response);
|
|
19
|
-
return connectorHTTPError(`Google Mail API error: ${detail}`, response.status);
|
|
5
|
+
export function mailAPIClient(accessToken, fetch) {
|
|
6
|
+
return createGoogleAPIClient({
|
|
7
|
+
api: 'Google Mail API',
|
|
8
|
+
accessToken,
|
|
9
|
+
fetch,
|
|
10
|
+
toError: ({ httpStatus, message })=>connectorHTTPError(message, httpStatus)
|
|
11
|
+
});
|
|
20
12
|
}
|
|
21
13
|
export function decodeBase64Url(data) {
|
|
22
14
|
return b64uToUTF(data);
|
|
@@ -86,6 +78,93 @@ export function extractEmails(headerValue) {
|
|
|
86
78
|
if (headerValue == null || headerValue.trim() === '') return [];
|
|
87
79
|
return headerValue.split(',').map((part)=>extractEmail(part));
|
|
88
80
|
}
|
|
81
|
+
// A mailbox is either `Display Name <addr@example.com>` (the display name
|
|
82
|
+
// optionally quoted) or a bare `addr@example.com`. This is a liberal but
|
|
83
|
+
// bounded check for "looks like an email address" -- no whitespace, angle
|
|
84
|
+
// bracket or quote characters around a single '@', with at least one '.' in
|
|
85
|
+
// the domain -- never a full RFC 5322 grammar. Gmail headers are already
|
|
86
|
+
// provider-validated, so this only guards against a header this parser
|
|
87
|
+
// cannot make sense of; it never invents an address that is not there.
|
|
88
|
+
const MAILBOX_WITH_NAME = /^([^<>]*)<([^<>]*)>\s*$/;
|
|
89
|
+
const PLAUSIBLE_EMAIL = /^[^\s<>"@]+@[^\s<>"@]+\.[^\s<>"@]+$/;
|
|
90
|
+
function stripQuotes(value) {
|
|
91
|
+
const trimmed = value.trim();
|
|
92
|
+
const match = /^"(.*)"$/.exec(trimmed);
|
|
93
|
+
return match?.[1]?.replace(/\\(["\\])/g, '$1') ?? trimmed;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Parses one mailbox out of a header value: `"Alice A" <alice@example.com>`,
|
|
97
|
+
* `Alice A <alice@example.com>`, or a bare `alice@example.com`. A value that
|
|
98
|
+
* names no address at all -- malformed or empty -- returns `{}` rather than
|
|
99
|
+
* a name with no email, or an email this parser only guessed at.
|
|
100
|
+
*/ export function parseMailParty(value) {
|
|
101
|
+
const trimmed = value.trim();
|
|
102
|
+
if (trimmed === '') return {};
|
|
103
|
+
const withName = MAILBOX_WITH_NAME.exec(trimmed);
|
|
104
|
+
if (withName != null) {
|
|
105
|
+
const email = (withName[2] ?? '').trim();
|
|
106
|
+
if (!PLAUSIBLE_EMAIL.test(email)) return {};
|
|
107
|
+
const name = stripQuotes(withName[1] ?? '');
|
|
108
|
+
return name === '' ? {
|
|
109
|
+
email
|
|
110
|
+
} : {
|
|
111
|
+
name,
|
|
112
|
+
email
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return PLAUSIBLE_EMAIL.test(trimmed) ? {
|
|
116
|
+
email: trimmed
|
|
117
|
+
} : {};
|
|
118
|
+
}
|
|
119
|
+
// Split only at mailbox boundaries outside quoted names and angle addresses.
|
|
120
|
+
// A group label is not a mailbox; its comma-separated members are.
|
|
121
|
+
function splitMailboxes(headerValue) {
|
|
122
|
+
const parts = [];
|
|
123
|
+
let current = '';
|
|
124
|
+
let inQuotes = false;
|
|
125
|
+
let inAngle = false;
|
|
126
|
+
let inGroup = false;
|
|
127
|
+
const push = ()=>{
|
|
128
|
+
if (current.trim() !== '') parts.push(current.trim());
|
|
129
|
+
current = '';
|
|
130
|
+
};
|
|
131
|
+
for(let i = 0; i < headerValue.length; i++){
|
|
132
|
+
const char = headerValue[i];
|
|
133
|
+
if (char === '\\' && inQuotes && i + 1 < headerValue.length) {
|
|
134
|
+
current += char + headerValue[++i];
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (char === '"' && !inAngle) inQuotes = !inQuotes;
|
|
138
|
+
if (!inQuotes && char === '<') inAngle = true;
|
|
139
|
+
if (!inQuotes && char === '>') inAngle = false;
|
|
140
|
+
if (!inQuotes && !inAngle) {
|
|
141
|
+
if (char === ':' && !inGroup && /^[^@<>:;]+$/.test(current.trim())) {
|
|
142
|
+
current = '';
|
|
143
|
+
inGroup = true;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (char === ',' || char === ';' && inGroup) {
|
|
147
|
+
push();
|
|
148
|
+
if (char === ';') inGroup = false;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
current += char;
|
|
153
|
+
if (char === '>' && !inQuotes) {
|
|
154
|
+
const next = headerValue.slice(i + 1).trimStart();
|
|
155
|
+
if (next.startsWith('<')) push();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
push();
|
|
159
|
+
return parts;
|
|
160
|
+
}
|
|
161
|
+
function parseMailPartyList(headerValue) {
|
|
162
|
+
if (headerValue == null || headerValue.trim() === '') return [];
|
|
163
|
+
return splitMailboxes(headerValue).map(parseMailParty).filter((party)=>party.name != null || party.email != null);
|
|
164
|
+
}
|
|
165
|
+
function nonEmptyPartyList(parties) {
|
|
166
|
+
return parties.length === 0 ? undefined : parties;
|
|
167
|
+
}
|
|
89
168
|
function findPartByMime(part, mime) {
|
|
90
169
|
if (part.mimeType === mime && part.body?.data != null) return part;
|
|
91
170
|
for (const child of part.parts ?? []){
|
|
@@ -125,16 +204,19 @@ export function mapMessageToEntity(message) {
|
|
|
125
204
|
const headers = message.payload?.headers;
|
|
126
205
|
const labels = message.labelIds ?? [];
|
|
127
206
|
const sentAtMs = message.internalDate != null ? Number.parseInt(message.internalDate, 10) : Number.NaN;
|
|
207
|
+
const from = parseMailParty(getHeader(headers, 'From') ?? '');
|
|
128
208
|
return {
|
|
129
209
|
sourceService: 'google-mail',
|
|
130
210
|
sourceID: message.id,
|
|
131
211
|
sourceURL: `https://mail.google.com/mail/u/0/#inbox/${message.id}`,
|
|
132
212
|
body: extractBody(message.payload),
|
|
133
|
-
|
|
213
|
+
// A message with no From header, or one this parser cannot make sense
|
|
214
|
+
// of, carries no `from` rather than an empty Party.
|
|
215
|
+
from: from.name != null || from.email != null ? from : undefined,
|
|
134
216
|
subject: getHeader(headers, 'Subject'),
|
|
135
217
|
snippet: message.snippet,
|
|
136
|
-
to:
|
|
137
|
-
cc:
|
|
218
|
+
to: nonEmptyPartyList(parseMailPartyList(getHeader(headers, 'To'))),
|
|
219
|
+
cc: nonEmptyPartyList(parseMailPartyList(getHeader(headers, 'Cc'))),
|
|
138
220
|
labels,
|
|
139
221
|
unread: labels.includes('UNREAD'),
|
|
140
222
|
sentAt: Number.isNaN(sentAtMs) ? undefined : new Date(sentAtMs).toISOString(),
|
|
@@ -147,52 +229,34 @@ export function mapMessageToEntity(message) {
|
|
|
147
229
|
};
|
|
148
230
|
}
|
|
149
231
|
// Targeted single-message fetch (anti-entropy repair verification / write-back
|
|
150
|
-
// recovery's fetchEntity+reconcile). A 404 here is the deletion signal
|
|
151
|
-
//
|
|
232
|
+
// recovery's fetchEntity+reconcile). A 404 here is the deletion signal:
|
|
233
|
+
// fetchOrAbsent turns it into a `present: false` result instead of a throw.
|
|
152
234
|
export async function getMessage(config) {
|
|
153
|
-
|
|
154
|
-
const response = await fetchFn(buildMessageGetURL({
|
|
235
|
+
return mailAPIClient(config.accessToken, config.fetch).get(buildMessageGetURL({
|
|
155
236
|
messageID: config.messageID
|
|
156
237
|
}), {
|
|
157
|
-
headers: {
|
|
158
|
-
Authorization: `Bearer ${config.accessToken}`
|
|
159
|
-
},
|
|
160
238
|
signal: config.signal
|
|
161
239
|
});
|
|
162
|
-
if (!response.ok) throw await googleMailAPIError(response);
|
|
163
|
-
return await response.json();
|
|
164
240
|
}
|
|
165
241
|
// Targeted single-thread fetch — a real threads.get(format=full) read, mapped
|
|
166
242
|
// from its own resource (id + constituent messages), never synthesized from a
|
|
167
243
|
// bulk message listing's locally-scanned subject.
|
|
168
244
|
export async function getThread(config) {
|
|
169
|
-
|
|
170
|
-
const response = await fetchFn(buildThreadGetURL({
|
|
245
|
+
return mailAPIClient(config.accessToken, config.fetch).get(buildThreadGetURL({
|
|
171
246
|
threadID: config.threadID
|
|
172
247
|
}), {
|
|
173
|
-
headers: {
|
|
174
|
-
Authorization: `Bearer ${config.accessToken}`
|
|
175
|
-
},
|
|
176
248
|
signal: config.signal
|
|
177
249
|
});
|
|
178
|
-
if (!response.ok) throw await googleMailAPIError(response);
|
|
179
|
-
return await response.json();
|
|
180
250
|
}
|
|
181
251
|
// Targeted single-label fetch. Unlike labels.list (buildLabelsListURL, used by
|
|
182
252
|
// the bulk labelBatch), a direct labels.get returns the full resource
|
|
183
253
|
// including `color` — the list endpoint omits it.
|
|
184
254
|
export async function getLabel(config) {
|
|
185
|
-
|
|
186
|
-
const response = await fetchFn(buildLabelGetURL({
|
|
255
|
+
return mailAPIClient(config.accessToken, config.fetch).get(buildLabelGetURL({
|
|
187
256
|
labelID: config.labelID
|
|
188
257
|
}), {
|
|
189
|
-
headers: {
|
|
190
|
-
Authorization: `Bearer ${config.accessToken}`
|
|
191
|
-
},
|
|
192
258
|
signal: config.signal
|
|
193
259
|
});
|
|
194
|
-
if (!response.ok) throw await googleMailAPIError(response);
|
|
195
|
-
return await response.json();
|
|
196
260
|
}
|
|
197
261
|
function streamOfBytes(bytes) {
|
|
198
262
|
return new ReadableStream({
|
|
@@ -215,17 +279,10 @@ export async function importMessageAttachment(config) {
|
|
|
215
279
|
if (target == null) {
|
|
216
280
|
throw new Error(`Google Mail: no attachment with providerRef "${config.providerRef}"`);
|
|
217
281
|
}
|
|
218
|
-
const
|
|
219
|
-
const response = await fetchFn(buildAttachmentGetURL({
|
|
282
|
+
const body = await mailAPIClient(config.accessToken, config.fetch).get(buildAttachmentGetURL({
|
|
220
283
|
messageID: config.messageID,
|
|
221
284
|
attachmentID: config.providerRef
|
|
222
|
-
})
|
|
223
|
-
headers: {
|
|
224
|
-
Authorization: `Bearer ${config.accessToken}`
|
|
225
|
-
}
|
|
226
|
-
});
|
|
227
|
-
if (!response.ok) throw await googleMailAPIError(response);
|
|
228
|
-
const body = await response.json();
|
|
285
|
+
}));
|
|
229
286
|
if (body.data == null) throw new Error('Google Mail: attachment response has no data');
|
|
230
287
|
const bytes = decodeBase64UrlBytes(body.data);
|
|
231
288
|
const attachmentID = await config.writeAttachment(streamOfBytes(bytes), {
|
|
@@ -295,28 +352,22 @@ function requireString(params, key) {
|
|
|
295
352
|
}
|
|
296
353
|
// Gmail's write endpoints (messages.send, drafts.create, messages.modify) return only
|
|
297
354
|
// { id, threadId, labelIds } -- no payload/headers/body. Re-fetch the full message so the
|
|
298
|
-
// mapped entity carries real
|
|
355
|
+
// mapped entity carries real from/body/subject/etc. instead of empty placeholders.
|
|
299
356
|
// Everything here runs after the write went out, so any failure is marked sent: the
|
|
300
357
|
// write may have landed, whatever the read-back did.
|
|
301
358
|
async function readBackWrittenMessage(config) {
|
|
302
359
|
try {
|
|
303
360
|
const messageID = config.messageIDOf(await config.written.json());
|
|
304
|
-
const
|
|
305
|
-
const response = await fetchFn(buildMessageGetURL({
|
|
361
|
+
const message = await mailAPIClient(config.accessToken, config.fetch).get(buildMessageGetURL({
|
|
306
362
|
messageID
|
|
307
|
-
})
|
|
308
|
-
|
|
309
|
-
Authorization: `Bearer ${config.accessToken}`
|
|
310
|
-
}
|
|
311
|
-
});
|
|
312
|
-
if (!response.ok) throw await googleMailAPIError(response);
|
|
313
|
-
return mapMessageToEntity(await response.json());
|
|
363
|
+
}));
|
|
364
|
+
return mapMessageToEntity(message);
|
|
314
365
|
} catch (err) {
|
|
315
366
|
throw markWriteSent(err);
|
|
316
367
|
}
|
|
317
368
|
}
|
|
318
369
|
export async function sendMessage(config) {
|
|
319
|
-
const
|
|
370
|
+
const client = mailAPIClient(config.accessToken, config.fetch);
|
|
320
371
|
const raw = buildRawMessage({
|
|
321
372
|
to: config.params.to,
|
|
322
373
|
cc: config.params.cc,
|
|
@@ -324,17 +375,13 @@ export async function sendMessage(config) {
|
|
|
324
375
|
subject: requireString(config.params, 'subject'),
|
|
325
376
|
body: requireString(config.params, 'body')
|
|
326
377
|
});
|
|
327
|
-
const response = await
|
|
378
|
+
const response = await client.request(`${MAIL_BASE_URL}/users/me/messages/send`, {
|
|
328
379
|
method: 'POST',
|
|
329
|
-
|
|
330
|
-
Authorization: `Bearer ${config.accessToken}`,
|
|
331
|
-
'Content-Type': 'application/json'
|
|
332
|
-
},
|
|
333
|
-
body: JSON.stringify({
|
|
380
|
+
body: {
|
|
334
381
|
raw
|
|
335
|
-
}
|
|
382
|
+
}
|
|
336
383
|
});
|
|
337
|
-
if (!response.ok)
|
|
384
|
+
if (!response.ok) return client.fail(response);
|
|
338
385
|
return readBackWrittenMessage({
|
|
339
386
|
accessToken: config.accessToken,
|
|
340
387
|
written: response,
|
|
@@ -343,7 +390,7 @@ export async function sendMessage(config) {
|
|
|
343
390
|
});
|
|
344
391
|
}
|
|
345
392
|
export async function createDraft(config) {
|
|
346
|
-
const
|
|
393
|
+
const client = mailAPIClient(config.accessToken, config.fetch);
|
|
347
394
|
const raw = buildRawMessage({
|
|
348
395
|
to: config.params.to,
|
|
349
396
|
cc: config.params.cc,
|
|
@@ -351,19 +398,15 @@ export async function createDraft(config) {
|
|
|
351
398
|
subject: requireString(config.params, 'subject'),
|
|
352
399
|
body: requireString(config.params, 'body')
|
|
353
400
|
});
|
|
354
|
-
const response = await
|
|
401
|
+
const response = await client.request(`${MAIL_BASE_URL}/users/me/drafts`, {
|
|
355
402
|
method: 'POST',
|
|
356
|
-
|
|
357
|
-
Authorization: `Bearer ${config.accessToken}`,
|
|
358
|
-
'Content-Type': 'application/json'
|
|
359
|
-
},
|
|
360
|
-
body: JSON.stringify({
|
|
403
|
+
body: {
|
|
361
404
|
message: {
|
|
362
405
|
raw
|
|
363
406
|
}
|
|
364
|
-
}
|
|
407
|
+
}
|
|
365
408
|
});
|
|
366
|
-
if (!response.ok)
|
|
409
|
+
if (!response.ok) return client.fail(response);
|
|
367
410
|
return readBackWrittenMessage({
|
|
368
411
|
accessToken: config.accessToken,
|
|
369
412
|
written: response,
|
|
@@ -372,20 +415,16 @@ export async function createDraft(config) {
|
|
|
372
415
|
});
|
|
373
416
|
}
|
|
374
417
|
export async function modifyLabels(config) {
|
|
375
|
-
const
|
|
418
|
+
const client = mailAPIClient(config.accessToken, config.fetch);
|
|
376
419
|
const url = `${MAIL_BASE_URL}/users/me/messages/${encodeURIComponent(config.messageID)}/modify`;
|
|
377
|
-
const response = await
|
|
420
|
+
const response = await client.request(url, {
|
|
378
421
|
method: 'POST',
|
|
379
|
-
|
|
380
|
-
Authorization: `Bearer ${config.accessToken}`,
|
|
381
|
-
'Content-Type': 'application/json'
|
|
382
|
-
},
|
|
383
|
-
body: JSON.stringify({
|
|
422
|
+
body: {
|
|
384
423
|
addLabelIds: config.addLabelIds ?? [],
|
|
385
424
|
removeLabelIds: config.removeLabelIds ?? []
|
|
386
|
-
}
|
|
425
|
+
}
|
|
387
426
|
});
|
|
388
|
-
if (!response.ok)
|
|
427
|
+
if (!response.ok) return client.fail(response);
|
|
389
428
|
return readBackWrittenMessage({
|
|
390
429
|
accessToken: config.accessToken,
|
|
391
430
|
written: response,
|
|
@@ -414,31 +453,19 @@ function buildLabelBody(params) {
|
|
|
414
453
|
return body;
|
|
415
454
|
}
|
|
416
455
|
export async function createLabel(config) {
|
|
417
|
-
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
418
456
|
const body = buildLabelBody(config.params);
|
|
419
457
|
if (typeof body.name !== 'string') throw new Error('Google Mail: "name" is required');
|
|
420
|
-
const
|
|
458
|
+
const label = await mailAPIClient(config.accessToken, config.fetch).send(buildLabelsListURL(), {
|
|
421
459
|
method: 'POST',
|
|
422
|
-
|
|
423
|
-
Authorization: `Bearer ${config.accessToken}`,
|
|
424
|
-
'Content-Type': 'application/json'
|
|
425
|
-
},
|
|
426
|
-
body: JSON.stringify(body)
|
|
460
|
+
body
|
|
427
461
|
});
|
|
428
|
-
|
|
429
|
-
return mapLabelToEntity(await response.json());
|
|
462
|
+
return mapLabelToEntity(label);
|
|
430
463
|
}
|
|
431
464
|
export async function updateLabel(config) {
|
|
432
|
-
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
433
465
|
const url = `${MAIL_BASE_URL}/users/me/labels/${encodeURIComponent(config.labelID)}`;
|
|
434
|
-
const
|
|
466
|
+
const label = await mailAPIClient(config.accessToken, config.fetch).send(url, {
|
|
435
467
|
method: 'PATCH',
|
|
436
|
-
|
|
437
|
-
Authorization: `Bearer ${config.accessToken}`,
|
|
438
|
-
'Content-Type': 'application/json'
|
|
439
|
-
},
|
|
440
|
-
body: JSON.stringify(buildLabelBody(config.params))
|
|
468
|
+
body: buildLabelBody(config.params)
|
|
441
469
|
});
|
|
442
|
-
|
|
443
|
-
return mapLabelToEntity(await response.json());
|
|
470
|
+
return mapLabelToEntity(label);
|
|
444
471
|
}
|
package/lib/model.d.ts
CHANGED
|
@@ -5,10 +5,6 @@ export declare const googleMailThreadDefinition: {
|
|
|
5
5
|
readonly schema: {
|
|
6
6
|
readonly type: "object";
|
|
7
7
|
readonly properties: {
|
|
8
|
-
readonly title: {
|
|
9
|
-
readonly type: "string";
|
|
10
|
-
readonly maxLength: 200;
|
|
11
|
-
};
|
|
12
8
|
readonly sourceService: {
|
|
13
9
|
readonly $ref: "#/definitions/SourceService";
|
|
14
10
|
};
|
|
@@ -47,7 +43,7 @@ export declare const googleMailThreadDefinition: {
|
|
|
47
43
|
};
|
|
48
44
|
readonly fieldsMeta: {};
|
|
49
45
|
readonly providerMergePolicy: {
|
|
50
|
-
readonly group: [
|
|
46
|
+
readonly group: [...string[], "sourceService", "sourceID", "sourceURL"];
|
|
51
47
|
};
|
|
52
48
|
};
|
|
53
49
|
export declare const googleMailMessageDefinition: {
|
|
@@ -57,43 +53,16 @@ export declare const googleMailMessageDefinition: {
|
|
|
57
53
|
readonly schema: {
|
|
58
54
|
readonly type: "object";
|
|
59
55
|
readonly properties: {
|
|
60
|
-
readonly body: {
|
|
61
|
-
readonly type: "string";
|
|
62
|
-
};
|
|
63
|
-
readonly sender: {
|
|
64
|
-
readonly type: "string";
|
|
65
|
-
readonly minLength: 1;
|
|
66
|
-
readonly maxLength: 320;
|
|
67
|
-
};
|
|
68
|
-
readonly subject: {
|
|
69
|
-
readonly type: "string";
|
|
70
|
-
};
|
|
71
56
|
readonly snippet: {
|
|
72
57
|
readonly type: "string";
|
|
73
58
|
};
|
|
74
|
-
readonly to: {
|
|
75
|
-
readonly type: "array";
|
|
76
|
-
readonly title: "To";
|
|
77
|
-
readonly items: {
|
|
78
|
-
readonly type: "string";
|
|
79
|
-
};
|
|
80
|
-
};
|
|
81
|
-
readonly cc: {
|
|
82
|
-
readonly type: "array";
|
|
83
|
-
readonly title: "Cc";
|
|
84
|
-
readonly items: {
|
|
85
|
-
readonly type: "string";
|
|
86
|
-
};
|
|
87
|
-
};
|
|
88
59
|
readonly labels: {
|
|
89
60
|
readonly type: "array";
|
|
90
61
|
readonly title: "Labels";
|
|
91
62
|
readonly items: {
|
|
92
63
|
readonly type: "string";
|
|
93
64
|
};
|
|
94
|
-
|
|
95
|
-
readonly sentAt: {
|
|
96
|
-
readonly $ref: "#/definitions/DateTime";
|
|
65
|
+
readonly maxItems: 100;
|
|
97
66
|
};
|
|
98
67
|
readonly unread: {
|
|
99
68
|
readonly type: "boolean";
|
|
@@ -104,13 +73,11 @@ export declare const googleMailMessageDefinition: {
|
|
|
104
73
|
readonly items: {
|
|
105
74
|
readonly $ref: "#/definitions/Attachment";
|
|
106
75
|
};
|
|
76
|
+
readonly maxItems: 100;
|
|
107
77
|
};
|
|
108
78
|
readonly threadID: {
|
|
109
79
|
readonly type: "string";
|
|
110
80
|
};
|
|
111
|
-
readonly thread: {
|
|
112
|
-
readonly $ref: "#/definitions/ThreadDocID";
|
|
113
|
-
};
|
|
114
81
|
readonly sourceService: {
|
|
115
82
|
readonly $ref: "#/definitions/SourceService";
|
|
116
83
|
};
|
|
@@ -124,14 +91,9 @@ export declare const googleMailMessageDefinition: {
|
|
|
124
91
|
readonly $ref: "#/definitions/DateTime";
|
|
125
92
|
};
|
|
126
93
|
};
|
|
127
|
-
readonly required: ["
|
|
94
|
+
readonly required: ["threadID", "thread", "sourceService", "sourceID", "lastSyncedAt"];
|
|
128
95
|
readonly additionalProperties: false;
|
|
129
96
|
readonly definitions: {
|
|
130
|
-
readonly ThreadDocID: {
|
|
131
|
-
readonly type: "string";
|
|
132
|
-
readonly title: "DocID";
|
|
133
|
-
readonly pattern: "^k[0-9a-z]{10,120}$";
|
|
134
|
-
};
|
|
135
97
|
readonly Attachment: {
|
|
136
98
|
readonly type: "object";
|
|
137
99
|
readonly title: "Attachment";
|
|
@@ -176,14 +138,9 @@ export declare const googleMailMessageDefinition: {
|
|
|
176
138
|
readonly DateTime: import("json-schema-typed").JSONSchema.String;
|
|
177
139
|
};
|
|
178
140
|
};
|
|
179
|
-
readonly fieldsMeta: {
|
|
180
|
-
readonly thread: {
|
|
181
|
-
readonly type: "document";
|
|
182
|
-
readonly model: "#0";
|
|
183
|
-
};
|
|
184
|
-
};
|
|
141
|
+
readonly fieldsMeta: {};
|
|
185
142
|
readonly providerMergePolicy: {
|
|
186
|
-
readonly group: [
|
|
143
|
+
readonly group: [...string[], "sourceService", "sourceID", "sourceURL"];
|
|
187
144
|
};
|
|
188
145
|
};
|
|
189
146
|
export declare const googleMailLabelDefinition: {
|
package/lib/model.js
CHANGED
|
@@ -1,37 +1,25 @@
|
|
|
1
|
-
import { connectorClusters, connectorModelIDs } from '@kubun/connector';
|
|
2
|
-
import {
|
|
1
|
+
import { connectorClusters, connectorModelIDs, externalEntityDefinition } from '@kubun/connector';
|
|
2
|
+
import { SHORT_LIST_MAX_ITEMS } from '@kubun/models-core';
|
|
3
|
+
import { messagingClusters, messagingModelDefinitions, messagingModelIDs } from '@kubun/models-messaging';
|
|
3
4
|
import { GraphBuilder } from '@kubun/protocol';
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
SourceID: {
|
|
13
|
-
type: 'string',
|
|
14
|
-
title: 'SourceID',
|
|
15
|
-
minLength: 1,
|
|
16
|
-
maxLength: 500
|
|
17
|
-
},
|
|
18
|
-
SourceURL: urlSchema,
|
|
19
|
-
DateTime: dateTimeSchema
|
|
20
|
-
};
|
|
5
|
+
const threadKindProviderFields = messagingModelDefinitions.find((definition)=>definition.name === 'ThreadKind')?.providerMergePolicy?.group;
|
|
6
|
+
const messageKindProviderFields = messagingModelDefinitions.find((definition)=>definition.name === 'MessageKind')?.providerMergePolicy?.group;
|
|
7
|
+
const emailKindProviderFields = messagingModelDefinitions.find((definition)=>definition.name === 'EmailKind')?.providerMergePolicy?.group;
|
|
8
|
+
if (threadKindProviderFields == null || messageKindProviderFields == null || emailKindProviderFields == null) {
|
|
9
|
+
throw new Error('Messaging provider fields are missing');
|
|
10
|
+
}
|
|
11
|
+
// The shared thread field (title) comes from ThreadKind; only what is
|
|
12
|
+
// Google's own is declared here.
|
|
21
13
|
export const googleMailThreadDefinition = {
|
|
22
14
|
name: 'GoogleMailThread',
|
|
23
15
|
behavior: 'default',
|
|
24
16
|
interfaces: [
|
|
25
17
|
connectorModelIDs.ExternalEntity,
|
|
26
|
-
|
|
18
|
+
messagingModelIDs.ThreadKind
|
|
27
19
|
],
|
|
28
20
|
schema: {
|
|
29
21
|
type: 'object',
|
|
30
22
|
properties: {
|
|
31
|
-
title: {
|
|
32
|
-
type: 'string',
|
|
33
|
-
maxLength: 200
|
|
34
|
-
},
|
|
35
23
|
sourceService: {
|
|
36
24
|
$ref: '#/definitions/SourceService'
|
|
37
25
|
},
|
|
@@ -52,7 +40,7 @@ export const googleMailThreadDefinition = {
|
|
|
52
40
|
],
|
|
53
41
|
additionalProperties: false,
|
|
54
42
|
definitions: {
|
|
55
|
-
...
|
|
43
|
+
...externalEntityDefinition.schema.definitions
|
|
56
44
|
}
|
|
57
45
|
},
|
|
58
46
|
fieldsMeta: {},
|
|
@@ -60,60 +48,35 @@ export const googleMailThreadDefinition = {
|
|
|
60
48
|
// provider content, so it stays outside the group.
|
|
61
49
|
providerMergePolicy: {
|
|
62
50
|
group: [
|
|
63
|
-
|
|
51
|
+
...threadKindProviderFields,
|
|
64
52
|
'sourceService',
|
|
65
53
|
'sourceID',
|
|
66
54
|
'sourceURL'
|
|
67
55
|
]
|
|
68
56
|
}
|
|
69
57
|
};
|
|
58
|
+
// Generic message fields come from MessageKind, and mail fields from EmailKind.
|
|
59
|
+
// Only what is Google's own is declared here.
|
|
70
60
|
export const googleMailMessageDefinition = {
|
|
71
61
|
name: 'GoogleMailMessage',
|
|
72
62
|
behavior: 'default',
|
|
73
63
|
interfaces: [
|
|
74
64
|
connectorModelIDs.ExternalEntity,
|
|
75
|
-
|
|
65
|
+
messagingModelIDs.EmailKind
|
|
76
66
|
],
|
|
77
67
|
schema: {
|
|
78
68
|
type: 'object',
|
|
79
69
|
properties: {
|
|
80
|
-
body: {
|
|
81
|
-
type: 'string'
|
|
82
|
-
},
|
|
83
|
-
sender: {
|
|
84
|
-
type: 'string',
|
|
85
|
-
minLength: 1,
|
|
86
|
-
maxLength: 320
|
|
87
|
-
},
|
|
88
|
-
subject: {
|
|
89
|
-
type: 'string'
|
|
90
|
-
},
|
|
91
70
|
snippet: {
|
|
92
71
|
type: 'string'
|
|
93
72
|
},
|
|
94
|
-
to: {
|
|
95
|
-
type: 'array',
|
|
96
|
-
title: 'To',
|
|
97
|
-
items: {
|
|
98
|
-
type: 'string'
|
|
99
|
-
}
|
|
100
|
-
},
|
|
101
|
-
cc: {
|
|
102
|
-
type: 'array',
|
|
103
|
-
title: 'Cc',
|
|
104
|
-
items: {
|
|
105
|
-
type: 'string'
|
|
106
|
-
}
|
|
107
|
-
},
|
|
108
73
|
labels: {
|
|
109
74
|
type: 'array',
|
|
110
75
|
title: 'Labels',
|
|
111
76
|
items: {
|
|
112
77
|
type: 'string'
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
sentAt: {
|
|
116
|
-
$ref: '#/definitions/DateTime'
|
|
78
|
+
},
|
|
79
|
+
maxItems: SHORT_LIST_MAX_ITEMS
|
|
117
80
|
},
|
|
118
81
|
unread: {
|
|
119
82
|
type: 'boolean'
|
|
@@ -123,14 +86,12 @@ export const googleMailMessageDefinition = {
|
|
|
123
86
|
title: 'Attachments',
|
|
124
87
|
items: {
|
|
125
88
|
$ref: '#/definitions/Attachment'
|
|
126
|
-
}
|
|
89
|
+
},
|
|
90
|
+
maxItems: SHORT_LIST_MAX_ITEMS
|
|
127
91
|
},
|
|
128
92
|
threadID: {
|
|
129
93
|
type: 'string'
|
|
130
94
|
},
|
|
131
|
-
thread: {
|
|
132
|
-
$ref: '#/definitions/ThreadDocID'
|
|
133
|
-
},
|
|
134
95
|
sourceService: {
|
|
135
96
|
$ref: '#/definitions/SourceService'
|
|
136
97
|
},
|
|
@@ -145,8 +106,6 @@ export const googleMailMessageDefinition = {
|
|
|
145
106
|
}
|
|
146
107
|
},
|
|
147
108
|
required: [
|
|
148
|
-
'body',
|
|
149
|
-
'sender',
|
|
150
109
|
'threadID',
|
|
151
110
|
'thread',
|
|
152
111
|
'sourceService',
|
|
@@ -155,8 +114,7 @@ export const googleMailMessageDefinition = {
|
|
|
155
114
|
],
|
|
156
115
|
additionalProperties: false,
|
|
157
116
|
definitions: {
|
|
158
|
-
...
|
|
159
|
-
ThreadDocID: docIDSchema,
|
|
117
|
+
...externalEntityDefinition.schema.definitions,
|
|
160
118
|
Attachment: {
|
|
161
119
|
type: 'object',
|
|
162
120
|
title: 'Attachment',
|
|
@@ -190,29 +148,22 @@ export const googleMailMessageDefinition = {
|
|
|
190
148
|
}
|
|
191
149
|
}
|
|
192
150
|
},
|
|
193
|
-
fieldsMeta: {
|
|
194
|
-
thread: {
|
|
195
|
-
type: 'document',
|
|
196
|
-
model: '#0'
|
|
197
|
-
}
|
|
198
|
-
},
|
|
151
|
+
fieldsMeta: {},
|
|
199
152
|
// lastSyncedAt is device-local sync bookkeeping; attachments carries the
|
|
200
153
|
// locally-materialized `blob` annotation (absent until an explicit import
|
|
201
154
|
// fills it), so the whole field stays outside the group rather than
|
|
202
155
|
// splitting a single top-level field between provider and device content.
|
|
203
156
|
providerMergePolicy: {
|
|
204
157
|
group: [
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
'subject',
|
|
158
|
+
...messageKindProviderFields.slice(0, 2),
|
|
159
|
+
...emailKindProviderFields.slice(0, 1),
|
|
208
160
|
'snippet',
|
|
209
|
-
|
|
210
|
-
'cc',
|
|
161
|
+
...emailKindProviderFields.slice(1),
|
|
211
162
|
'labels',
|
|
212
|
-
|
|
163
|
+
...messageKindProviderFields.slice(2, 3),
|
|
213
164
|
'unread',
|
|
214
165
|
'threadID',
|
|
215
|
-
|
|
166
|
+
...messageKindProviderFields.slice(3),
|
|
216
167
|
'sourceService',
|
|
217
168
|
'sourceID',
|
|
218
169
|
'sourceURL'
|
|
@@ -274,7 +225,7 @@ export const googleMailLabelDefinition = {
|
|
|
274
225
|
],
|
|
275
226
|
additionalProperties: false,
|
|
276
227
|
definitions: {
|
|
277
|
-
...
|
|
228
|
+
...externalEntityDefinition.schema.definitions
|
|
278
229
|
}
|
|
279
230
|
},
|
|
280
231
|
fieldsMeta: {},
|
|
@@ -297,7 +248,7 @@ export const googleMailLabelDefinition = {
|
|
|
297
248
|
const builder = new GraphBuilder({
|
|
298
249
|
externalClusters: {
|
|
299
250
|
...connectorClusters,
|
|
300
|
-
...
|
|
251
|
+
...messagingClusters
|
|
301
252
|
}
|
|
302
253
|
});
|
|
303
254
|
builder.add(googleMailThreadDefinition); // #0
|
package/lib/provider.js
CHANGED
|
@@ -1,32 +1,38 @@
|
|
|
1
|
-
import { resolveModelID } from '@kubun/connector';
|
|
2
|
-
import {
|
|
1
|
+
import { fetchOrAbsent, parseMaxAgeDays, resolveModelID } from '@kubun/connector';
|
|
2
|
+
import { mapConcurrent, retry } from '@sozai/async';
|
|
3
|
+
import { buildHistoryListURL, buildLabelsListURL, buildMessagesListURL, buildProfileURL, getHeader, getLabel, getMessage, getThread, mailAPIClient, mapLabelToEntity, mapMessageToEntity, mapThreadToEntity } from './api.js';
|
|
3
4
|
import { googleMailClusters } from './model.js';
|
|
5
|
+
// messages.get costs 5 quota units against Gmail's 250 units/s per-user limit.
|
|
6
|
+
const MESSAGE_FETCH_CONCURRENCY = 4;
|
|
7
|
+
// A burst can still trip Gmail's per-user rate limit; a limited message is
|
|
8
|
+
// retried on its own so the rest of the sync keeps going.
|
|
9
|
+
const MESSAGE_RATE_LIMIT_POLICY = {
|
|
10
|
+
maxAttempts: 6,
|
|
11
|
+
backoff: {
|
|
12
|
+
initialMs: 250,
|
|
13
|
+
multiplier: 2,
|
|
14
|
+
maxMs: 8000,
|
|
15
|
+
jitter: true
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
// 429, or Gmail's 403 rateLimitExceeded / userRateLimitExceeded.
|
|
19
|
+
function isRateLimited(error) {
|
|
20
|
+
const { httpStatus, message } = error ?? {};
|
|
21
|
+
if (httpStatus === 429) return true;
|
|
22
|
+
return httpStatus === 403 && typeof message === 'string' && /rate ?limit/i.test(message);
|
|
23
|
+
}
|
|
4
24
|
export const DEFAULT_QUERY = 'in:inbox';
|
|
5
25
|
const THREAD_MODEL_ID = resolveModelID(googleMailClusters, 'GoogleMailThread');
|
|
6
26
|
const MESSAGE_MODEL_ID = resolveModelID(googleMailClusters, 'GoogleMailMessage');
|
|
7
27
|
const LABEL_MODEL_ID = resolveModelID(googleMailClusters, 'GoogleMailLabel');
|
|
8
|
-
async function fetchJSON(url, credential, fetchFn, signal) {
|
|
9
|
-
const response = await fetchFn(url, {
|
|
10
|
-
headers: {
|
|
11
|
-
Authorization: `Bearer ${credential.accessToken}`
|
|
12
|
-
},
|
|
13
|
-
signal
|
|
14
|
-
});
|
|
15
|
-
if (!response.ok) {
|
|
16
|
-
throw new Error(`Google Mail API error: ${await parseGoogleError(response)}`);
|
|
17
|
-
}
|
|
18
|
-
return await response.json();
|
|
19
|
-
}
|
|
20
28
|
/**
|
|
21
29
|
* The absolute lower bound (epoch seconds) for a full message listing: a window
|
|
22
30
|
* already frozen in the checkpoint, else `now − maxAge` computed once. Null when
|
|
23
31
|
* the boundary carries no `maxAge` (no floor).
|
|
24
32
|
*/ function windowStartSeconds(boundary, frozen) {
|
|
25
33
|
if (frozen != null) return frozen;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (match?.[1] == null) return null;
|
|
29
|
-
const days = Number.parseInt(match[1], 10);
|
|
34
|
+
const days = parseMaxAgeDays(boundary);
|
|
35
|
+
if (days == null) return null;
|
|
30
36
|
return Math.floor((Date.now() - days * 86_400_000) / 1000);
|
|
31
37
|
}
|
|
32
38
|
/** Appends an absolute `after:<epoch seconds>` floor to a Gmail search query. */ function applyAfter(query, windowStart) {
|
|
@@ -44,21 +50,18 @@ function maxHistoryId(messages) {
|
|
|
44
50
|
}
|
|
45
51
|
export function createGoogleMailProvider(config) {
|
|
46
52
|
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
53
|
+
const client = mailAPIClient(config.credential.accessToken, fetchFn);
|
|
47
54
|
const mintFetchHLC = config.mintFetchHLC;
|
|
48
|
-
// getMessage/getThread/getLabel (api.ts) throw a plain Error tagged with
|
|
49
|
-
// `.httpStatus` — this is the seam fetchEntity/reconcile use to turn a 404
|
|
50
|
-
// into a `present: false` result instead of a throw.
|
|
51
|
-
function isNotFound(err) {
|
|
52
|
-
return err?.httpStatus === 404;
|
|
53
|
-
}
|
|
54
55
|
async function listMessageIDs(query, signal) {
|
|
55
56
|
const ids = [];
|
|
56
57
|
let pageToken;
|
|
57
58
|
do {
|
|
58
|
-
const data = await
|
|
59
|
+
const data = await client.get(buildMessagesListURL({
|
|
59
60
|
query,
|
|
60
61
|
pageToken
|
|
61
|
-
}),
|
|
62
|
+
}), {
|
|
63
|
+
signal
|
|
64
|
+
});
|
|
62
65
|
for (const m of data.messages ?? [])ids.push(m);
|
|
63
66
|
pageToken = data.nextPageToken;
|
|
64
67
|
}while (pageToken != null)
|
|
@@ -68,22 +71,36 @@ export function createGoogleMailProvider(config) {
|
|
|
68
71
|
// shared api.ts getMessage so both the bulk and targeted (fetchEntity)
|
|
69
72
|
// paths share one 404-tagging choke point.
|
|
70
73
|
async function getMessageResource(id, signal) {
|
|
71
|
-
return getMessage({
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
return retry(({ signal: attemptSignal })=>getMessage({
|
|
75
|
+
accessToken: config.credential.accessToken,
|
|
76
|
+
messageID: id,
|
|
77
|
+
fetch: fetchFn,
|
|
78
|
+
signal: attemptSignal
|
|
79
|
+
}), {
|
|
80
|
+
policy: MESSAGE_RATE_LIMIT_POLICY,
|
|
81
|
+
signal,
|
|
82
|
+
retryable: isRateLimited
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function getMessageResources(ids, signal) {
|
|
86
|
+
return mapConcurrent(ids, (id, _index, callSignal)=>getMessageResource(id, callSignal), {
|
|
87
|
+
limit: MESSAGE_FETCH_CONCURRENCY,
|
|
75
88
|
signal
|
|
76
89
|
});
|
|
77
90
|
}
|
|
78
91
|
async function profileHistoryId(signal) {
|
|
79
|
-
const profile = await
|
|
92
|
+
const profile = await client.get(buildProfileURL(), {
|
|
93
|
+
signal
|
|
94
|
+
});
|
|
80
95
|
return profile.historyId ?? '0';
|
|
81
96
|
}
|
|
82
97
|
// Labels have no incremental history API, so every sync re-lists them in full.
|
|
83
98
|
// The list endpoint is a single cheap call; it omits color (captured only on
|
|
84
99
|
// create/edit) but carries id, name, type, and the visibility fields.
|
|
85
100
|
async function labelBatch(signal) {
|
|
86
|
-
const data = await
|
|
101
|
+
const data = await client.get(buildLabelsListURL(), {
|
|
102
|
+
signal
|
|
103
|
+
});
|
|
87
104
|
const sourceFetchHLC = mintFetchHLC();
|
|
88
105
|
return {
|
|
89
106
|
modelID: LABEL_MODEL_ID,
|
|
@@ -103,10 +120,7 @@ export function createGoogleMailProvider(config) {
|
|
|
103
120
|
const windowStart = windowStartSeconds(params.boundary, prior?.windowStart);
|
|
104
121
|
const query = applyAfter(config.query ?? DEFAULT_QUERY, windowStart);
|
|
105
122
|
const ids = await listMessageIDs(query, params.signal);
|
|
106
|
-
const messages =
|
|
107
|
-
for (const { id } of ids){
|
|
108
|
-
messages.push(await getMessageResource(id, params.signal));
|
|
109
|
-
}
|
|
123
|
+
const messages = await getMessageResources(ids.map(({ id })=>id), params.signal);
|
|
110
124
|
// Thread batch: one thread per distinct threadId, titled by its first message's subject.
|
|
111
125
|
const threadSubjects = new Map();
|
|
112
126
|
for (const m of messages){
|
|
@@ -157,22 +171,18 @@ export function createGoogleMailProvider(config) {
|
|
|
157
171
|
let latestHistoryId = checkpoint.historyId;
|
|
158
172
|
let pageToken;
|
|
159
173
|
do {
|
|
160
|
-
const response = await
|
|
174
|
+
const response = await client.request(buildHistoryListURL({
|
|
161
175
|
startHistoryId: checkpoint.historyId,
|
|
162
176
|
pageToken
|
|
163
177
|
}), {
|
|
164
|
-
|
|
165
|
-
Authorization: `Bearer ${config.credential.accessToken}`
|
|
166
|
-
},
|
|
178
|
+
method: 'GET',
|
|
167
179
|
signal: params.signal
|
|
168
180
|
});
|
|
169
181
|
if (response.status === 404) {
|
|
170
182
|
yield* fetchAllEntities(params);
|
|
171
183
|
return;
|
|
172
184
|
}
|
|
173
|
-
if (!response.ok)
|
|
174
|
-
throw new Error(`Google Mail API error: ${await parseGoogleError(response)}`);
|
|
175
|
-
}
|
|
185
|
+
if (!response.ok) await client.fail(response);
|
|
176
186
|
const data = await response.json();
|
|
177
187
|
for (const record of data.history ?? []){
|
|
178
188
|
for (const ref of [
|
|
@@ -193,10 +203,7 @@ export function createGoogleMailProvider(config) {
|
|
|
193
203
|
const survivingIDs = [
|
|
194
204
|
...addedIDs.keys()
|
|
195
205
|
].filter((id)=>!deletedIDs.has(id));
|
|
196
|
-
const messages =
|
|
197
|
-
for (const id of survivingIDs){
|
|
198
|
-
messages.push(await getMessageResource(id, params.signal));
|
|
199
|
-
}
|
|
206
|
+
const messages = await getMessageResources(survivingIDs, params.signal);
|
|
200
207
|
const threadSubjects = new Map();
|
|
201
208
|
for (const m of messages){
|
|
202
209
|
if (!threadSubjects.has(m.threadId)) {
|
|
@@ -247,53 +254,42 @@ export function createGoogleMailProvider(config) {
|
|
|
247
254
|
// the list endpoint labelBatch uses).
|
|
248
255
|
async function fetchEntity(model, sourceID) {
|
|
249
256
|
const sourceFetchHLC = mintFetchHLC();
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
257
|
+
return fetchOrAbsent({
|
|
258
|
+
sourceID,
|
|
259
|
+
sourceFetchHLC,
|
|
260
|
+
read: async ()=>{
|
|
261
|
+
if (model === THREAD_MODEL_ID) {
|
|
262
|
+
const thread = await getThread({
|
|
263
|
+
accessToken: config.credential.accessToken,
|
|
264
|
+
threadID: sourceID,
|
|
265
|
+
fetch: fetchFn
|
|
266
|
+
});
|
|
267
|
+
const first = thread.messages?.[0];
|
|
268
|
+
const subject = first != null ? getHeader(first.payload?.headers, 'Subject') : undefined;
|
|
269
|
+
return {
|
|
270
|
+
record: mapThreadToEntity(thread.id, subject)
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
if (model === LABEL_MODEL_ID) {
|
|
274
|
+
const label = await getLabel({
|
|
275
|
+
accessToken: config.credential.accessToken,
|
|
276
|
+
labelID: sourceID,
|
|
277
|
+
fetch: fetchFn
|
|
278
|
+
});
|
|
279
|
+
return {
|
|
280
|
+
record: mapLabelToEntity(label)
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const message = await getMessage({
|
|
267
284
|
accessToken: config.credential.accessToken,
|
|
268
|
-
|
|
285
|
+
messageID: sourceID,
|
|
269
286
|
fetch: fetchFn
|
|
270
287
|
});
|
|
271
288
|
return {
|
|
272
|
-
|
|
273
|
-
record: mapLabelToEntity(label),
|
|
274
|
-
sourceFetchHLC
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
const message = await getMessage({
|
|
278
|
-
accessToken: config.credential.accessToken,
|
|
279
|
-
messageID: sourceID,
|
|
280
|
-
fetch: fetchFn
|
|
281
|
-
});
|
|
282
|
-
return {
|
|
283
|
-
present: true,
|
|
284
|
-
record: mapMessageToEntity(message),
|
|
285
|
-
sourceFetchHLC
|
|
286
|
-
};
|
|
287
|
-
} catch (err) {
|
|
288
|
-
if (isNotFound(err)) {
|
|
289
|
-
return {
|
|
290
|
-
present: false,
|
|
291
|
-
sourceID,
|
|
292
|
-
sourceFetchHLC
|
|
289
|
+
record: mapMessageToEntity(message)
|
|
293
290
|
};
|
|
294
291
|
}
|
|
295
|
-
|
|
296
|
-
}
|
|
292
|
+
});
|
|
297
293
|
}
|
|
298
294
|
// Provider-authoritative re-read for write-back recovery. Gmail's
|
|
299
295
|
// resources carry no dependable ETag on a direct get, so this mirrors
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/connector-google-mail",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"keywords": [],
|
|
5
5
|
"license": "see LICENSE.md",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -15,14 +15,16 @@
|
|
|
15
15
|
"LICENSE.md"
|
|
16
16
|
],
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@kubun/connector": "^0.
|
|
19
|
-
"@kubun/credential-types": "^0.
|
|
20
|
-
"@kubun/hlc": "^0.
|
|
21
|
-
"@kubun/models": "^0.
|
|
22
|
-
"@kubun/
|
|
23
|
-
"@kubun/
|
|
24
|
-
"@kubun/
|
|
25
|
-
"@
|
|
18
|
+
"@kubun/connector": "^0.18.0",
|
|
19
|
+
"@kubun/credential-types": "^0.18.0",
|
|
20
|
+
"@kubun/hlc": "^0.18.0",
|
|
21
|
+
"@kubun/models-core": "^0.18.0",
|
|
22
|
+
"@kubun/models-messaging": "^0.18.0",
|
|
23
|
+
"@kubun/protocol": "^0.18.0",
|
|
24
|
+
"@kubun/provider-google": "^0.18.0",
|
|
25
|
+
"@kubun/scalars": "^0.18.0",
|
|
26
|
+
"@sozai/async": "^0.4.1",
|
|
27
|
+
"@sozai/codec": "^0.4.3"
|
|
26
28
|
},
|
|
27
29
|
"publishConfig": {
|
|
28
30
|
"access": "public"
|