@kubun/connector-google-mail 0.17.1 → 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 +1 -0
- package/lib/provider.js +119 -15
- 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
|
@@ -10,5 +10,6 @@ export type GoogleMailProviderConfig = {
|
|
|
10
10
|
boundary?: SyncBoundary;
|
|
11
11
|
fetch?: typeof globalThis.fetch;
|
|
12
12
|
query?: string;
|
|
13
|
+
mintFetchHLC: () => string;
|
|
13
14
|
};
|
|
14
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');
|
|
@@ -44,6 +44,13 @@ function maxHistoryId(messages) {
|
|
|
44
44
|
}
|
|
45
45
|
export function createGoogleMailProvider(config) {
|
|
46
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
|
+
}
|
|
47
54
|
async function listMessageIDs(query, signal) {
|
|
48
55
|
const ids = [];
|
|
49
56
|
let pageToken;
|
|
@@ -57,10 +64,16 @@ export function createGoogleMailProvider(config) {
|
|
|
57
64
|
}while (pageToken != null)
|
|
58
65
|
return ids;
|
|
59
66
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
});
|
|
64
77
|
}
|
|
65
78
|
async function profileHistoryId(signal) {
|
|
66
79
|
const profile = await fetchJSON(buildProfileURL(), config.credential, fetchFn, signal);
|
|
@@ -71,9 +84,14 @@ export function createGoogleMailProvider(config) {
|
|
|
71
84
|
// create/edit) but carries id, name, type, and the visibility fields.
|
|
72
85
|
async function labelBatch(signal) {
|
|
73
86
|
const data = await fetchJSON(buildLabelsListURL(), config.credential, fetchFn, signal);
|
|
87
|
+
const sourceFetchHLC = mintFetchHLC();
|
|
74
88
|
return {
|
|
75
89
|
modelID: LABEL_MODEL_ID,
|
|
76
|
-
entities: (data.labels ?? []).map(
|
|
90
|
+
entities: (data.labels ?? []).map((label)=>({
|
|
91
|
+
present: true,
|
|
92
|
+
record: mapLabelToEntity(label),
|
|
93
|
+
sourceFetchHLC
|
|
94
|
+
})),
|
|
77
95
|
hasMore: true
|
|
78
96
|
};
|
|
79
97
|
}
|
|
@@ -87,7 +105,7 @@ export function createGoogleMailProvider(config) {
|
|
|
87
105
|
const ids = await listMessageIDs(query, params.signal);
|
|
88
106
|
const messages = [];
|
|
89
107
|
for (const { id } of ids){
|
|
90
|
-
messages.push(await
|
|
108
|
+
messages.push(await getMessageResource(id, params.signal));
|
|
91
109
|
}
|
|
92
110
|
// Thread batch: one thread per distinct threadId, titled by its first message's subject.
|
|
93
111
|
const threadSubjects = new Map();
|
|
@@ -96,11 +114,16 @@ export function createGoogleMailProvider(config) {
|
|
|
96
114
|
threadSubjects.set(m.threadId, getHeader(m.payload?.headers, 'Subject'));
|
|
97
115
|
}
|
|
98
116
|
}
|
|
117
|
+
const threadToken = mintFetchHLC();
|
|
99
118
|
yield {
|
|
100
119
|
modelID: THREAD_MODEL_ID,
|
|
101
120
|
entities: [
|
|
102
121
|
...threadSubjects
|
|
103
|
-
].map(([threadId, subject])=>
|
|
122
|
+
].map(([threadId, subject])=>({
|
|
123
|
+
present: true,
|
|
124
|
+
record: mapThreadToEntity(threadId, subject),
|
|
125
|
+
sourceFetchHLC: threadToken
|
|
126
|
+
})),
|
|
104
127
|
hasMore: true
|
|
105
128
|
};
|
|
106
129
|
const checkpoint = {
|
|
@@ -109,9 +132,14 @@ export function createGoogleMailProvider(config) {
|
|
|
109
132
|
windowStart
|
|
110
133
|
} : {}
|
|
111
134
|
};
|
|
135
|
+
const messageToken = mintFetchHLC();
|
|
112
136
|
yield {
|
|
113
137
|
modelID: MESSAGE_MODEL_ID,
|
|
114
|
-
entities: messages.map(
|
|
138
|
+
entities: messages.map((message)=>({
|
|
139
|
+
present: true,
|
|
140
|
+
record: mapMessageToEntity(message),
|
|
141
|
+
sourceFetchHLC: messageToken
|
|
142
|
+
})),
|
|
115
143
|
hasMore: false,
|
|
116
144
|
checkpoint
|
|
117
145
|
};
|
|
@@ -167,7 +195,7 @@ export function createGoogleMailProvider(config) {
|
|
|
167
195
|
].filter((id)=>!deletedIDs.has(id));
|
|
168
196
|
const messages = [];
|
|
169
197
|
for (const id of survivingIDs){
|
|
170
|
-
messages.push(await
|
|
198
|
+
messages.push(await getMessageResource(id, params.signal));
|
|
171
199
|
}
|
|
172
200
|
const threadSubjects = new Map();
|
|
173
201
|
for (const m of messages){
|
|
@@ -175,21 +203,32 @@ export function createGoogleMailProvider(config) {
|
|
|
175
203
|
threadSubjects.set(m.threadId, getHeader(m.payload?.headers, 'Subject'));
|
|
176
204
|
}
|
|
177
205
|
}
|
|
206
|
+
const threadToken = mintFetchHLC();
|
|
178
207
|
yield {
|
|
179
208
|
modelID: THREAD_MODEL_ID,
|
|
180
209
|
entities: [
|
|
181
210
|
...threadSubjects
|
|
182
|
-
].map(([threadId, subject])=>
|
|
211
|
+
].map(([threadId, subject])=>({
|
|
212
|
+
present: true,
|
|
213
|
+
record: mapThreadToEntity(threadId, subject),
|
|
214
|
+
sourceFetchHLC: threadToken
|
|
215
|
+
})),
|
|
183
216
|
hasMore: true
|
|
184
217
|
};
|
|
218
|
+
const messageToken = mintFetchHLC();
|
|
185
219
|
yield {
|
|
186
220
|
modelID: MESSAGE_MODEL_ID,
|
|
187
|
-
entities: messages.map(
|
|
221
|
+
entities: messages.map((message)=>({
|
|
222
|
+
present: true,
|
|
223
|
+
record: mapMessageToEntity(message),
|
|
224
|
+
sourceFetchHLC: messageToken
|
|
225
|
+
})),
|
|
188
226
|
deleted: [
|
|
189
227
|
...deletedIDs
|
|
190
228
|
].map((id)=>({
|
|
191
|
-
|
|
192
|
-
sourceID: id
|
|
229
|
+
present: false,
|
|
230
|
+
sourceID: id,
|
|
231
|
+
sourceFetchHLC: messageToken
|
|
193
232
|
})),
|
|
194
233
|
hasMore: false,
|
|
195
234
|
checkpoint: {
|
|
@@ -200,11 +239,76 @@ export function createGoogleMailProvider(config) {
|
|
|
200
239
|
}
|
|
201
240
|
};
|
|
202
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
|
+
}
|
|
203
305
|
return {
|
|
204
306
|
supportedStrategies: [
|
|
205
307
|
'on-demand'
|
|
206
308
|
],
|
|
207
309
|
fetchAll: fetchAllEntities,
|
|
208
|
-
fetchChanges: fetchChangesEntities
|
|
310
|
+
fetchChanges: fetchChangesEntities,
|
|
311
|
+
fetchEntity,
|
|
312
|
+
reconcile
|
|
209
313
|
};
|
|
210
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"
|