@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 CHANGED
@@ -6,5 +6,6 @@ export type GoogleMailActionHandlerConfig = {
6
6
  };
7
7
  fetch?: typeof globalThis.fetch;
8
8
  writeAttachment?: WriteAttachment;
9
+ mintFetchHLC: () => string;
9
10
  };
10
11
  export declare function createGoogleMailActionHandler(config: GoogleMailActionHandlerConfig): ActionHandler;
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
- return createLabel({
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
- return createDraft({
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
- return sendMessage({
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
- return updateLabel({
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. Without it, the update modifies labels as before.
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
- return importMessageAttachment({
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
- return modifyLabels({
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 { EntityRecord, WriteAttachment } from '@kubun/connector';
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 parseGoogleError(response: Response): Promise<string>;
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 async function parseGoogleError(response) {
4
- try {
5
- const body = await response.json();
6
- return body.error?.message ?? `${response.status} ${response.statusText}`;
7
- } catch {
8
- return `${response.status} ${response.statusText}`;
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 entity with that attachment's `blob` set. Other attachments'
141
- // already-imported blobs are preserved from `existing`. Gmail returns the whole
142
- // attachment as a single base64url `data` field, so the bytes are buffered here.
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 fetchFn = config.fetch ?? globalThis.fetch;
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
- async function fetchAndMapMessage(config) {
232
- const fetchFn = config.fetch ?? globalThis.fetch;
233
- const response = await fetchFn(buildMessageGetURL({
234
- messageID: config.messageID
235
- }), {
236
- headers: {
237
- Authorization: `Bearer ${config.accessToken}`
238
- }
239
- });
240
- if (!response.ok) throw new Error(`Google Mail API error: ${await parseGoogleError(response)}`);
241
- return mapMessageToEntity(await response.json());
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 fetchFn = config.fetch ?? globalThis.fetch;
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 fetchFn(`${MAIL_BASE_URL}/users/me/messages/send`, {
288
+ const response = await client.request(`${MAIL_BASE_URL}/users/me/messages/send`, {
253
289
  method: 'POST',
254
- headers: {
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) throw new Error(`Google Mail API error: ${await parseGoogleError(response)}`);
263
- const sent = await response.json();
264
- return fetchAndMapMessage({
294
+ if (!response.ok) return client.fail(response);
295
+ return readBackWrittenMessage({
265
296
  accessToken: config.accessToken,
266
- messageID: sent.id,
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 fetchFn = config.fetch ?? globalThis.fetch;
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 fetchFn(`${MAIL_BASE_URL}/users/me/drafts`, {
311
+ const response = await client.request(`${MAIL_BASE_URL}/users/me/drafts`, {
280
312
  method: 'POST',
281
- headers: {
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) throw new Error(`Google Mail API error: ${await parseGoogleError(response)}`);
292
- const draft = await response.json();
293
- return fetchAndMapMessage({
319
+ if (!response.ok) return client.fail(response);
320
+ return readBackWrittenMessage({
294
321
  accessToken: config.accessToken,
295
- messageID: draft.message.id,
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 fetchFn = config.fetch ?? globalThis.fetch;
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 fetchFn(url, {
330
+ const response = await client.request(url, {
303
331
  method: 'POST',
304
- headers: {
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) throw new Error(`Google Mail API error: ${await parseGoogleError(response)}`);
314
- const modified = await response.json();
315
- return fetchAndMapMessage({
337
+ if (!response.ok) return client.fail(response);
338
+ return readBackWrittenMessage({
316
339
  accessToken: config.accessToken,
317
- messageID: modified.id,
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 response = await fetchFn(buildLabelsListURL(), {
368
+ const label = await mailAPIClient(config.accessToken, config.fetch).send(buildLabelsListURL(), {
346
369
  method: 'POST',
347
- headers: {
348
- Authorization: `Bearer ${config.accessToken}`,
349
- 'Content-Type': 'application/json'
350
- },
351
- body: JSON.stringify(body)
370
+ body
352
371
  });
353
- if (!response.ok) throw new Error(`Google Mail API error: ${await parseGoogleError(response)}`);
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 response = await fetchFn(url, {
376
+ const label = await mailAPIClient(config.accessToken, config.fetch).send(url, {
360
377
  method: 'PATCH',
361
- headers: {
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
- if (!response.ok) throw new Error(`Google Mail API error: ${await parseGoogleError(response)}`);
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: 'On GoogleMailMessage: send a message (or create a draft when draft is true). On GoogleMailLabel: create a label.',
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
- // GoogleMailLabel
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: 'On GoogleMailMessage: add/remove labels, or import an attachment when providerRef is set. On GoogleMailLabel: rename or restyle the label.',
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
- // GoogleMailLabel
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
  };
@@ -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 { dateTimeSchema, docIDSchema, urlSchema } from '@kubun/scalars';
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
- ...commonDefinitions
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
- ...commonDefinitions,
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
- ...commonDefinitions
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, buildMessageGetURL, buildMessagesListURL, buildProfileURL, getHeader, mapLabelToEntity, mapMessageToEntity, mapThreadToEntity, parseGoogleError } from './api.js';
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
- if (boundary?.maxAge == null) return null;
27
- const match = /^P(\d+)D$/i.exec(boundary.maxAge);
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 fetchJSON(buildMessagesListURL({
39
+ const data = await client.get(buildMessagesListURL({
52
40
  query,
53
41
  pageToken
54
- }), config.credential, fetchFn, signal);
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
- async function getMessage(id, signal) {
61
- return fetchJSON(buildMessageGetURL({
62
- messageID: id
63
- }), config.credential, fetchFn, signal);
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 fetchJSON(buildProfileURL(), config.credential, fetchFn, signal);
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 fetchJSON(buildLabelsListURL(), config.credential, fetchFn, signal);
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(mapLabelToEntity),
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 getMessage(id, params.signal));
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])=>mapThreadToEntity(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(mapMessageToEntity),
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 fetchFn(buildHistoryListURL({
147
+ const response = await client.request(buildHistoryListURL({
133
148
  startHistoryId: checkpoint.historyId,
134
149
  pageToken
135
150
  }), {
136
- headers: {
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 getMessage(id, params.signal));
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])=>mapThreadToEntity(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(mapMessageToEntity),
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
- sourceService: 'google-mail',
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.1",
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.1",
19
- "@kubun/credential-types": "^0.17.0",
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.0",
22
- "@kubun/provider-google": "^0.17.0",
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
  },