@kubun/connector-google-mail 0.17.1 → 0.17.3
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 +32 -2
- package/lib/api.js +97 -85
- 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 +54 -23
- package/lib/provider.d.ts +1 -0
- package/lib/provider.js +119 -43
- package/package.json +6 -5
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,6 +1,7 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EntityRecord, type WriteAttachment } from '@kubun/connector';
|
|
2
|
+
import { type GoogleAPIClient } from '@kubun/provider-google';
|
|
2
3
|
export declare const MAIL_BASE_URL = "https://gmail.googleapis.com/gmail/v1";
|
|
3
|
-
export declare function
|
|
4
|
+
export declare function mailAPIClient(accessToken: string, fetch?: typeof globalThis.fetch): GoogleAPIClient;
|
|
4
5
|
export declare function decodeBase64Url(data: string): string;
|
|
5
6
|
export declare function decodeBase64UrlBytes(data: string): Uint8Array;
|
|
6
7
|
export declare function encodeBase64Url(text: string): string;
|
|
@@ -11,6 +12,12 @@ export declare function buildMessagesListURL(params: {
|
|
|
11
12
|
export declare function buildMessageGetURL(params: {
|
|
12
13
|
messageID: string;
|
|
13
14
|
}): string;
|
|
15
|
+
export declare function buildThreadGetURL(params: {
|
|
16
|
+
threadID: string;
|
|
17
|
+
}): string;
|
|
18
|
+
export declare function buildLabelGetURL(params: {
|
|
19
|
+
labelID: string;
|
|
20
|
+
}): string;
|
|
14
21
|
export declare function buildAttachmentGetURL(params: {
|
|
15
22
|
messageID: string;
|
|
16
23
|
attachmentID: string;
|
|
@@ -85,6 +92,11 @@ export type GmailMessagesListResponse = {
|
|
|
85
92
|
nextPageToken?: string;
|
|
86
93
|
resultSizeEstimate?: number;
|
|
87
94
|
};
|
|
95
|
+
export type GmailThreadResource = {
|
|
96
|
+
id: string;
|
|
97
|
+
historyId?: string;
|
|
98
|
+
messages?: Array<GmailMessageResource>;
|
|
99
|
+
};
|
|
88
100
|
export declare function getHeader(headers: Array<{
|
|
89
101
|
name: string;
|
|
90
102
|
value: string;
|
|
@@ -92,6 +104,24 @@ export declare function getHeader(headers: Array<{
|
|
|
92
104
|
export declare function extractEmail(headerValue: string): string;
|
|
93
105
|
export declare function extractEmails(headerValue: string | undefined): Array<string>;
|
|
94
106
|
export declare function mapMessageToEntity(message: GmailMessageResource): EntityRecord;
|
|
107
|
+
export declare function getMessage(config: {
|
|
108
|
+
accessToken: string;
|
|
109
|
+
messageID: string;
|
|
110
|
+
fetch?: typeof globalThis.fetch;
|
|
111
|
+
signal?: AbortSignal;
|
|
112
|
+
}): Promise<GmailMessageResource>;
|
|
113
|
+
export declare function getThread(config: {
|
|
114
|
+
accessToken: string;
|
|
115
|
+
threadID: string;
|
|
116
|
+
fetch?: typeof globalThis.fetch;
|
|
117
|
+
signal?: AbortSignal;
|
|
118
|
+
}): Promise<GmailThreadResource>;
|
|
119
|
+
export declare function getLabel(config: {
|
|
120
|
+
accessToken: string;
|
|
121
|
+
labelID: string;
|
|
122
|
+
fetch?: typeof globalThis.fetch;
|
|
123
|
+
signal?: AbortSignal;
|
|
124
|
+
}): Promise<GmailLabelResource>;
|
|
95
125
|
export declare function importMessageAttachment(config: {
|
|
96
126
|
accessToken: string;
|
|
97
127
|
messageID: string;
|
package/lib/api.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import { connectorHTTPError, markWriteSent } from '@kubun/connector';
|
|
2
|
+
import { createGoogleAPIClient } from '@kubun/provider-google';
|
|
1
3
|
import { b64uFromUTF, b64uToUTF, fromB64U } from '@sozai/codec';
|
|
2
4
|
export const MAIL_BASE_URL = 'https://gmail.googleapis.com/gmail/v1';
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
5
|
+
export function mailAPIClient(accessToken, fetch) {
|
|
6
|
+
return createGoogleAPIClient({
|
|
7
|
+
api: 'Google Mail API',
|
|
8
|
+
accessToken,
|
|
9
|
+
fetch,
|
|
10
|
+
toError: ({ httpStatus, message })=>connectorHTTPError(message, httpStatus)
|
|
11
|
+
});
|
|
10
12
|
}
|
|
11
13
|
export function decodeBase64Url(data) {
|
|
12
14
|
return b64uToUTF(data);
|
|
@@ -28,6 +30,14 @@ export function buildMessageGetURL(params) {
|
|
|
28
30
|
url.searchParams.set('format', 'full');
|
|
29
31
|
return url.toString();
|
|
30
32
|
}
|
|
33
|
+
export function buildThreadGetURL(params) {
|
|
34
|
+
const url = new URL(`${MAIL_BASE_URL}/users/me/threads/${encodeURIComponent(params.threadID)}`);
|
|
35
|
+
url.searchParams.set('format', 'full');
|
|
36
|
+
return url.toString();
|
|
37
|
+
}
|
|
38
|
+
export function buildLabelGetURL(params) {
|
|
39
|
+
return `${MAIL_BASE_URL}/users/me/labels/${encodeURIComponent(params.labelID)}`;
|
|
40
|
+
}
|
|
31
41
|
export function buildAttachmentGetURL(params) {
|
|
32
42
|
return `${MAIL_BASE_URL}/users/me/messages/${encodeURIComponent(params.messageID)}/attachments/${encodeURIComponent(params.attachmentID)}`;
|
|
33
43
|
}
|
|
@@ -128,6 +138,36 @@ export function mapMessageToEntity(message) {
|
|
|
128
138
|
}
|
|
129
139
|
};
|
|
130
140
|
}
|
|
141
|
+
// Targeted single-message fetch (anti-entropy repair verification / write-back
|
|
142
|
+
// recovery's fetchEntity+reconcile). A 404 here is the deletion signal:
|
|
143
|
+
// fetchOrAbsent turns it into a `present: false` result instead of a throw.
|
|
144
|
+
export async function getMessage(config) {
|
|
145
|
+
return mailAPIClient(config.accessToken, config.fetch).get(buildMessageGetURL({
|
|
146
|
+
messageID: config.messageID
|
|
147
|
+
}), {
|
|
148
|
+
signal: config.signal
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
// Targeted single-thread fetch — a real threads.get(format=full) read, mapped
|
|
152
|
+
// from its own resource (id + constituent messages), never synthesized from a
|
|
153
|
+
// bulk message listing's locally-scanned subject.
|
|
154
|
+
export async function getThread(config) {
|
|
155
|
+
return mailAPIClient(config.accessToken, config.fetch).get(buildThreadGetURL({
|
|
156
|
+
threadID: config.threadID
|
|
157
|
+
}), {
|
|
158
|
+
signal: config.signal
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
// Targeted single-label fetch. Unlike labels.list (buildLabelsListURL, used by
|
|
162
|
+
// the bulk labelBatch), a direct labels.get returns the full resource
|
|
163
|
+
// including `color` — the list endpoint omits it.
|
|
164
|
+
export async function getLabel(config) {
|
|
165
|
+
return mailAPIClient(config.accessToken, config.fetch).get(buildLabelGetURL({
|
|
166
|
+
labelID: config.labelID
|
|
167
|
+
}), {
|
|
168
|
+
signal: config.signal
|
|
169
|
+
});
|
|
170
|
+
}
|
|
131
171
|
function streamOfBytes(bytes) {
|
|
132
172
|
return new ReadableStream({
|
|
133
173
|
start (controller) {
|
|
@@ -137,26 +177,22 @@ function streamOfBytes(bytes) {
|
|
|
137
177
|
});
|
|
138
178
|
}
|
|
139
179
|
// 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
|
-
//
|
|
180
|
+
// return the message identity with just its `attachments`, that attachment's
|
|
181
|
+
// `blob` set. Other attachments' already-imported blobs are preserved from
|
|
182
|
+
// `existing`, and no other field of `existing` is carried: `attachments` is a
|
|
183
|
+
// local annotation, and the local copy of the provider fields must never be
|
|
184
|
+
// re-issued as if freshly read. Gmail returns the whole attachment as a single
|
|
185
|
+
// base64url `data` field, so the bytes are buffered here.
|
|
143
186
|
export async function importMessageAttachment(config) {
|
|
144
187
|
const attachments = config.existing?.attachments ?? [];
|
|
145
188
|
const target = attachments.find((a)=>a.providerRef === config.providerRef);
|
|
146
189
|
if (target == null) {
|
|
147
190
|
throw new Error(`Google Mail: no attachment with providerRef "${config.providerRef}"`);
|
|
148
191
|
}
|
|
149
|
-
const
|
|
150
|
-
const response = await fetchFn(buildAttachmentGetURL({
|
|
192
|
+
const body = await mailAPIClient(config.accessToken, config.fetch).get(buildAttachmentGetURL({
|
|
151
193
|
messageID: config.messageID,
|
|
152
194
|
attachmentID: config.providerRef
|
|
153
|
-
})
|
|
154
|
-
headers: {
|
|
155
|
-
Authorization: `Bearer ${config.accessToken}`
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
if (!response.ok) throw new Error(`Google Mail API error: ${await parseGoogleError(response)}`);
|
|
159
|
-
const body = await response.json();
|
|
195
|
+
}));
|
|
160
196
|
if (body.data == null) throw new Error('Google Mail: attachment response has no data');
|
|
161
197
|
const bytes = decodeBase64UrlBytes(body.data);
|
|
162
198
|
const attachmentID = await config.writeAttachment(streamOfBytes(bytes), {
|
|
@@ -167,7 +203,6 @@ export async function importMessageAttachment(config) {
|
|
|
167
203
|
blob: attachmentID
|
|
168
204
|
} : a);
|
|
169
205
|
return {
|
|
170
|
-
...config.existing ?? {},
|
|
171
206
|
sourceService: 'google-mail',
|
|
172
207
|
sourceID: config.messageID,
|
|
173
208
|
attachments: updated
|
|
@@ -228,20 +263,21 @@ function requireString(params, key) {
|
|
|
228
263
|
// Gmail's write endpoints (messages.send, drafts.create, messages.modify) return only
|
|
229
264
|
// { id, threadId, labelIds } -- no payload/headers/body. Re-fetch the full message so the
|
|
230
265
|
// mapped entity carries real sender/body/subject/etc. instead of empty placeholders.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
266
|
+
// Everything here runs after the write went out, so any failure is marked sent: the
|
|
267
|
+
// write may have landed, whatever the read-back did.
|
|
268
|
+
async function readBackWrittenMessage(config) {
|
|
269
|
+
try {
|
|
270
|
+
const messageID = config.messageIDOf(await config.written.json());
|
|
271
|
+
const message = await mailAPIClient(config.accessToken, config.fetch).get(buildMessageGetURL({
|
|
272
|
+
messageID
|
|
273
|
+
}));
|
|
274
|
+
return mapMessageToEntity(message);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
throw markWriteSent(err);
|
|
277
|
+
}
|
|
242
278
|
}
|
|
243
279
|
export async function sendMessage(config) {
|
|
244
|
-
const
|
|
280
|
+
const client = mailAPIClient(config.accessToken, config.fetch);
|
|
245
281
|
const raw = buildRawMessage({
|
|
246
282
|
to: config.params.to,
|
|
247
283
|
cc: config.params.cc,
|
|
@@ -249,26 +285,22 @@ export async function sendMessage(config) {
|
|
|
249
285
|
subject: requireString(config.params, 'subject'),
|
|
250
286
|
body: requireString(config.params, 'body')
|
|
251
287
|
});
|
|
252
|
-
const response = await
|
|
288
|
+
const response = await client.request(`${MAIL_BASE_URL}/users/me/messages/send`, {
|
|
253
289
|
method: 'POST',
|
|
254
|
-
|
|
255
|
-
Authorization: `Bearer ${config.accessToken}`,
|
|
256
|
-
'Content-Type': 'application/json'
|
|
257
|
-
},
|
|
258
|
-
body: JSON.stringify({
|
|
290
|
+
body: {
|
|
259
291
|
raw
|
|
260
|
-
}
|
|
292
|
+
}
|
|
261
293
|
});
|
|
262
|
-
if (!response.ok)
|
|
263
|
-
|
|
264
|
-
return fetchAndMapMessage({
|
|
294
|
+
if (!response.ok) return client.fail(response);
|
|
295
|
+
return readBackWrittenMessage({
|
|
265
296
|
accessToken: config.accessToken,
|
|
266
|
-
|
|
297
|
+
written: response,
|
|
298
|
+
messageIDOf: (sent)=>sent.id,
|
|
267
299
|
fetch: config.fetch
|
|
268
300
|
});
|
|
269
301
|
}
|
|
270
302
|
export async function createDraft(config) {
|
|
271
|
-
const
|
|
303
|
+
const client = mailAPIClient(config.accessToken, config.fetch);
|
|
272
304
|
const raw = buildRawMessage({
|
|
273
305
|
to: config.params.to,
|
|
274
306
|
cc: config.params.cc,
|
|
@@ -276,45 +308,37 @@ export async function createDraft(config) {
|
|
|
276
308
|
subject: requireString(config.params, 'subject'),
|
|
277
309
|
body: requireString(config.params, 'body')
|
|
278
310
|
});
|
|
279
|
-
const response = await
|
|
311
|
+
const response = await client.request(`${MAIL_BASE_URL}/users/me/drafts`, {
|
|
280
312
|
method: 'POST',
|
|
281
|
-
|
|
282
|
-
Authorization: `Bearer ${config.accessToken}`,
|
|
283
|
-
'Content-Type': 'application/json'
|
|
284
|
-
},
|
|
285
|
-
body: JSON.stringify({
|
|
313
|
+
body: {
|
|
286
314
|
message: {
|
|
287
315
|
raw
|
|
288
316
|
}
|
|
289
|
-
}
|
|
317
|
+
}
|
|
290
318
|
});
|
|
291
|
-
if (!response.ok)
|
|
292
|
-
|
|
293
|
-
return fetchAndMapMessage({
|
|
319
|
+
if (!response.ok) return client.fail(response);
|
|
320
|
+
return readBackWrittenMessage({
|
|
294
321
|
accessToken: config.accessToken,
|
|
295
|
-
|
|
322
|
+
written: response,
|
|
323
|
+
messageIDOf: (draft)=>draft.message.id,
|
|
296
324
|
fetch: config.fetch
|
|
297
325
|
});
|
|
298
326
|
}
|
|
299
327
|
export async function modifyLabels(config) {
|
|
300
|
-
const
|
|
328
|
+
const client = mailAPIClient(config.accessToken, config.fetch);
|
|
301
329
|
const url = `${MAIL_BASE_URL}/users/me/messages/${encodeURIComponent(config.messageID)}/modify`;
|
|
302
|
-
const response = await
|
|
330
|
+
const response = await client.request(url, {
|
|
303
331
|
method: 'POST',
|
|
304
|
-
|
|
305
|
-
Authorization: `Bearer ${config.accessToken}`,
|
|
306
|
-
'Content-Type': 'application/json'
|
|
307
|
-
},
|
|
308
|
-
body: JSON.stringify({
|
|
332
|
+
body: {
|
|
309
333
|
addLabelIds: config.addLabelIds ?? [],
|
|
310
334
|
removeLabelIds: config.removeLabelIds ?? []
|
|
311
|
-
}
|
|
335
|
+
}
|
|
312
336
|
});
|
|
313
|
-
if (!response.ok)
|
|
314
|
-
|
|
315
|
-
return fetchAndMapMessage({
|
|
337
|
+
if (!response.ok) return client.fail(response);
|
|
338
|
+
return readBackWrittenMessage({
|
|
316
339
|
accessToken: config.accessToken,
|
|
317
|
-
|
|
340
|
+
written: response,
|
|
341
|
+
messageIDOf: (modified)=>modified.id,
|
|
318
342
|
fetch: config.fetch
|
|
319
343
|
});
|
|
320
344
|
}
|
|
@@ -339,31 +363,19 @@ function buildLabelBody(params) {
|
|
|
339
363
|
return body;
|
|
340
364
|
}
|
|
341
365
|
export async function createLabel(config) {
|
|
342
|
-
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
343
366
|
const body = buildLabelBody(config.params);
|
|
344
367
|
if (typeof body.name !== 'string') throw new Error('Google Mail: "name" is required');
|
|
345
|
-
const
|
|
368
|
+
const label = await mailAPIClient(config.accessToken, config.fetch).send(buildLabelsListURL(), {
|
|
346
369
|
method: 'POST',
|
|
347
|
-
|
|
348
|
-
Authorization: `Bearer ${config.accessToken}`,
|
|
349
|
-
'Content-Type': 'application/json'
|
|
350
|
-
},
|
|
351
|
-
body: JSON.stringify(body)
|
|
370
|
+
body
|
|
352
371
|
});
|
|
353
|
-
|
|
354
|
-
return mapLabelToEntity(await response.json());
|
|
372
|
+
return mapLabelToEntity(label);
|
|
355
373
|
}
|
|
356
374
|
export async function updateLabel(config) {
|
|
357
|
-
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
358
375
|
const url = `${MAIL_BASE_URL}/users/me/labels/${encodeURIComponent(config.labelID)}`;
|
|
359
|
-
const
|
|
376
|
+
const label = await mailAPIClient(config.accessToken, config.fetch).send(url, {
|
|
360
377
|
method: 'PATCH',
|
|
361
|
-
|
|
362
|
-
Authorization: `Bearer ${config.accessToken}`,
|
|
363
|
-
'Content-Type': 'application/json'
|
|
364
|
-
},
|
|
365
|
-
body: JSON.stringify(buildLabelBody(config.params))
|
|
378
|
+
body: buildLabelBody(config.params)
|
|
366
379
|
});
|
|
367
|
-
|
|
368
|
-
return mapLabelToEntity(await response.json());
|
|
380
|
+
return mapLabelToEntity(label);
|
|
369
381
|
}
|
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
|
@@ -1,23 +1,7 @@
|
|
|
1
|
-
import { connectorClusters, connectorModelIDs } from '@kubun/connector';
|
|
1
|
+
import { connectorClusters, connectorModelIDs, externalEntityDefinition } from '@kubun/connector';
|
|
2
2
|
import { modelIDs, modelsClusters } from '@kubun/models';
|
|
3
3
|
import { GraphBuilder } from '@kubun/protocol';
|
|
4
|
-
import {
|
|
5
|
-
const commonDefinitions = {
|
|
6
|
-
SourceService: {
|
|
7
|
-
type: 'string',
|
|
8
|
-
title: 'SourceService',
|
|
9
|
-
minLength: 1,
|
|
10
|
-
maxLength: 50
|
|
11
|
-
},
|
|
12
|
-
SourceID: {
|
|
13
|
-
type: 'string',
|
|
14
|
-
title: 'SourceID',
|
|
15
|
-
minLength: 1,
|
|
16
|
-
maxLength: 500
|
|
17
|
-
},
|
|
18
|
-
SourceURL: urlSchema,
|
|
19
|
-
DateTime: dateTimeSchema
|
|
20
|
-
};
|
|
4
|
+
import { docIDSchema } from '@kubun/scalars';
|
|
21
5
|
export const googleMailThreadDefinition = {
|
|
22
6
|
name: 'GoogleMailThread',
|
|
23
7
|
behavior: 'default',
|
|
@@ -52,10 +36,20 @@ export const googleMailThreadDefinition = {
|
|
|
52
36
|
],
|
|
53
37
|
additionalProperties: false,
|
|
54
38
|
definitions: {
|
|
55
|
-
...
|
|
39
|
+
...externalEntityDefinition.schema.definitions
|
|
56
40
|
}
|
|
57
41
|
},
|
|
58
|
-
fieldsMeta: {}
|
|
42
|
+
fieldsMeta: {},
|
|
43
|
+
// lastSyncedAt is a device-local sync-bookkeeping annotation, not fetched
|
|
44
|
+
// provider content, so it stays outside the group.
|
|
45
|
+
providerMergePolicy: {
|
|
46
|
+
group: [
|
|
47
|
+
'title',
|
|
48
|
+
'sourceService',
|
|
49
|
+
'sourceID',
|
|
50
|
+
'sourceURL'
|
|
51
|
+
]
|
|
52
|
+
}
|
|
59
53
|
};
|
|
60
54
|
export const googleMailMessageDefinition = {
|
|
61
55
|
name: 'GoogleMailMessage',
|
|
@@ -145,7 +139,7 @@ export const googleMailMessageDefinition = {
|
|
|
145
139
|
],
|
|
146
140
|
additionalProperties: false,
|
|
147
141
|
definitions: {
|
|
148
|
-
...
|
|
142
|
+
...externalEntityDefinition.schema.definitions,
|
|
149
143
|
ThreadDocID: docIDSchema,
|
|
150
144
|
Attachment: {
|
|
151
145
|
type: 'object',
|
|
@@ -185,6 +179,28 @@ export const googleMailMessageDefinition = {
|
|
|
185
179
|
type: 'document',
|
|
186
180
|
model: '#0'
|
|
187
181
|
}
|
|
182
|
+
},
|
|
183
|
+
// lastSyncedAt is device-local sync bookkeeping; attachments carries the
|
|
184
|
+
// locally-materialized `blob` annotation (absent until an explicit import
|
|
185
|
+
// fills it), so the whole field stays outside the group rather than
|
|
186
|
+
// splitting a single top-level field between provider and device content.
|
|
187
|
+
providerMergePolicy: {
|
|
188
|
+
group: [
|
|
189
|
+
'body',
|
|
190
|
+
'sender',
|
|
191
|
+
'subject',
|
|
192
|
+
'snippet',
|
|
193
|
+
'to',
|
|
194
|
+
'cc',
|
|
195
|
+
'labels',
|
|
196
|
+
'sentAt',
|
|
197
|
+
'unread',
|
|
198
|
+
'threadID',
|
|
199
|
+
'thread',
|
|
200
|
+
'sourceService',
|
|
201
|
+
'sourceID',
|
|
202
|
+
'sourceURL'
|
|
203
|
+
]
|
|
188
204
|
}
|
|
189
205
|
};
|
|
190
206
|
export const googleMailLabelDefinition = {
|
|
@@ -242,10 +258,25 @@ export const googleMailLabelDefinition = {
|
|
|
242
258
|
],
|
|
243
259
|
additionalProperties: false,
|
|
244
260
|
definitions: {
|
|
245
|
-
...
|
|
261
|
+
...externalEntityDefinition.schema.definitions
|
|
246
262
|
}
|
|
247
263
|
},
|
|
248
|
-
fieldsMeta: {}
|
|
264
|
+
fieldsMeta: {},
|
|
265
|
+
// lastSyncedAt is a device-local sync-bookkeeping annotation, not fetched
|
|
266
|
+
// provider content, so it stays outside the group.
|
|
267
|
+
providerMergePolicy: {
|
|
268
|
+
group: [
|
|
269
|
+
'name',
|
|
270
|
+
'type',
|
|
271
|
+
'messageListVisibility',
|
|
272
|
+
'labelListVisibility',
|
|
273
|
+
'textColor',
|
|
274
|
+
'backgroundColor',
|
|
275
|
+
'sourceService',
|
|
276
|
+
'sourceID',
|
|
277
|
+
'sourceURL'
|
|
278
|
+
]
|
|
279
|
+
}
|
|
249
280
|
};
|
|
250
281
|
const builder = new GraphBuilder({
|
|
251
282
|
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,32 +1,18 @@
|
|
|
1
|
-
import { resolveModelID } from '@kubun/connector';
|
|
2
|
-
import { buildHistoryListURL, buildLabelsListURL,
|
|
1
|
+
import { fetchOrAbsent, parseMaxAgeDays, resolveModelID } from '@kubun/connector';
|
|
2
|
+
import { buildHistoryListURL, buildLabelsListURL, buildMessagesListURL, buildProfileURL, getHeader, getLabel, getMessage, getThread, mailAPIClient, mapLabelToEntity, mapMessageToEntity, mapThreadToEntity } 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');
|
|
6
6
|
const MESSAGE_MODEL_ID = resolveModelID(googleMailClusters, 'GoogleMailMessage');
|
|
7
7
|
const LABEL_MODEL_ID = resolveModelID(googleMailClusters, 'GoogleMailLabel');
|
|
8
|
-
async function fetchJSON(url, credential, fetchFn, signal) {
|
|
9
|
-
const response = await fetchFn(url, {
|
|
10
|
-
headers: {
|
|
11
|
-
Authorization: `Bearer ${credential.accessToken}`
|
|
12
|
-
},
|
|
13
|
-
signal
|
|
14
|
-
});
|
|
15
|
-
if (!response.ok) {
|
|
16
|
-
throw new Error(`Google Mail API error: ${await parseGoogleError(response)}`);
|
|
17
|
-
}
|
|
18
|
-
return await response.json();
|
|
19
|
-
}
|
|
20
8
|
/**
|
|
21
9
|
* The absolute lower bound (epoch seconds) for a full message listing: a window
|
|
22
10
|
* already frozen in the checkpoint, else `now − maxAge` computed once. Null when
|
|
23
11
|
* the boundary carries no `maxAge` (no floor).
|
|
24
12
|
*/ function windowStartSeconds(boundary, frozen) {
|
|
25
13
|
if (frozen != null) return frozen;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (match?.[1] == null) return null;
|
|
29
|
-
const days = Number.parseInt(match[1], 10);
|
|
14
|
+
const days = parseMaxAgeDays(boundary);
|
|
15
|
+
if (days == null) return null;
|
|
30
16
|
return Math.floor((Date.now() - days * 86_400_000) / 1000);
|
|
31
17
|
}
|
|
32
18
|
/** Appends an absolute `after:<epoch seconds>` floor to a Gmail search query. */ function applyAfter(query, windowStart) {
|
|
@@ -44,36 +30,55 @@ function maxHistoryId(messages) {
|
|
|
44
30
|
}
|
|
45
31
|
export function createGoogleMailProvider(config) {
|
|
46
32
|
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
33
|
+
const client = mailAPIClient(config.credential.accessToken, fetchFn);
|
|
34
|
+
const mintFetchHLC = config.mintFetchHLC;
|
|
47
35
|
async function listMessageIDs(query, signal) {
|
|
48
36
|
const ids = [];
|
|
49
37
|
let pageToken;
|
|
50
38
|
do {
|
|
51
|
-
const data = await
|
|
39
|
+
const data = await client.get(buildMessagesListURL({
|
|
52
40
|
query,
|
|
53
41
|
pageToken
|
|
54
|
-
}),
|
|
42
|
+
}), {
|
|
43
|
+
signal
|
|
44
|
+
});
|
|
55
45
|
for (const m of data.messages ?? [])ids.push(m);
|
|
56
46
|
pageToken = data.nextPageToken;
|
|
57
47
|
}while (pageToken != null)
|
|
58
48
|
return ids;
|
|
59
49
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
50
|
+
// Bulk-listing message fetch (fetchAll/fetchChanges) — delegates to the
|
|
51
|
+
// shared api.ts getMessage so both the bulk and targeted (fetchEntity)
|
|
52
|
+
// paths share one 404-tagging choke point.
|
|
53
|
+
async function getMessageResource(id, signal) {
|
|
54
|
+
return getMessage({
|
|
55
|
+
accessToken: config.credential.accessToken,
|
|
56
|
+
messageID: id,
|
|
57
|
+
fetch: fetchFn,
|
|
58
|
+
signal
|
|
59
|
+
});
|
|
64
60
|
}
|
|
65
61
|
async function profileHistoryId(signal) {
|
|
66
|
-
const profile = await
|
|
62
|
+
const profile = await client.get(buildProfileURL(), {
|
|
63
|
+
signal
|
|
64
|
+
});
|
|
67
65
|
return profile.historyId ?? '0';
|
|
68
66
|
}
|
|
69
67
|
// Labels have no incremental history API, so every sync re-lists them in full.
|
|
70
68
|
// The list endpoint is a single cheap call; it omits color (captured only on
|
|
71
69
|
// create/edit) but carries id, name, type, and the visibility fields.
|
|
72
70
|
async function labelBatch(signal) {
|
|
73
|
-
const data = await
|
|
71
|
+
const data = await client.get(buildLabelsListURL(), {
|
|
72
|
+
signal
|
|
73
|
+
});
|
|
74
|
+
const sourceFetchHLC = mintFetchHLC();
|
|
74
75
|
return {
|
|
75
76
|
modelID: LABEL_MODEL_ID,
|
|
76
|
-
entities: (data.labels ?? []).map(
|
|
77
|
+
entities: (data.labels ?? []).map((label)=>({
|
|
78
|
+
present: true,
|
|
79
|
+
record: mapLabelToEntity(label),
|
|
80
|
+
sourceFetchHLC
|
|
81
|
+
})),
|
|
77
82
|
hasMore: true
|
|
78
83
|
};
|
|
79
84
|
}
|
|
@@ -87,7 +92,7 @@ export function createGoogleMailProvider(config) {
|
|
|
87
92
|
const ids = await listMessageIDs(query, params.signal);
|
|
88
93
|
const messages = [];
|
|
89
94
|
for (const { id } of ids){
|
|
90
|
-
messages.push(await
|
|
95
|
+
messages.push(await getMessageResource(id, params.signal));
|
|
91
96
|
}
|
|
92
97
|
// Thread batch: one thread per distinct threadId, titled by its first message's subject.
|
|
93
98
|
const threadSubjects = new Map();
|
|
@@ -96,11 +101,16 @@ export function createGoogleMailProvider(config) {
|
|
|
96
101
|
threadSubjects.set(m.threadId, getHeader(m.payload?.headers, 'Subject'));
|
|
97
102
|
}
|
|
98
103
|
}
|
|
104
|
+
const threadToken = mintFetchHLC();
|
|
99
105
|
yield {
|
|
100
106
|
modelID: THREAD_MODEL_ID,
|
|
101
107
|
entities: [
|
|
102
108
|
...threadSubjects
|
|
103
|
-
].map(([threadId, subject])=>
|
|
109
|
+
].map(([threadId, subject])=>({
|
|
110
|
+
present: true,
|
|
111
|
+
record: mapThreadToEntity(threadId, subject),
|
|
112
|
+
sourceFetchHLC: threadToken
|
|
113
|
+
})),
|
|
104
114
|
hasMore: true
|
|
105
115
|
};
|
|
106
116
|
const checkpoint = {
|
|
@@ -109,9 +119,14 @@ export function createGoogleMailProvider(config) {
|
|
|
109
119
|
windowStart
|
|
110
120
|
} : {}
|
|
111
121
|
};
|
|
122
|
+
const messageToken = mintFetchHLC();
|
|
112
123
|
yield {
|
|
113
124
|
modelID: MESSAGE_MODEL_ID,
|
|
114
|
-
entities: messages.map(
|
|
125
|
+
entities: messages.map((message)=>({
|
|
126
|
+
present: true,
|
|
127
|
+
record: mapMessageToEntity(message),
|
|
128
|
+
sourceFetchHLC: messageToken
|
|
129
|
+
})),
|
|
115
130
|
hasMore: false,
|
|
116
131
|
checkpoint
|
|
117
132
|
};
|
|
@@ -129,22 +144,18 @@ export function createGoogleMailProvider(config) {
|
|
|
129
144
|
let latestHistoryId = checkpoint.historyId;
|
|
130
145
|
let pageToken;
|
|
131
146
|
do {
|
|
132
|
-
const response = await
|
|
147
|
+
const response = await client.request(buildHistoryListURL({
|
|
133
148
|
startHistoryId: checkpoint.historyId,
|
|
134
149
|
pageToken
|
|
135
150
|
}), {
|
|
136
|
-
|
|
137
|
-
Authorization: `Bearer ${config.credential.accessToken}`
|
|
138
|
-
},
|
|
151
|
+
method: 'GET',
|
|
139
152
|
signal: params.signal
|
|
140
153
|
});
|
|
141
154
|
if (response.status === 404) {
|
|
142
155
|
yield* fetchAllEntities(params);
|
|
143
156
|
return;
|
|
144
157
|
}
|
|
145
|
-
if (!response.ok)
|
|
146
|
-
throw new Error(`Google Mail API error: ${await parseGoogleError(response)}`);
|
|
147
|
-
}
|
|
158
|
+
if (!response.ok) await client.fail(response);
|
|
148
159
|
const data = await response.json();
|
|
149
160
|
for (const record of data.history ?? []){
|
|
150
161
|
for (const ref of [
|
|
@@ -167,7 +178,7 @@ export function createGoogleMailProvider(config) {
|
|
|
167
178
|
].filter((id)=>!deletedIDs.has(id));
|
|
168
179
|
const messages = [];
|
|
169
180
|
for (const id of survivingIDs){
|
|
170
|
-
messages.push(await
|
|
181
|
+
messages.push(await getMessageResource(id, params.signal));
|
|
171
182
|
}
|
|
172
183
|
const threadSubjects = new Map();
|
|
173
184
|
for (const m of messages){
|
|
@@ -175,21 +186,32 @@ export function createGoogleMailProvider(config) {
|
|
|
175
186
|
threadSubjects.set(m.threadId, getHeader(m.payload?.headers, 'Subject'));
|
|
176
187
|
}
|
|
177
188
|
}
|
|
189
|
+
const threadToken = mintFetchHLC();
|
|
178
190
|
yield {
|
|
179
191
|
modelID: THREAD_MODEL_ID,
|
|
180
192
|
entities: [
|
|
181
193
|
...threadSubjects
|
|
182
|
-
].map(([threadId, subject])=>
|
|
194
|
+
].map(([threadId, subject])=>({
|
|
195
|
+
present: true,
|
|
196
|
+
record: mapThreadToEntity(threadId, subject),
|
|
197
|
+
sourceFetchHLC: threadToken
|
|
198
|
+
})),
|
|
183
199
|
hasMore: true
|
|
184
200
|
};
|
|
201
|
+
const messageToken = mintFetchHLC();
|
|
185
202
|
yield {
|
|
186
203
|
modelID: MESSAGE_MODEL_ID,
|
|
187
|
-
entities: messages.map(
|
|
204
|
+
entities: messages.map((message)=>({
|
|
205
|
+
present: true,
|
|
206
|
+
record: mapMessageToEntity(message),
|
|
207
|
+
sourceFetchHLC: messageToken
|
|
208
|
+
})),
|
|
188
209
|
deleted: [
|
|
189
210
|
...deletedIDs
|
|
190
211
|
].map((id)=>({
|
|
191
|
-
|
|
192
|
-
sourceID: id
|
|
212
|
+
present: false,
|
|
213
|
+
sourceID: id,
|
|
214
|
+
sourceFetchHLC: messageToken
|
|
193
215
|
})),
|
|
194
216
|
hasMore: false,
|
|
195
217
|
checkpoint: {
|
|
@@ -200,11 +222,65 @@ export function createGoogleMailProvider(config) {
|
|
|
200
222
|
}
|
|
201
223
|
};
|
|
202
224
|
}
|
|
225
|
+
// Targeted single-entity fetch (anti-entropy repair verification / write-back
|
|
226
|
+
// recovery). Mints its own fetch-order token at request start, independent of
|
|
227
|
+
// any batch token. `model` distinguishes the three GoogleMail* resources —
|
|
228
|
+
// Thread uses a real threads.get (not the bulk-listing subject synthesis
|
|
229
|
+
// fetchAll/fetchChanges use); Label uses labels.get (carries color, unlike
|
|
230
|
+
// the list endpoint labelBatch uses).
|
|
231
|
+
async function fetchEntity(model, sourceID) {
|
|
232
|
+
const sourceFetchHLC = mintFetchHLC();
|
|
233
|
+
return fetchOrAbsent({
|
|
234
|
+
sourceID,
|
|
235
|
+
sourceFetchHLC,
|
|
236
|
+
read: async ()=>{
|
|
237
|
+
if (model === THREAD_MODEL_ID) {
|
|
238
|
+
const thread = await getThread({
|
|
239
|
+
accessToken: config.credential.accessToken,
|
|
240
|
+
threadID: sourceID,
|
|
241
|
+
fetch: fetchFn
|
|
242
|
+
});
|
|
243
|
+
const first = thread.messages?.[0];
|
|
244
|
+
const subject = first != null ? getHeader(first.payload?.headers, 'Subject') : undefined;
|
|
245
|
+
return {
|
|
246
|
+
record: mapThreadToEntity(thread.id, subject)
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
if (model === LABEL_MODEL_ID) {
|
|
250
|
+
const label = await getLabel({
|
|
251
|
+
accessToken: config.credential.accessToken,
|
|
252
|
+
labelID: sourceID,
|
|
253
|
+
fetch: fetchFn
|
|
254
|
+
});
|
|
255
|
+
return {
|
|
256
|
+
record: mapLabelToEntity(label)
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const message = await getMessage({
|
|
260
|
+
accessToken: config.credential.accessToken,
|
|
261
|
+
messageID: sourceID,
|
|
262
|
+
fetch: fetchFn
|
|
263
|
+
});
|
|
264
|
+
return {
|
|
265
|
+
record: mapMessageToEntity(message)
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
// Provider-authoritative re-read for write-back recovery. Gmail's
|
|
271
|
+
// resources carry no dependable ETag on a direct get, so this mirrors
|
|
272
|
+
// fetchEntity without a providerETag — present for contract parity with the
|
|
273
|
+
// other connectors' reconcile.
|
|
274
|
+
async function reconcile(model, sourceID) {
|
|
275
|
+
return fetchEntity(model, sourceID);
|
|
276
|
+
}
|
|
203
277
|
return {
|
|
204
278
|
supportedStrategies: [
|
|
205
279
|
'on-demand'
|
|
206
280
|
],
|
|
207
281
|
fetchAll: fetchAllEntities,
|
|
208
|
-
fetchChanges: fetchChangesEntities
|
|
282
|
+
fetchChanges: fetchChangesEntities,
|
|
283
|
+
fetchEntity,
|
|
284
|
+
reconcile
|
|
209
285
|
};
|
|
210
286
|
}
|
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.3",
|
|
4
4
|
"keywords": [],
|
|
5
5
|
"license": "see LICENSE.md",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -15,11 +15,12 @@
|
|
|
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.3",
|
|
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/provider-google": "^0.17.
|
|
22
|
+
"@kubun/protocol": "^0.17.2",
|
|
23
|
+
"@kubun/provider-google": "^0.17.1",
|
|
23
24
|
"@kubun/scalars": "^0.17.0",
|
|
24
25
|
"@sozai/codec": "^0.4.0"
|
|
25
26
|
},
|