@kubun/connector-google-mail 0.17.0 → 0.17.2
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 -0
- package/lib/actions.js +64 -8
- package/lib/api.d.ts +30 -1
- package/lib/api.js +105 -30
- package/lib/connector.js +66 -12
- package/lib/errors.d.ts +5 -0
- package/lib/errors.js +7 -0
- package/lib/index.d.ts +3 -1
- package/lib/index.js +3 -1
- package/lib/local-annotations.d.ts +8 -0
- package/lib/local-annotations.js +39 -0
- package/lib/model.d.ts +9 -0
- package/lib/model.js +49 -2
- package/lib/provider.d.ts +2 -0
- package/lib/provider.js +146 -22
- package/package.json +5 -4
package/lib/actions.d.ts
CHANGED
package/lib/actions.js
CHANGED
|
@@ -1,44 +1,89 @@
|
|
|
1
1
|
import { createDraft, createLabel, importMessageAttachment, modifyLabels, sendMessage, updateLabel } from './api.js';
|
|
2
|
+
import { GoogleMailError } from './errors.js';
|
|
3
|
+
// Gmail takes a label color only as the textColor/backgroundColor pair, so a
|
|
4
|
+
// write that changes one of them carries the other from the target document.
|
|
5
|
+
// 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
|
+
function withColorPair(params, existing) {
|
|
8
|
+
const changesOne = 'textColor' in params !== 'backgroundColor' in params;
|
|
9
|
+
if (!changesOne) return params;
|
|
10
|
+
const missing = 'textColor' in params ? 'backgroundColor' : 'textColor';
|
|
11
|
+
const companion = existing?.[missing];
|
|
12
|
+
if (typeof companion !== 'string' || companion === '') {
|
|
13
|
+
throw new GoogleMailError(`Google Mail: a label color needs both textColor and backgroundColor, and the label has no ${missing} to keep`, {
|
|
14
|
+
kind: 'rejected'
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
...params,
|
|
19
|
+
[missing]: companion
|
|
20
|
+
};
|
|
21
|
+
}
|
|
2
22
|
export function createGoogleMailActionHandler(config) {
|
|
23
|
+
const mintActionFetchHLC = config.mintFetchHLC;
|
|
3
24
|
return {
|
|
4
25
|
async create (model, params) {
|
|
5
26
|
if (model === 'GoogleMailLabel') {
|
|
6
|
-
|
|
27
|
+
const record = await createLabel({
|
|
7
28
|
accessToken: config.credential.accessToken,
|
|
8
29
|
params,
|
|
9
30
|
fetch: config.fetch
|
|
10
31
|
});
|
|
32
|
+
return {
|
|
33
|
+
present: true,
|
|
34
|
+
record,
|
|
35
|
+
sourceFetchHLC: mintActionFetchHLC()
|
|
36
|
+
};
|
|
11
37
|
}
|
|
12
38
|
if (params.draft === true) {
|
|
13
|
-
|
|
39
|
+
const record = await createDraft({
|
|
14
40
|
accessToken: config.credential.accessToken,
|
|
15
41
|
params,
|
|
16
42
|
fetch: config.fetch
|
|
17
43
|
});
|
|
44
|
+
return {
|
|
45
|
+
present: true,
|
|
46
|
+
record,
|
|
47
|
+
sourceFetchHLC: mintActionFetchHLC()
|
|
48
|
+
};
|
|
18
49
|
}
|
|
19
|
-
|
|
50
|
+
const record = await sendMessage({
|
|
20
51
|
accessToken: config.credential.accessToken,
|
|
21
52
|
params,
|
|
22
53
|
fetch: config.fetch
|
|
23
54
|
});
|
|
55
|
+
return {
|
|
56
|
+
present: true,
|
|
57
|
+
record,
|
|
58
|
+
sourceFetchHLC: mintActionFetchHLC()
|
|
59
|
+
};
|
|
24
60
|
},
|
|
25
61
|
async update (model, sourceID, params, existing) {
|
|
26
62
|
if (model === 'GoogleMailLabel') {
|
|
27
|
-
|
|
63
|
+
const record = await updateLabel({
|
|
28
64
|
accessToken: config.credential.accessToken,
|
|
29
65
|
labelID: sourceID,
|
|
30
|
-
params,
|
|
66
|
+
params: withColorPair(params, existing),
|
|
31
67
|
fetch: config.fetch
|
|
32
68
|
});
|
|
69
|
+
return {
|
|
70
|
+
present: true,
|
|
71
|
+
record,
|
|
72
|
+
sourceFetchHLC: mintActionFetchHLC()
|
|
73
|
+
};
|
|
33
74
|
}
|
|
34
75
|
// On GoogleMailMessage, a providerRef selects an attachment to import: its
|
|
35
76
|
// bytes are downloaded and written to the blob store, filling that entry's
|
|
36
|
-
// blob field.
|
|
77
|
+
// blob field. `attachments` is outside GoogleMailMessage's providerMergePolicy
|
|
78
|
+
// group (model.ts), so this write only ever touches a local-annotation
|
|
79
|
+
// field — never the provider content group — and says so (`localChange`):
|
|
80
|
+
// it is projected as an outside-group change, not a fetched SET. Without
|
|
81
|
+
// it, the update modifies labels as before.
|
|
37
82
|
if (typeof params.providerRef === 'string') {
|
|
38
83
|
if (config.writeAttachment == null) {
|
|
39
84
|
throw new Error('Google Mail: cannot import attachment, no blob plugin installed');
|
|
40
85
|
}
|
|
41
|
-
|
|
86
|
+
const record = await importMessageAttachment({
|
|
42
87
|
accessToken: config.credential.accessToken,
|
|
43
88
|
messageID: sourceID,
|
|
44
89
|
providerRef: params.providerRef,
|
|
@@ -46,14 +91,25 @@ export function createGoogleMailActionHandler(config) {
|
|
|
46
91
|
writeAttachment: config.writeAttachment,
|
|
47
92
|
fetch: config.fetch
|
|
48
93
|
});
|
|
94
|
+
return {
|
|
95
|
+
present: true,
|
|
96
|
+
record,
|
|
97
|
+
sourceFetchHLC: mintActionFetchHLC(),
|
|
98
|
+
localChange: true
|
|
99
|
+
};
|
|
49
100
|
}
|
|
50
|
-
|
|
101
|
+
const record = await modifyLabels({
|
|
51
102
|
accessToken: config.credential.accessToken,
|
|
52
103
|
messageID: sourceID,
|
|
53
104
|
addLabelIds: params.addLabelIds,
|
|
54
105
|
removeLabelIds: params.removeLabelIds,
|
|
55
106
|
fetch: config.fetch
|
|
56
107
|
});
|
|
108
|
+
return {
|
|
109
|
+
present: true,
|
|
110
|
+
record,
|
|
111
|
+
sourceFetchHLC: mintActionFetchHLC()
|
|
112
|
+
};
|
|
57
113
|
}
|
|
58
114
|
};
|
|
59
115
|
}
|
package/lib/api.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EntityRecord, type WriteAttachment } from '@kubun/connector';
|
|
2
2
|
export declare const MAIL_BASE_URL = "https://gmail.googleapis.com/gmail/v1";
|
|
3
3
|
export declare function parseGoogleError(response: Response): Promise<string>;
|
|
4
4
|
export declare function decodeBase64Url(data: string): string;
|
|
@@ -11,6 +11,12 @@ export declare function buildMessagesListURL(params: {
|
|
|
11
11
|
export declare function buildMessageGetURL(params: {
|
|
12
12
|
messageID: string;
|
|
13
13
|
}): string;
|
|
14
|
+
export declare function buildThreadGetURL(params: {
|
|
15
|
+
threadID: string;
|
|
16
|
+
}): string;
|
|
17
|
+
export declare function buildLabelGetURL(params: {
|
|
18
|
+
labelID: string;
|
|
19
|
+
}): string;
|
|
14
20
|
export declare function buildAttachmentGetURL(params: {
|
|
15
21
|
messageID: string;
|
|
16
22
|
attachmentID: string;
|
|
@@ -85,6 +91,11 @@ export type GmailMessagesListResponse = {
|
|
|
85
91
|
nextPageToken?: string;
|
|
86
92
|
resultSizeEstimate?: number;
|
|
87
93
|
};
|
|
94
|
+
export type GmailThreadResource = {
|
|
95
|
+
id: string;
|
|
96
|
+
historyId?: string;
|
|
97
|
+
messages?: Array<GmailMessageResource>;
|
|
98
|
+
};
|
|
88
99
|
export declare function getHeader(headers: Array<{
|
|
89
100
|
name: string;
|
|
90
101
|
value: string;
|
|
@@ -92,6 +103,24 @@ export declare function getHeader(headers: Array<{
|
|
|
92
103
|
export declare function extractEmail(headerValue: string): string;
|
|
93
104
|
export declare function extractEmails(headerValue: string | undefined): Array<string>;
|
|
94
105
|
export declare function mapMessageToEntity(message: GmailMessageResource): EntityRecord;
|
|
106
|
+
export declare function getMessage(config: {
|
|
107
|
+
accessToken: string;
|
|
108
|
+
messageID: string;
|
|
109
|
+
fetch?: typeof globalThis.fetch;
|
|
110
|
+
signal?: AbortSignal;
|
|
111
|
+
}): Promise<GmailMessageResource>;
|
|
112
|
+
export declare function getThread(config: {
|
|
113
|
+
accessToken: string;
|
|
114
|
+
threadID: string;
|
|
115
|
+
fetch?: typeof globalThis.fetch;
|
|
116
|
+
signal?: AbortSignal;
|
|
117
|
+
}): Promise<GmailThreadResource>;
|
|
118
|
+
export declare function getLabel(config: {
|
|
119
|
+
accessToken: string;
|
|
120
|
+
labelID: string;
|
|
121
|
+
fetch?: typeof globalThis.fetch;
|
|
122
|
+
signal?: AbortSignal;
|
|
123
|
+
}): Promise<GmailLabelResource>;
|
|
95
124
|
export declare function importMessageAttachment(config: {
|
|
96
125
|
accessToken: string;
|
|
97
126
|
messageID: string;
|
package/lib/api.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { connectorHTTPError, markWriteSent } from '@kubun/connector';
|
|
1
2
|
import { b64uFromUTF, b64uToUTF, fromB64U } from '@sozai/codec';
|
|
2
3
|
export const MAIL_BASE_URL = 'https://gmail.googleapis.com/gmail/v1';
|
|
3
4
|
export async function parseGoogleError(response) {
|
|
@@ -8,6 +9,15 @@ export async function parseGoogleError(response) {
|
|
|
8
9
|
return `${response.status} ${response.statusText}`;
|
|
9
10
|
}
|
|
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);
|
|
20
|
+
}
|
|
11
21
|
export function decodeBase64Url(data) {
|
|
12
22
|
return b64uToUTF(data);
|
|
13
23
|
}
|
|
@@ -28,6 +38,14 @@ export function buildMessageGetURL(params) {
|
|
|
28
38
|
url.searchParams.set('format', 'full');
|
|
29
39
|
return url.toString();
|
|
30
40
|
}
|
|
41
|
+
export function buildThreadGetURL(params) {
|
|
42
|
+
const url = new URL(`${MAIL_BASE_URL}/users/me/threads/${encodeURIComponent(params.threadID)}`);
|
|
43
|
+
url.searchParams.set('format', 'full');
|
|
44
|
+
return url.toString();
|
|
45
|
+
}
|
|
46
|
+
export function buildLabelGetURL(params) {
|
|
47
|
+
return `${MAIL_BASE_URL}/users/me/labels/${encodeURIComponent(params.labelID)}`;
|
|
48
|
+
}
|
|
31
49
|
export function buildAttachmentGetURL(params) {
|
|
32
50
|
return `${MAIL_BASE_URL}/users/me/messages/${encodeURIComponent(params.messageID)}/attachments/${encodeURIComponent(params.attachmentID)}`;
|
|
33
51
|
}
|
|
@@ -128,6 +146,54 @@ export function mapMessageToEntity(message) {
|
|
|
128
146
|
}
|
|
129
147
|
};
|
|
130
148
|
}
|
|
149
|
+
// Targeted single-message fetch (anti-entropy repair verification / write-back
|
|
150
|
+
// recovery's fetchEntity+reconcile). A 404 here is the deletion signal — see
|
|
151
|
+
// googleMailAPIError's doc.
|
|
152
|
+
export async function getMessage(config) {
|
|
153
|
+
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
154
|
+
const response = await fetchFn(buildMessageGetURL({
|
|
155
|
+
messageID: config.messageID
|
|
156
|
+
}), {
|
|
157
|
+
headers: {
|
|
158
|
+
Authorization: `Bearer ${config.accessToken}`
|
|
159
|
+
},
|
|
160
|
+
signal: config.signal
|
|
161
|
+
});
|
|
162
|
+
if (!response.ok) throw await googleMailAPIError(response);
|
|
163
|
+
return await response.json();
|
|
164
|
+
}
|
|
165
|
+
// Targeted single-thread fetch — a real threads.get(format=full) read, mapped
|
|
166
|
+
// from its own resource (id + constituent messages), never synthesized from a
|
|
167
|
+
// bulk message listing's locally-scanned subject.
|
|
168
|
+
export async function getThread(config) {
|
|
169
|
+
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
170
|
+
const response = await fetchFn(buildThreadGetURL({
|
|
171
|
+
threadID: config.threadID
|
|
172
|
+
}), {
|
|
173
|
+
headers: {
|
|
174
|
+
Authorization: `Bearer ${config.accessToken}`
|
|
175
|
+
},
|
|
176
|
+
signal: config.signal
|
|
177
|
+
});
|
|
178
|
+
if (!response.ok) throw await googleMailAPIError(response);
|
|
179
|
+
return await response.json();
|
|
180
|
+
}
|
|
181
|
+
// Targeted single-label fetch. Unlike labels.list (buildLabelsListURL, used by
|
|
182
|
+
// the bulk labelBatch), a direct labels.get returns the full resource
|
|
183
|
+
// including `color` — the list endpoint omits it.
|
|
184
|
+
export async function getLabel(config) {
|
|
185
|
+
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
186
|
+
const response = await fetchFn(buildLabelGetURL({
|
|
187
|
+
labelID: config.labelID
|
|
188
|
+
}), {
|
|
189
|
+
headers: {
|
|
190
|
+
Authorization: `Bearer ${config.accessToken}`
|
|
191
|
+
},
|
|
192
|
+
signal: config.signal
|
|
193
|
+
});
|
|
194
|
+
if (!response.ok) throw await googleMailAPIError(response);
|
|
195
|
+
return await response.json();
|
|
196
|
+
}
|
|
131
197
|
function streamOfBytes(bytes) {
|
|
132
198
|
return new ReadableStream({
|
|
133
199
|
start (controller) {
|
|
@@ -137,9 +203,12 @@ function streamOfBytes(bytes) {
|
|
|
137
203
|
});
|
|
138
204
|
}
|
|
139
205
|
// Download one attachment's bytes from Gmail, write them to the blob store, and
|
|
140
|
-
// return the message
|
|
141
|
-
// already-imported blobs are preserved from
|
|
142
|
-
//
|
|
206
|
+
// return the message identity with just its `attachments`, that attachment's
|
|
207
|
+
// `blob` set. Other attachments' already-imported blobs are preserved from
|
|
208
|
+
// `existing`, and no other field of `existing` is carried: `attachments` is a
|
|
209
|
+
// local annotation, and the local copy of the provider fields must never be
|
|
210
|
+
// re-issued as if freshly read. Gmail returns the whole attachment as a single
|
|
211
|
+
// base64url `data` field, so the bytes are buffered here.
|
|
143
212
|
export async function importMessageAttachment(config) {
|
|
144
213
|
const attachments = config.existing?.attachments ?? [];
|
|
145
214
|
const target = attachments.find((a)=>a.providerRef === config.providerRef);
|
|
@@ -155,7 +224,7 @@ export async function importMessageAttachment(config) {
|
|
|
155
224
|
Authorization: `Bearer ${config.accessToken}`
|
|
156
225
|
}
|
|
157
226
|
});
|
|
158
|
-
if (!response.ok) throw
|
|
227
|
+
if (!response.ok) throw await googleMailAPIError(response);
|
|
159
228
|
const body = await response.json();
|
|
160
229
|
if (body.data == null) throw new Error('Google Mail: attachment response has no data');
|
|
161
230
|
const bytes = decodeBase64UrlBytes(body.data);
|
|
@@ -167,7 +236,6 @@ export async function importMessageAttachment(config) {
|
|
|
167
236
|
blob: attachmentID
|
|
168
237
|
} : a);
|
|
169
238
|
return {
|
|
170
|
-
...config.existing ?? {},
|
|
171
239
|
sourceService: 'google-mail',
|
|
172
240
|
sourceID: config.messageID,
|
|
173
241
|
attachments: updated
|
|
@@ -228,17 +296,24 @@ function requireString(params, key) {
|
|
|
228
296
|
// Gmail's write endpoints (messages.send, drafts.create, messages.modify) return only
|
|
229
297
|
// { id, threadId, labelIds } -- no payload/headers/body. Re-fetch the full message so the
|
|
230
298
|
// mapped entity carries real sender/body/subject/etc. instead of empty placeholders.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
299
|
+
// Everything here runs after the write went out, so any failure is marked sent: the
|
|
300
|
+
// write may have landed, whatever the read-back did.
|
|
301
|
+
async function readBackWrittenMessage(config) {
|
|
302
|
+
try {
|
|
303
|
+
const messageID = config.messageIDOf(await config.written.json());
|
|
304
|
+
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
305
|
+
const response = await fetchFn(buildMessageGetURL({
|
|
306
|
+
messageID
|
|
307
|
+
}), {
|
|
308
|
+
headers: {
|
|
309
|
+
Authorization: `Bearer ${config.accessToken}`
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
if (!response.ok) throw await googleMailAPIError(response);
|
|
313
|
+
return mapMessageToEntity(await response.json());
|
|
314
|
+
} catch (err) {
|
|
315
|
+
throw markWriteSent(err);
|
|
316
|
+
}
|
|
242
317
|
}
|
|
243
318
|
export async function sendMessage(config) {
|
|
244
319
|
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
@@ -259,11 +334,11 @@ export async function sendMessage(config) {
|
|
|
259
334
|
raw
|
|
260
335
|
})
|
|
261
336
|
});
|
|
262
|
-
if (!response.ok) throw
|
|
263
|
-
|
|
264
|
-
return fetchAndMapMessage({
|
|
337
|
+
if (!response.ok) throw await googleMailAPIError(response);
|
|
338
|
+
return readBackWrittenMessage({
|
|
265
339
|
accessToken: config.accessToken,
|
|
266
|
-
|
|
340
|
+
written: response,
|
|
341
|
+
messageIDOf: (sent)=>sent.id,
|
|
267
342
|
fetch: config.fetch
|
|
268
343
|
});
|
|
269
344
|
}
|
|
@@ -288,11 +363,11 @@ export async function createDraft(config) {
|
|
|
288
363
|
}
|
|
289
364
|
})
|
|
290
365
|
});
|
|
291
|
-
if (!response.ok) throw
|
|
292
|
-
|
|
293
|
-
return fetchAndMapMessage({
|
|
366
|
+
if (!response.ok) throw await googleMailAPIError(response);
|
|
367
|
+
return readBackWrittenMessage({
|
|
294
368
|
accessToken: config.accessToken,
|
|
295
|
-
|
|
369
|
+
written: response,
|
|
370
|
+
messageIDOf: (draft)=>draft.message.id,
|
|
296
371
|
fetch: config.fetch
|
|
297
372
|
});
|
|
298
373
|
}
|
|
@@ -310,11 +385,11 @@ export async function modifyLabels(config) {
|
|
|
310
385
|
removeLabelIds: config.removeLabelIds ?? []
|
|
311
386
|
})
|
|
312
387
|
});
|
|
313
|
-
if (!response.ok) throw
|
|
314
|
-
|
|
315
|
-
return fetchAndMapMessage({
|
|
388
|
+
if (!response.ok) throw await googleMailAPIError(response);
|
|
389
|
+
return readBackWrittenMessage({
|
|
316
390
|
accessToken: config.accessToken,
|
|
317
|
-
|
|
391
|
+
written: response,
|
|
392
|
+
messageIDOf: (modified)=>modified.id,
|
|
318
393
|
fetch: config.fetch
|
|
319
394
|
});
|
|
320
395
|
}
|
|
@@ -350,7 +425,7 @@ export async function createLabel(config) {
|
|
|
350
425
|
},
|
|
351
426
|
body: JSON.stringify(body)
|
|
352
427
|
});
|
|
353
|
-
if (!response.ok) throw
|
|
428
|
+
if (!response.ok) throw await googleMailAPIError(response);
|
|
354
429
|
return mapLabelToEntity(await response.json());
|
|
355
430
|
}
|
|
356
431
|
export async function updateLabel(config) {
|
|
@@ -364,6 +439,6 @@ export async function updateLabel(config) {
|
|
|
364
439
|
},
|
|
365
440
|
body: JSON.stringify(buildLabelBody(config.params))
|
|
366
441
|
});
|
|
367
|
-
if (!response.ok) throw
|
|
442
|
+
if (!response.ok) throw await googleMailAPIError(response);
|
|
368
443
|
return mapLabelToEntity(await response.json());
|
|
369
444
|
}
|
package/lib/connector.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createGoogleMailActionHandler } from './actions.js';
|
|
2
|
+
import { mergeMailLocalAnnotations } from './local-annotations.js';
|
|
2
3
|
import { googleMailClusters } from './model.js';
|
|
3
4
|
import { createGoogleMailProvider, DEFAULT_QUERY } from './provider.js';
|
|
4
5
|
export function createGoogleMailConnector(options) {
|
|
@@ -9,14 +10,19 @@ export function createGoogleMailConnector(options) {
|
|
|
9
10
|
syncStrategies: [
|
|
10
11
|
'on-demand'
|
|
11
12
|
],
|
|
13
|
+
// Two `create`/`update` verbs, each split per entityModel rather than one
|
|
14
|
+
// action spanning multiple models — schema.ts's dynamic mutation generator
|
|
15
|
+
// (connectorCreate<Model>/connectorUpdate<Model>) and the write-back
|
|
16
|
+
// recovery driver (writeback/workflow.ts) both select an action by
|
|
17
|
+
// (name, entityModel), so each model gets its own recoveryClass.
|
|
12
18
|
actions: [
|
|
13
19
|
{
|
|
14
20
|
name: 'create',
|
|
15
|
-
description: '
|
|
21
|
+
description: 'Send a message (or create a draft when draft is true).',
|
|
22
|
+
entityModel: 'GoogleMailMessage',
|
|
16
23
|
input: {
|
|
17
24
|
type: 'object',
|
|
18
25
|
properties: {
|
|
19
|
-
// GoogleMailMessage
|
|
20
26
|
to: {
|
|
21
27
|
type: 'array',
|
|
22
28
|
items: {
|
|
@@ -43,8 +49,23 @@ export function createGoogleMailConnector(options) {
|
|
|
43
49
|
},
|
|
44
50
|
draft: {
|
|
45
51
|
type: 'boolean'
|
|
46
|
-
}
|
|
47
|
-
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
requiresCredential: true,
|
|
56
|
+
// Gmail assigns the message id — there is no caller-supplied
|
|
57
|
+
// deterministic id to key a safe reissue on, and a blind resend after
|
|
58
|
+
// an outcome-unknown failure could duplicate-send to the recipient.
|
|
59
|
+
// Recovery must reconcile rather than reissue.
|
|
60
|
+
recoveryClass: 'reconcile-only'
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'create',
|
|
64
|
+
description: 'Create a label.',
|
|
65
|
+
entityModel: 'GoogleMailLabel',
|
|
66
|
+
input: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: {
|
|
48
69
|
name: {
|
|
49
70
|
type: 'string'
|
|
50
71
|
},
|
|
@@ -62,15 +83,19 @@ export function createGoogleMailConnector(options) {
|
|
|
62
83
|
}
|
|
63
84
|
}
|
|
64
85
|
},
|
|
65
|
-
requiresCredential: true
|
|
86
|
+
requiresCredential: true,
|
|
87
|
+
// Same reasoning as the google-contacts connector's create: Google
|
|
88
|
+
// assigns the label id, so a blind retry could create a duplicate
|
|
89
|
+
// label. Recovery must reconcile rather than reissue.
|
|
90
|
+
recoveryClass: 'reconcile-only'
|
|
66
91
|
},
|
|
67
92
|
{
|
|
68
93
|
name: 'update',
|
|
69
|
-
description: '
|
|
94
|
+
description: 'Add/remove labels on a message, or import an attachment when providerRef is set.',
|
|
95
|
+
entityModel: 'GoogleMailMessage',
|
|
70
96
|
input: {
|
|
71
97
|
type: 'object',
|
|
72
98
|
properties: {
|
|
73
|
-
// GoogleMailMessage
|
|
74
99
|
addLabelIds: {
|
|
75
100
|
type: 'array',
|
|
76
101
|
items: {
|
|
@@ -86,8 +111,23 @@ export function createGoogleMailConnector(options) {
|
|
|
86
111
|
// Import the attachment identified by this Gmail part id into the blob store.
|
|
87
112
|
providerRef: {
|
|
88
113
|
type: 'string'
|
|
89
|
-
}
|
|
90
|
-
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
requiresCredential: true,
|
|
118
|
+
// Both effects are idempotent under exact-input replay: re-applying the
|
|
119
|
+
// same add/remove label set is a no-op on the already-applied labels,
|
|
120
|
+
// and re-downloading + re-writing the same attachment bytes to the
|
|
121
|
+
// content-addressed blob store is a no-op re-write. Safe to reissue.
|
|
122
|
+
recoveryClass: 'reissue-safe'
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'update',
|
|
126
|
+
description: 'Rename or restyle a label.',
|
|
127
|
+
entityModel: 'GoogleMailLabel',
|
|
128
|
+
input: {
|
|
129
|
+
type: 'object',
|
|
130
|
+
properties: {
|
|
91
131
|
name: {
|
|
92
132
|
type: 'string'
|
|
93
133
|
},
|
|
@@ -105,14 +145,27 @@ export function createGoogleMailConnector(options) {
|
|
|
105
145
|
}
|
|
106
146
|
}
|
|
107
147
|
},
|
|
108
|
-
requiresCredential: true
|
|
148
|
+
requiresCredential: true,
|
|
149
|
+
// Same blind-absolute-overwrite reasoning as the google-calendar/
|
|
150
|
+
// google-contacts connectors' update: a reissue of absolute field
|
|
151
|
+
// values could silently clobber an intervening edit, so recovery must
|
|
152
|
+
// reconcile rather than reissue.
|
|
153
|
+
recoveryClass: 'reconcile-only'
|
|
109
154
|
}
|
|
110
155
|
],
|
|
156
|
+
mergeLocalAnnotations: mergeMailLocalAnnotations,
|
|
157
|
+
// A mail write lands in the account's own mailbox: its target is the
|
|
158
|
+
// account (`users/me`), already pinned by the operation's account key.
|
|
159
|
+
resolveActionTarget: async ({ input })=>({
|
|
160
|
+
target: 'users/me',
|
|
161
|
+
input
|
|
162
|
+
}),
|
|
111
163
|
actionHandler: (config)=>{
|
|
112
164
|
return createGoogleMailActionHandler({
|
|
113
165
|
credential: config.credential,
|
|
114
166
|
fetch: options?.fetch,
|
|
115
|
-
writeAttachment: config.writeAttachment
|
|
167
|
+
writeAttachment: config.writeAttachment,
|
|
168
|
+
mintFetchHLC: config.mintFetchHLC
|
|
116
169
|
});
|
|
117
170
|
},
|
|
118
171
|
auth: {
|
|
@@ -131,7 +184,8 @@ export function createGoogleMailConnector(options) {
|
|
|
131
184
|
credential: config.credential,
|
|
132
185
|
boundary: config.boundary,
|
|
133
186
|
fetch: options?.fetch,
|
|
134
|
-
query: options?.query ?? DEFAULT_QUERY
|
|
187
|
+
query: options?.query ?? DEFAULT_QUERY,
|
|
188
|
+
mintFetchHLC: config.mintFetchHLC
|
|
135
189
|
});
|
|
136
190
|
}
|
|
137
191
|
};
|
package/lib/errors.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { ConnectorError, type ConnectorErrorOptions } from '@kubun/connector';
|
|
2
|
+
/** A Google Mail connector failure, classified the way `ConnectorError` is. */
|
|
3
|
+
export declare class GoogleMailError extends ConnectorError {
|
|
4
|
+
constructor(message: string, options?: ConnectorErrorOptions);
|
|
5
|
+
}
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ConnectorError } from '@kubun/connector';
|
|
2
|
+
/** A Google Mail connector failure, classified the way `ConnectorError` is. */ export class GoogleMailError extends ConnectorError {
|
|
3
|
+
constructor(message, options = {}){
|
|
4
|
+
super(message, options);
|
|
5
|
+
this.name = 'GoogleMailError';
|
|
6
|
+
}
|
|
7
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { createGoogleMailActionHandler, type GoogleMailActionHandlerConfig } from './actions.js';
|
|
2
|
-
export { buildHistoryListURL, buildLabelsListURL, buildMessageGetURL, buildMessagesListURL, buildProfileURL, buildRawMessage, createDraft, createLabel, decodeBase64Url, encodeBase64Url, extractEmail, extractEmails, type GmailHistoryListResponse, type GmailHistoryRecord, type GmailLabelResource, type GmailLabelsListResponse, type GmailMessagePart, type GmailMessageResource, type GmailMessagesListResponse, getHeader, mapLabelToEntity, mapMessageToEntity, mapThreadToEntity, modifyLabels, sendMessage, updateLabel, } from './api.js';
|
|
2
|
+
export { buildHistoryListURL, buildLabelGetURL, buildLabelsListURL, buildMessageGetURL, buildMessagesListURL, buildProfileURL, buildRawMessage, buildThreadGetURL, createDraft, createLabel, decodeBase64Url, encodeBase64Url, extractEmail, extractEmails, type GmailHistoryListResponse, type GmailHistoryRecord, type GmailLabelResource, type GmailLabelsListResponse, type GmailMessagePart, type GmailMessageResource, type GmailMessagesListResponse, type GmailThreadResource, getHeader, getLabel, getMessage, getThread, mapLabelToEntity, mapMessageToEntity, mapThreadToEntity, modifyLabels, sendMessage, updateLabel, } from './api.js';
|
|
3
3
|
export { type CreateGoogleMailConnectorOptions, createGoogleMailConnector } from './connector.js';
|
|
4
|
+
export { GoogleMailError } from './errors.js';
|
|
5
|
+
export { mergeMailLocalAnnotations } from './local-annotations.js';
|
|
4
6
|
export { googleMailClusters, googleMailLabelDefinition, googleMailMessageDefinition, googleMailThreadDefinition, } from './model.js';
|
|
5
7
|
export { createGoogleMailProvider, DEFAULT_QUERY, type GoogleMailProviderConfig, type MailCheckpoint, } from './provider.js';
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { createGoogleMailActionHandler } from './actions.js';
|
|
2
|
-
export { buildHistoryListURL, buildLabelsListURL, buildMessageGetURL, buildMessagesListURL, buildProfileURL, buildRawMessage, createDraft, createLabel, decodeBase64Url, encodeBase64Url, extractEmail, extractEmails, getHeader, mapLabelToEntity, mapMessageToEntity, mapThreadToEntity, modifyLabels, sendMessage, updateLabel } from './api.js';
|
|
2
|
+
export { buildHistoryListURL, buildLabelGetURL, buildLabelsListURL, buildMessageGetURL, buildMessagesListURL, buildProfileURL, buildRawMessage, buildThreadGetURL, createDraft, createLabel, decodeBase64Url, encodeBase64Url, extractEmail, extractEmails, getHeader, getLabel, getMessage, getThread, mapLabelToEntity, mapMessageToEntity, mapThreadToEntity, modifyLabels, sendMessage, updateLabel } from './api.js';
|
|
3
3
|
export { createGoogleMailConnector } from './connector.js';
|
|
4
|
+
export { GoogleMailError } from './errors.js';
|
|
5
|
+
export { mergeMailLocalAnnotations } from './local-annotations.js';
|
|
4
6
|
export { googleMailClusters, googleMailLabelDefinition, googleMailMessageDefinition, googleMailThreadDefinition } from './model.js';
|
|
5
7
|
export { createGoogleMailProvider, DEFAULT_QUERY } from './provider.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { LocalAnnotationMerger } from '@kubun/connector';
|
|
2
|
+
/**
|
|
3
|
+
* The Gmail local-annotation merger (see `LocalAnnotationMerger`). Gmail
|
|
4
|
+
* authors a message's attachment metadata but never the `blob` an import
|
|
5
|
+
* adds locally: each incoming attachment without a blob keeps the one its
|
|
6
|
+
* existing counterpart holds. Nothing else is carried over.
|
|
7
|
+
*/
|
|
8
|
+
export declare const mergeMailLocalAnnotations: LocalAnnotationMerger;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { googleMailMessageDefinition } from './model.js';
|
|
2
|
+
function sameContent(a, b) {
|
|
3
|
+
return a.filename === b.filename && a.mimeType === b.mimeType && a.size === b.size;
|
|
4
|
+
}
|
|
5
|
+
// The existing attachment an incoming one is: the one with its attachment id,
|
|
6
|
+
// or — Gmail may re-issue attachment ids — the only one of the message with
|
|
7
|
+
// its filename, type and size when that is unambiguous on both sides. A
|
|
8
|
+
// message's attachments never change, so that pairing is the same content.
|
|
9
|
+
function counterpart(incoming, incomingAll, existingAll) {
|
|
10
|
+
const byRef = existingAll.find((a)=>a.providerRef === incoming.providerRef);
|
|
11
|
+
if (byRef != null) return byRef;
|
|
12
|
+
const matches = existingAll.filter((a)=>sameContent(a, incoming));
|
|
13
|
+
const twins = incomingAll.filter((a)=>sameContent(a, incoming));
|
|
14
|
+
return matches.length === 1 && twins.length === 1 ? matches[0] : undefined;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The Gmail local-annotation merger (see `LocalAnnotationMerger`). Gmail
|
|
18
|
+
* authors a message's attachment metadata but never the `blob` an import
|
|
19
|
+
* adds locally: each incoming attachment without a blob keeps the one its
|
|
20
|
+
* existing counterpart holds. Nothing else is carried over.
|
|
21
|
+
*/ export const mergeMailLocalAnnotations = ({ model, incoming, existing })=>{
|
|
22
|
+
if (model !== googleMailMessageDefinition.name) return incoming;
|
|
23
|
+
if (!Array.isArray(incoming.attachments) || !Array.isArray(existing.attachments)) return incoming;
|
|
24
|
+
const incomingAll = incoming.attachments;
|
|
25
|
+
const existingAll = existing.attachments;
|
|
26
|
+
if (!existingAll.some((a)=>typeof a.blob === 'string')) return incoming;
|
|
27
|
+
const attachments = incomingAll.map((attachment)=>{
|
|
28
|
+
if (attachment.blob != null) return attachment;
|
|
29
|
+
const blob = counterpart(attachment, incomingAll, existingAll)?.blob;
|
|
30
|
+
return typeof blob === 'string' ? {
|
|
31
|
+
...attachment,
|
|
32
|
+
blob
|
|
33
|
+
} : attachment;
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
...incoming,
|
|
37
|
+
attachments
|
|
38
|
+
};
|
|
39
|
+
};
|
package/lib/model.d.ts
CHANGED
|
@@ -46,6 +46,9 @@ export declare const googleMailThreadDefinition: {
|
|
|
46
46
|
};
|
|
47
47
|
};
|
|
48
48
|
readonly fieldsMeta: {};
|
|
49
|
+
readonly providerMergePolicy: {
|
|
50
|
+
readonly group: ["title", "sourceService", "sourceID", "sourceURL"];
|
|
51
|
+
};
|
|
49
52
|
};
|
|
50
53
|
export declare const googleMailMessageDefinition: {
|
|
51
54
|
readonly name: "GoogleMailMessage";
|
|
@@ -179,6 +182,9 @@ export declare const googleMailMessageDefinition: {
|
|
|
179
182
|
readonly model: "#0";
|
|
180
183
|
};
|
|
181
184
|
};
|
|
185
|
+
readonly providerMergePolicy: {
|
|
186
|
+
readonly group: ["body", "sender", "subject", "snippet", "to", "cc", "labels", "sentAt", "unread", "threadID", "thread", "sourceService", "sourceID", "sourceURL"];
|
|
187
|
+
};
|
|
182
188
|
};
|
|
183
189
|
export declare const googleMailLabelDefinition: {
|
|
184
190
|
readonly name: "GoogleMailLabel";
|
|
@@ -249,5 +255,8 @@ export declare const googleMailLabelDefinition: {
|
|
|
249
255
|
};
|
|
250
256
|
};
|
|
251
257
|
readonly fieldsMeta: {};
|
|
258
|
+
readonly providerMergePolicy: {
|
|
259
|
+
readonly group: ["name", "type", "messageListVisibility", "labelListVisibility", "textColor", "backgroundColor", "sourceService", "sourceID", "sourceURL"];
|
|
260
|
+
};
|
|
252
261
|
};
|
|
253
262
|
export declare const googleMailClusters: import("@kubun/protocol").ClustersRecord;
|
package/lib/model.js
CHANGED
|
@@ -55,7 +55,17 @@ export const googleMailThreadDefinition = {
|
|
|
55
55
|
...commonDefinitions
|
|
56
56
|
}
|
|
57
57
|
},
|
|
58
|
-
fieldsMeta: {}
|
|
58
|
+
fieldsMeta: {},
|
|
59
|
+
// lastSyncedAt is a device-local sync-bookkeeping annotation, not fetched
|
|
60
|
+
// provider content, so it stays outside the group.
|
|
61
|
+
providerMergePolicy: {
|
|
62
|
+
group: [
|
|
63
|
+
'title',
|
|
64
|
+
'sourceService',
|
|
65
|
+
'sourceID',
|
|
66
|
+
'sourceURL'
|
|
67
|
+
]
|
|
68
|
+
}
|
|
59
69
|
};
|
|
60
70
|
export const googleMailMessageDefinition = {
|
|
61
71
|
name: 'GoogleMailMessage',
|
|
@@ -185,6 +195,28 @@ export const googleMailMessageDefinition = {
|
|
|
185
195
|
type: 'document',
|
|
186
196
|
model: '#0'
|
|
187
197
|
}
|
|
198
|
+
},
|
|
199
|
+
// lastSyncedAt is device-local sync bookkeeping; attachments carries the
|
|
200
|
+
// locally-materialized `blob` annotation (absent until an explicit import
|
|
201
|
+
// fills it), so the whole field stays outside the group rather than
|
|
202
|
+
// splitting a single top-level field between provider and device content.
|
|
203
|
+
providerMergePolicy: {
|
|
204
|
+
group: [
|
|
205
|
+
'body',
|
|
206
|
+
'sender',
|
|
207
|
+
'subject',
|
|
208
|
+
'snippet',
|
|
209
|
+
'to',
|
|
210
|
+
'cc',
|
|
211
|
+
'labels',
|
|
212
|
+
'sentAt',
|
|
213
|
+
'unread',
|
|
214
|
+
'threadID',
|
|
215
|
+
'thread',
|
|
216
|
+
'sourceService',
|
|
217
|
+
'sourceID',
|
|
218
|
+
'sourceURL'
|
|
219
|
+
]
|
|
188
220
|
}
|
|
189
221
|
};
|
|
190
222
|
export const googleMailLabelDefinition = {
|
|
@@ -245,7 +277,22 @@ export const googleMailLabelDefinition = {
|
|
|
245
277
|
...commonDefinitions
|
|
246
278
|
}
|
|
247
279
|
},
|
|
248
|
-
fieldsMeta: {}
|
|
280
|
+
fieldsMeta: {},
|
|
281
|
+
// lastSyncedAt is a device-local sync-bookkeeping annotation, not fetched
|
|
282
|
+
// provider content, so it stays outside the group.
|
|
283
|
+
providerMergePolicy: {
|
|
284
|
+
group: [
|
|
285
|
+
'name',
|
|
286
|
+
'type',
|
|
287
|
+
'messageListVisibility',
|
|
288
|
+
'labelListVisibility',
|
|
289
|
+
'textColor',
|
|
290
|
+
'backgroundColor',
|
|
291
|
+
'sourceService',
|
|
292
|
+
'sourceID',
|
|
293
|
+
'sourceURL'
|
|
294
|
+
]
|
|
295
|
+
}
|
|
249
296
|
};
|
|
250
297
|
const builder = new GraphBuilder({
|
|
251
298
|
externalClusters: {
|
package/lib/provider.d.ts
CHANGED
|
@@ -3,11 +3,13 @@ import type { Credential } from '@kubun/credential-types';
|
|
|
3
3
|
export declare const DEFAULT_QUERY = "in:inbox";
|
|
4
4
|
export type MailCheckpoint = {
|
|
5
5
|
historyId: string;
|
|
6
|
+
windowStart?: number;
|
|
6
7
|
};
|
|
7
8
|
export type GoogleMailProviderConfig = {
|
|
8
9
|
credential: Credential;
|
|
9
10
|
boundary?: SyncBoundary;
|
|
10
11
|
fetch?: typeof globalThis.fetch;
|
|
11
12
|
query?: string;
|
|
13
|
+
mintFetchHLC: () => string;
|
|
12
14
|
};
|
|
13
15
|
export declare function createGoogleMailProvider(config: GoogleMailProviderConfig): ServerDataProvider;
|
package/lib/provider.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveModelID } from '@kubun/connector';
|
|
2
|
-
import { buildHistoryListURL, buildLabelsListURL,
|
|
2
|
+
import { buildHistoryListURL, buildLabelsListURL, buildMessagesListURL, buildProfileURL, getHeader, getLabel, getMessage, getThread, mapLabelToEntity, mapMessageToEntity, mapThreadToEntity, parseGoogleError } from './api.js';
|
|
3
3
|
import { googleMailClusters } from './model.js';
|
|
4
4
|
export const DEFAULT_QUERY = 'in:inbox';
|
|
5
5
|
const THREAD_MODEL_ID = resolveModelID(googleMailClusters, 'GoogleMailThread');
|
|
@@ -17,12 +17,22 @@ async function fetchJSON(url, credential, fetchFn, signal) {
|
|
|
17
17
|
}
|
|
18
18
|
return await response.json();
|
|
19
19
|
}
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* The absolute lower bound (epoch seconds) for a full message listing: a window
|
|
22
|
+
* already frozen in the checkpoint, else `now − maxAge` computed once. Null when
|
|
23
|
+
* the boundary carries no `maxAge` (no floor).
|
|
24
|
+
*/ function windowStartSeconds(boundary, frozen) {
|
|
25
|
+
if (frozen != null) return frozen;
|
|
26
|
+
if (boundary?.maxAge == null) return null;
|
|
22
27
|
const match = /^P(\d+)D$/i.exec(boundary.maxAge);
|
|
23
|
-
if (match == null) return
|
|
28
|
+
if (match?.[1] == null) return null;
|
|
29
|
+
const days = Number.parseInt(match[1], 10);
|
|
30
|
+
return Math.floor((Date.now() - days * 86_400_000) / 1000);
|
|
31
|
+
}
|
|
32
|
+
/** Appends an absolute `after:<epoch seconds>` floor to a Gmail search query. */ function applyAfter(query, windowStart) {
|
|
33
|
+
if (windowStart == null) return query;
|
|
24
34
|
if (/\b(newer_than:|after:)/i.test(query)) return query;
|
|
25
|
-
return `${query}
|
|
35
|
+
return `${query} after:${windowStart}`;
|
|
26
36
|
}
|
|
27
37
|
function maxHistoryId(messages) {
|
|
28
38
|
let max = -1;
|
|
@@ -34,6 +44,13 @@ function maxHistoryId(messages) {
|
|
|
34
44
|
}
|
|
35
45
|
export function createGoogleMailProvider(config) {
|
|
36
46
|
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
47
|
+
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
|
+
}
|
|
37
54
|
async function listMessageIDs(query, signal) {
|
|
38
55
|
const ids = [];
|
|
39
56
|
let pageToken;
|
|
@@ -47,10 +64,16 @@ export function createGoogleMailProvider(config) {
|
|
|
47
64
|
}while (pageToken != null)
|
|
48
65
|
return ids;
|
|
49
66
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
67
|
+
// Bulk-listing message fetch (fetchAll/fetchChanges) — delegates to the
|
|
68
|
+
// shared api.ts getMessage so both the bulk and targeted (fetchEntity)
|
|
69
|
+
// paths share one 404-tagging choke point.
|
|
70
|
+
async function getMessageResource(id, signal) {
|
|
71
|
+
return getMessage({
|
|
72
|
+
accessToken: config.credential.accessToken,
|
|
73
|
+
messageID: id,
|
|
74
|
+
fetch: fetchFn,
|
|
75
|
+
signal
|
|
76
|
+
});
|
|
54
77
|
}
|
|
55
78
|
async function profileHistoryId(signal) {
|
|
56
79
|
const profile = await fetchJSON(buildProfileURL(), config.credential, fetchFn, signal);
|
|
@@ -61,19 +84,28 @@ export function createGoogleMailProvider(config) {
|
|
|
61
84
|
// create/edit) but carries id, name, type, and the visibility fields.
|
|
62
85
|
async function labelBatch(signal) {
|
|
63
86
|
const data = await fetchJSON(buildLabelsListURL(), config.credential, fetchFn, signal);
|
|
87
|
+
const sourceFetchHLC = mintFetchHLC();
|
|
64
88
|
return {
|
|
65
89
|
modelID: LABEL_MODEL_ID,
|
|
66
|
-
entities: (data.labels ?? []).map(
|
|
90
|
+
entities: (data.labels ?? []).map((label)=>({
|
|
91
|
+
present: true,
|
|
92
|
+
record: mapLabelToEntity(label),
|
|
93
|
+
sourceFetchHLC
|
|
94
|
+
})),
|
|
67
95
|
hasMore: true
|
|
68
96
|
};
|
|
69
97
|
}
|
|
70
98
|
async function* fetchAllEntities(params) {
|
|
71
99
|
yield await labelBatch(params.signal);
|
|
72
|
-
|
|
100
|
+
// Reuse a window frozen by a prior sync (carried in a checkpoint on the 404
|
|
101
|
+
// resync path, or a re-driven initial); only a genuine first sync computes it.
|
|
102
|
+
const prior = params.checkpoint;
|
|
103
|
+
const windowStart = windowStartSeconds(params.boundary, prior?.windowStart);
|
|
104
|
+
const query = applyAfter(config.query ?? DEFAULT_QUERY, windowStart);
|
|
73
105
|
const ids = await listMessageIDs(query, params.signal);
|
|
74
106
|
const messages = [];
|
|
75
107
|
for (const { id } of ids){
|
|
76
|
-
messages.push(await
|
|
108
|
+
messages.push(await getMessageResource(id, params.signal));
|
|
77
109
|
}
|
|
78
110
|
// Thread batch: one thread per distinct threadId, titled by its first message's subject.
|
|
79
111
|
const threadSubjects = new Map();
|
|
@@ -82,19 +114,32 @@ export function createGoogleMailProvider(config) {
|
|
|
82
114
|
threadSubjects.set(m.threadId, getHeader(m.payload?.headers, 'Subject'));
|
|
83
115
|
}
|
|
84
116
|
}
|
|
117
|
+
const threadToken = mintFetchHLC();
|
|
85
118
|
yield {
|
|
86
119
|
modelID: THREAD_MODEL_ID,
|
|
87
120
|
entities: [
|
|
88
121
|
...threadSubjects
|
|
89
|
-
].map(([threadId, subject])=>
|
|
122
|
+
].map(([threadId, subject])=>({
|
|
123
|
+
present: true,
|
|
124
|
+
record: mapThreadToEntity(threadId, subject),
|
|
125
|
+
sourceFetchHLC: threadToken
|
|
126
|
+
})),
|
|
90
127
|
hasMore: true
|
|
91
128
|
};
|
|
92
129
|
const checkpoint = {
|
|
93
|
-
historyId: maxHistoryId(messages) ?? await profileHistoryId(params.signal)
|
|
130
|
+
historyId: maxHistoryId(messages) ?? await profileHistoryId(params.signal),
|
|
131
|
+
...windowStart != null ? {
|
|
132
|
+
windowStart
|
|
133
|
+
} : {}
|
|
94
134
|
};
|
|
135
|
+
const messageToken = mintFetchHLC();
|
|
95
136
|
yield {
|
|
96
137
|
modelID: MESSAGE_MODEL_ID,
|
|
97
|
-
entities: messages.map(
|
|
138
|
+
entities: messages.map((message)=>({
|
|
139
|
+
present: true,
|
|
140
|
+
record: mapMessageToEntity(message),
|
|
141
|
+
sourceFetchHLC: messageToken
|
|
142
|
+
})),
|
|
98
143
|
hasMore: false,
|
|
99
144
|
checkpoint
|
|
100
145
|
};
|
|
@@ -150,7 +195,7 @@ export function createGoogleMailProvider(config) {
|
|
|
150
195
|
].filter((id)=>!deletedIDs.has(id));
|
|
151
196
|
const messages = [];
|
|
152
197
|
for (const id of survivingIDs){
|
|
153
|
-
messages.push(await
|
|
198
|
+
messages.push(await getMessageResource(id, params.signal));
|
|
154
199
|
}
|
|
155
200
|
const threadSubjects = new Map();
|
|
156
201
|
for (const m of messages){
|
|
@@ -158,33 +203,112 @@ export function createGoogleMailProvider(config) {
|
|
|
158
203
|
threadSubjects.set(m.threadId, getHeader(m.payload?.headers, 'Subject'));
|
|
159
204
|
}
|
|
160
205
|
}
|
|
206
|
+
const threadToken = mintFetchHLC();
|
|
161
207
|
yield {
|
|
162
208
|
modelID: THREAD_MODEL_ID,
|
|
163
209
|
entities: [
|
|
164
210
|
...threadSubjects
|
|
165
|
-
].map(([threadId, subject])=>
|
|
211
|
+
].map(([threadId, subject])=>({
|
|
212
|
+
present: true,
|
|
213
|
+
record: mapThreadToEntity(threadId, subject),
|
|
214
|
+
sourceFetchHLC: threadToken
|
|
215
|
+
})),
|
|
166
216
|
hasMore: true
|
|
167
217
|
};
|
|
218
|
+
const messageToken = mintFetchHLC();
|
|
168
219
|
yield {
|
|
169
220
|
modelID: MESSAGE_MODEL_ID,
|
|
170
|
-
entities: messages.map(
|
|
221
|
+
entities: messages.map((message)=>({
|
|
222
|
+
present: true,
|
|
223
|
+
record: mapMessageToEntity(message),
|
|
224
|
+
sourceFetchHLC: messageToken
|
|
225
|
+
})),
|
|
171
226
|
deleted: [
|
|
172
227
|
...deletedIDs
|
|
173
228
|
].map((id)=>({
|
|
174
|
-
|
|
175
|
-
sourceID: id
|
|
229
|
+
present: false,
|
|
230
|
+
sourceID: id,
|
|
231
|
+
sourceFetchHLC: messageToken
|
|
176
232
|
})),
|
|
177
233
|
hasMore: false,
|
|
178
234
|
checkpoint: {
|
|
179
|
-
historyId: latestHistoryId
|
|
235
|
+
historyId: latestHistoryId,
|
|
236
|
+
...checkpoint.windowStart != null ? {
|
|
237
|
+
windowStart: checkpoint.windowStart
|
|
238
|
+
} : {}
|
|
180
239
|
}
|
|
181
240
|
};
|
|
182
241
|
}
|
|
242
|
+
// Targeted single-entity fetch (anti-entropy repair verification / write-back
|
|
243
|
+
// recovery). Mints its own fetch-order token at request start, independent of
|
|
244
|
+
// any batch token. `model` distinguishes the three GoogleMail* resources —
|
|
245
|
+
// Thread uses a real threads.get (not the bulk-listing subject synthesis
|
|
246
|
+
// fetchAll/fetchChanges use); Label uses labels.get (carries color, unlike
|
|
247
|
+
// the list endpoint labelBatch uses).
|
|
248
|
+
async function fetchEntity(model, sourceID) {
|
|
249
|
+
const sourceFetchHLC = mintFetchHLC();
|
|
250
|
+
try {
|
|
251
|
+
if (model === THREAD_MODEL_ID) {
|
|
252
|
+
const thread = await getThread({
|
|
253
|
+
accessToken: config.credential.accessToken,
|
|
254
|
+
threadID: sourceID,
|
|
255
|
+
fetch: fetchFn
|
|
256
|
+
});
|
|
257
|
+
const first = thread.messages?.[0];
|
|
258
|
+
const subject = first != null ? getHeader(first.payload?.headers, 'Subject') : undefined;
|
|
259
|
+
return {
|
|
260
|
+
present: true,
|
|
261
|
+
record: mapThreadToEntity(thread.id, subject),
|
|
262
|
+
sourceFetchHLC
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
if (model === LABEL_MODEL_ID) {
|
|
266
|
+
const label = await getLabel({
|
|
267
|
+
accessToken: config.credential.accessToken,
|
|
268
|
+
labelID: sourceID,
|
|
269
|
+
fetch: fetchFn
|
|
270
|
+
});
|
|
271
|
+
return {
|
|
272
|
+
present: true,
|
|
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
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
throw err;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Provider-authoritative re-read for write-back recovery. Gmail's
|
|
299
|
+
// resources carry no dependable ETag on a direct get, so this mirrors
|
|
300
|
+
// fetchEntity without a providerETag — present for contract parity with the
|
|
301
|
+
// other connectors' reconcile.
|
|
302
|
+
async function reconcile(model, sourceID) {
|
|
303
|
+
return fetchEntity(model, sourceID);
|
|
304
|
+
}
|
|
183
305
|
return {
|
|
184
306
|
supportedStrategies: [
|
|
185
307
|
'on-demand'
|
|
186
308
|
],
|
|
187
309
|
fetchAll: fetchAllEntities,
|
|
188
|
-
fetchChanges: fetchChangesEntities
|
|
310
|
+
fetchChanges: fetchChangesEntities,
|
|
311
|
+
fetchEntity,
|
|
312
|
+
reconcile
|
|
189
313
|
};
|
|
190
314
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/connector-google-mail",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.2",
|
|
4
4
|
"keywords": [],
|
|
5
5
|
"license": "see LICENSE.md",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
"LICENSE.md"
|
|
16
16
|
],
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@kubun/connector": "^0.17.
|
|
19
|
-
"@kubun/credential-types": "^0.17.
|
|
18
|
+
"@kubun/connector": "^0.17.2",
|
|
19
|
+
"@kubun/credential-types": "^0.17.2",
|
|
20
|
+
"@kubun/hlc": "^0.17.0",
|
|
20
21
|
"@kubun/models": "^0.17.0",
|
|
21
|
-
"@kubun/protocol": "^0.17.
|
|
22
|
+
"@kubun/protocol": "^0.17.1",
|
|
22
23
|
"@kubun/provider-google": "^0.17.0",
|
|
23
24
|
"@kubun/scalars": "^0.17.0",
|
|
24
25
|
"@sozai/codec": "^0.4.0"
|