@kubun/connector-google-mail 0.17.1 → 0.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/actions.d.ts 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,4 +1,4 @@
1
- import type { EntityRecord, WriteAttachment } from '@kubun/connector';
1
+ import { type EntityRecord, type WriteAttachment } from '@kubun/connector';
2
2
  export declare const MAIL_BASE_URL = "https://gmail.googleapis.com/gmail/v1";
3
3
  export declare function parseGoogleError(response: Response): Promise<string>;
4
4
  export declare function decodeBase64Url(data: string): string;
@@ -11,6 +11,12 @@ export declare function buildMessagesListURL(params: {
11
11
  export declare function buildMessageGetURL(params: {
12
12
  messageID: string;
13
13
  }): string;
14
+ export declare function buildThreadGetURL(params: {
15
+ threadID: string;
16
+ }): string;
17
+ export declare function buildLabelGetURL(params: {
18
+ labelID: string;
19
+ }): string;
14
20
  export declare function buildAttachmentGetURL(params: {
15
21
  messageID: string;
16
22
  attachmentID: string;
@@ -85,6 +91,11 @@ export type GmailMessagesListResponse = {
85
91
  nextPageToken?: string;
86
92
  resultSizeEstimate?: number;
87
93
  };
94
+ export type GmailThreadResource = {
95
+ id: string;
96
+ historyId?: string;
97
+ messages?: Array<GmailMessageResource>;
98
+ };
88
99
  export declare function getHeader(headers: Array<{
89
100
  name: string;
90
101
  value: string;
@@ -92,6 +103,24 @@ export declare function getHeader(headers: Array<{
92
103
  export declare function extractEmail(headerValue: string): string;
93
104
  export declare function extractEmails(headerValue: string | undefined): Array<string>;
94
105
  export declare function mapMessageToEntity(message: GmailMessageResource): EntityRecord;
106
+ export declare function getMessage(config: {
107
+ accessToken: string;
108
+ messageID: string;
109
+ fetch?: typeof globalThis.fetch;
110
+ signal?: AbortSignal;
111
+ }): Promise<GmailMessageResource>;
112
+ export declare function getThread(config: {
113
+ accessToken: string;
114
+ threadID: string;
115
+ fetch?: typeof globalThis.fetch;
116
+ signal?: AbortSignal;
117
+ }): Promise<GmailThreadResource>;
118
+ export declare function getLabel(config: {
119
+ accessToken: string;
120
+ labelID: string;
121
+ fetch?: typeof globalThis.fetch;
122
+ signal?: AbortSignal;
123
+ }): Promise<GmailLabelResource>;
95
124
  export declare function importMessageAttachment(config: {
96
125
  accessToken: string;
97
126
  messageID: string;
package/lib/api.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { connectorHTTPError, markWriteSent } from '@kubun/connector';
1
2
  import { b64uFromUTF, b64uToUTF, fromB64U } from '@sozai/codec';
2
3
  export const MAIL_BASE_URL = 'https://gmail.googleapis.com/gmail/v1';
3
4
  export async function parseGoogleError(response) {
@@ -8,6 +9,15 @@ export async function parseGoogleError(response) {
8
9
  return `${response.status} ${response.statusText}`;
9
10
  }
10
11
  }
12
+ // Build the error thrown by a Gmail call, tagged with the HTTP status and its
13
+ // outcome classification (`kind`, see `classifyHTTPStatus`) — a definite
14
+ // rejection dead-letters a write instead of re-driving it. The status is also
15
+ // the seam fetchEntity/reconcile (provider.ts) use to turn a 404 into a
16
+ // `present: false` result (deletion detected) instead of a throw.
17
+ async function googleMailAPIError(response) {
18
+ const detail = await parseGoogleError(response);
19
+ return connectorHTTPError(`Google Mail API error: ${detail}`, response.status);
20
+ }
11
21
  export function decodeBase64Url(data) {
12
22
  return b64uToUTF(data);
13
23
  }
@@ -28,6 +38,14 @@ export function buildMessageGetURL(params) {
28
38
  url.searchParams.set('format', 'full');
29
39
  return url.toString();
30
40
  }
41
+ export function buildThreadGetURL(params) {
42
+ const url = new URL(`${MAIL_BASE_URL}/users/me/threads/${encodeURIComponent(params.threadID)}`);
43
+ url.searchParams.set('format', 'full');
44
+ return url.toString();
45
+ }
46
+ export function buildLabelGetURL(params) {
47
+ return `${MAIL_BASE_URL}/users/me/labels/${encodeURIComponent(params.labelID)}`;
48
+ }
31
49
  export function buildAttachmentGetURL(params) {
32
50
  return `${MAIL_BASE_URL}/users/me/messages/${encodeURIComponent(params.messageID)}/attachments/${encodeURIComponent(params.attachmentID)}`;
33
51
  }
@@ -128,6 +146,54 @@ export function mapMessageToEntity(message) {
128
146
  }
129
147
  };
130
148
  }
149
+ // Targeted single-message fetch (anti-entropy repair verification / write-back
150
+ // recovery's fetchEntity+reconcile). A 404 here is the deletion signal — see
151
+ // googleMailAPIError's doc.
152
+ export async function getMessage(config) {
153
+ const fetchFn = config.fetch ?? globalThis.fetch;
154
+ const response = await fetchFn(buildMessageGetURL({
155
+ messageID: config.messageID
156
+ }), {
157
+ headers: {
158
+ Authorization: `Bearer ${config.accessToken}`
159
+ },
160
+ signal: config.signal
161
+ });
162
+ if (!response.ok) throw await googleMailAPIError(response);
163
+ return await response.json();
164
+ }
165
+ // Targeted single-thread fetch — a real threads.get(format=full) read, mapped
166
+ // from its own resource (id + constituent messages), never synthesized from a
167
+ // bulk message listing's locally-scanned subject.
168
+ export async function getThread(config) {
169
+ const fetchFn = config.fetch ?? globalThis.fetch;
170
+ const response = await fetchFn(buildThreadGetURL({
171
+ threadID: config.threadID
172
+ }), {
173
+ headers: {
174
+ Authorization: `Bearer ${config.accessToken}`
175
+ },
176
+ signal: config.signal
177
+ });
178
+ if (!response.ok) throw await googleMailAPIError(response);
179
+ return await response.json();
180
+ }
181
+ // Targeted single-label fetch. Unlike labels.list (buildLabelsListURL, used by
182
+ // the bulk labelBatch), a direct labels.get returns the full resource
183
+ // including `color` — the list endpoint omits it.
184
+ export async function getLabel(config) {
185
+ const fetchFn = config.fetch ?? globalThis.fetch;
186
+ const response = await fetchFn(buildLabelGetURL({
187
+ labelID: config.labelID
188
+ }), {
189
+ headers: {
190
+ Authorization: `Bearer ${config.accessToken}`
191
+ },
192
+ signal: config.signal
193
+ });
194
+ if (!response.ok) throw await googleMailAPIError(response);
195
+ return await response.json();
196
+ }
131
197
  function streamOfBytes(bytes) {
132
198
  return new ReadableStream({
133
199
  start (controller) {
@@ -137,9 +203,12 @@ function streamOfBytes(bytes) {
137
203
  });
138
204
  }
139
205
  // Download one attachment's bytes from Gmail, write them to the blob store, and
140
- // return the message 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.
206
+ // return the message identity with just its `attachments`, that attachment's
207
+ // `blob` set. Other attachments' already-imported blobs are preserved from
208
+ // `existing`, and no other field of `existing` is carried: `attachments` is a
209
+ // local annotation, and the local copy of the provider fields must never be
210
+ // re-issued as if freshly read. Gmail returns the whole attachment as a single
211
+ // base64url `data` field, so the bytes are buffered here.
143
212
  export async function importMessageAttachment(config) {
144
213
  const attachments = config.existing?.attachments ?? [];
145
214
  const target = attachments.find((a)=>a.providerRef === config.providerRef);
@@ -155,7 +224,7 @@ export async function importMessageAttachment(config) {
155
224
  Authorization: `Bearer ${config.accessToken}`
156
225
  }
157
226
  });
158
- if (!response.ok) throw new Error(`Google Mail API error: ${await parseGoogleError(response)}`);
227
+ if (!response.ok) throw await googleMailAPIError(response);
159
228
  const body = await response.json();
160
229
  if (body.data == null) throw new Error('Google Mail: attachment response has no data');
161
230
  const bytes = decodeBase64UrlBytes(body.data);
@@ -167,7 +236,6 @@ export async function importMessageAttachment(config) {
167
236
  blob: attachmentID
168
237
  } : a);
169
238
  return {
170
- ...config.existing ?? {},
171
239
  sourceService: 'google-mail',
172
240
  sourceID: config.messageID,
173
241
  attachments: updated
@@ -228,17 +296,24 @@ function requireString(params, key) {
228
296
  // Gmail's write endpoints (messages.send, drafts.create, messages.modify) return only
229
297
  // { id, threadId, labelIds } -- no payload/headers/body. Re-fetch the full message so the
230
298
  // mapped entity carries real sender/body/subject/etc. instead of empty placeholders.
231
- 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());
299
+ // Everything here runs after the write went out, so any failure is marked sent: the
300
+ // write may have landed, whatever the read-back did.
301
+ async function readBackWrittenMessage(config) {
302
+ try {
303
+ const messageID = config.messageIDOf(await config.written.json());
304
+ const fetchFn = config.fetch ?? globalThis.fetch;
305
+ const response = await fetchFn(buildMessageGetURL({
306
+ messageID
307
+ }), {
308
+ headers: {
309
+ Authorization: `Bearer ${config.accessToken}`
310
+ }
311
+ });
312
+ if (!response.ok) throw await googleMailAPIError(response);
313
+ return mapMessageToEntity(await response.json());
314
+ } catch (err) {
315
+ throw markWriteSent(err);
316
+ }
242
317
  }
243
318
  export async function sendMessage(config) {
244
319
  const fetchFn = config.fetch ?? globalThis.fetch;
@@ -259,11 +334,11 @@ export async function sendMessage(config) {
259
334
  raw
260
335
  })
261
336
  });
262
- if (!response.ok) throw new Error(`Google Mail API error: ${await parseGoogleError(response)}`);
263
- const sent = await response.json();
264
- return fetchAndMapMessage({
337
+ if (!response.ok) throw await googleMailAPIError(response);
338
+ return readBackWrittenMessage({
265
339
  accessToken: config.accessToken,
266
- messageID: sent.id,
340
+ written: response,
341
+ messageIDOf: (sent)=>sent.id,
267
342
  fetch: config.fetch
268
343
  });
269
344
  }
@@ -288,11 +363,11 @@ export async function createDraft(config) {
288
363
  }
289
364
  })
290
365
  });
291
- if (!response.ok) throw new Error(`Google Mail API error: ${await parseGoogleError(response)}`);
292
- const draft = await response.json();
293
- return fetchAndMapMessage({
366
+ if (!response.ok) throw await googleMailAPIError(response);
367
+ return readBackWrittenMessage({
294
368
  accessToken: config.accessToken,
295
- messageID: draft.message.id,
369
+ written: response,
370
+ messageIDOf: (draft)=>draft.message.id,
296
371
  fetch: config.fetch
297
372
  });
298
373
  }
@@ -310,11 +385,11 @@ export async function modifyLabels(config) {
310
385
  removeLabelIds: config.removeLabelIds ?? []
311
386
  })
312
387
  });
313
- if (!response.ok) throw new Error(`Google Mail API error: ${await parseGoogleError(response)}`);
314
- const modified = await response.json();
315
- return fetchAndMapMessage({
388
+ if (!response.ok) throw await googleMailAPIError(response);
389
+ return readBackWrittenMessage({
316
390
  accessToken: config.accessToken,
317
- messageID: modified.id,
391
+ written: response,
392
+ messageIDOf: (modified)=>modified.id,
318
393
  fetch: config.fetch
319
394
  });
320
395
  }
@@ -350,7 +425,7 @@ export async function createLabel(config) {
350
425
  },
351
426
  body: JSON.stringify(body)
352
427
  });
353
- if (!response.ok) throw new Error(`Google Mail API error: ${await parseGoogleError(response)}`);
428
+ if (!response.ok) throw await googleMailAPIError(response);
354
429
  return mapLabelToEntity(await response.json());
355
430
  }
356
431
  export async function updateLabel(config) {
@@ -364,6 +439,6 @@ export async function updateLabel(config) {
364
439
  },
365
440
  body: JSON.stringify(buildLabelBody(config.params))
366
441
  });
367
- if (!response.ok) throw new Error(`Google Mail API error: ${await parseGoogleError(response)}`);
442
+ if (!response.ok) throw await googleMailAPIError(response);
368
443
  return mapLabelToEntity(await response.json());
369
444
  }
package/lib/connector.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createGoogleMailActionHandler } from './actions.js';
2
+ import { mergeMailLocalAnnotations } from './local-annotations.js';
2
3
  import { googleMailClusters } from './model.js';
3
4
  import { createGoogleMailProvider, DEFAULT_QUERY } from './provider.js';
4
5
  export function createGoogleMailConnector(options) {
@@ -9,14 +10,19 @@ export function createGoogleMailConnector(options) {
9
10
  syncStrategies: [
10
11
  'on-demand'
11
12
  ],
13
+ // Two `create`/`update` verbs, each split per entityModel rather than one
14
+ // action spanning multiple models — schema.ts's dynamic mutation generator
15
+ // (connectorCreate<Model>/connectorUpdate<Model>) and the write-back
16
+ // recovery driver (writeback/workflow.ts) both select an action by
17
+ // (name, entityModel), so each model gets its own recoveryClass.
12
18
  actions: [
13
19
  {
14
20
  name: 'create',
15
- description: '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
@@ -55,7 +55,17 @@ export const googleMailThreadDefinition = {
55
55
  ...commonDefinitions
56
56
  }
57
57
  },
58
- fieldsMeta: {}
58
+ fieldsMeta: {},
59
+ // lastSyncedAt is a device-local sync-bookkeeping annotation, not fetched
60
+ // provider content, so it stays outside the group.
61
+ providerMergePolicy: {
62
+ group: [
63
+ 'title',
64
+ 'sourceService',
65
+ 'sourceID',
66
+ 'sourceURL'
67
+ ]
68
+ }
59
69
  };
60
70
  export const googleMailMessageDefinition = {
61
71
  name: 'GoogleMailMessage',
@@ -185,6 +195,28 @@ export const googleMailMessageDefinition = {
185
195
  type: 'document',
186
196
  model: '#0'
187
197
  }
198
+ },
199
+ // lastSyncedAt is device-local sync bookkeeping; attachments carries the
200
+ // locally-materialized `blob` annotation (absent until an explicit import
201
+ // fills it), so the whole field stays outside the group rather than
202
+ // splitting a single top-level field between provider and device content.
203
+ providerMergePolicy: {
204
+ group: [
205
+ 'body',
206
+ 'sender',
207
+ 'subject',
208
+ 'snippet',
209
+ 'to',
210
+ 'cc',
211
+ 'labels',
212
+ 'sentAt',
213
+ 'unread',
214
+ 'threadID',
215
+ 'thread',
216
+ 'sourceService',
217
+ 'sourceID',
218
+ 'sourceURL'
219
+ ]
188
220
  }
189
221
  };
190
222
  export const googleMailLabelDefinition = {
@@ -245,7 +277,22 @@ export const googleMailLabelDefinition = {
245
277
  ...commonDefinitions
246
278
  }
247
279
  },
248
- fieldsMeta: {}
280
+ fieldsMeta: {},
281
+ // lastSyncedAt is a device-local sync-bookkeeping annotation, not fetched
282
+ // provider content, so it stays outside the group.
283
+ providerMergePolicy: {
284
+ group: [
285
+ 'name',
286
+ 'type',
287
+ 'messageListVisibility',
288
+ 'labelListVisibility',
289
+ 'textColor',
290
+ 'backgroundColor',
291
+ 'sourceService',
292
+ 'sourceID',
293
+ 'sourceURL'
294
+ ]
295
+ }
249
296
  };
250
297
  const builder = new GraphBuilder({
251
298
  externalClusters: {
package/lib/provider.d.ts CHANGED
@@ -10,5 +10,6 @@ export type GoogleMailProviderConfig = {
10
10
  boundary?: SyncBoundary;
11
11
  fetch?: typeof globalThis.fetch;
12
12
  query?: string;
13
+ mintFetchHLC: () => string;
13
14
  };
14
15
  export declare function createGoogleMailProvider(config: GoogleMailProviderConfig): ServerDataProvider;
package/lib/provider.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { resolveModelID } from '@kubun/connector';
2
- import { buildHistoryListURL, buildLabelsListURL, buildMessageGetURL, buildMessagesListURL, buildProfileURL, getHeader, mapLabelToEntity, mapMessageToEntity, mapThreadToEntity, parseGoogleError } from './api.js';
2
+ import { buildHistoryListURL, buildLabelsListURL, buildMessagesListURL, buildProfileURL, getHeader, getLabel, getMessage, getThread, mapLabelToEntity, mapMessageToEntity, mapThreadToEntity, parseGoogleError } from './api.js';
3
3
  import { googleMailClusters } from './model.js';
4
4
  export const DEFAULT_QUERY = 'in:inbox';
5
5
  const THREAD_MODEL_ID = resolveModelID(googleMailClusters, 'GoogleMailThread');
@@ -44,6 +44,13 @@ function maxHistoryId(messages) {
44
44
  }
45
45
  export function createGoogleMailProvider(config) {
46
46
  const fetchFn = config.fetch ?? globalThis.fetch;
47
+ const mintFetchHLC = config.mintFetchHLC;
48
+ // getMessage/getThread/getLabel (api.ts) throw a plain Error tagged with
49
+ // `.httpStatus` — this is the seam fetchEntity/reconcile use to turn a 404
50
+ // into a `present: false` result instead of a throw.
51
+ function isNotFound(err) {
52
+ return err?.httpStatus === 404;
53
+ }
47
54
  async function listMessageIDs(query, signal) {
48
55
  const ids = [];
49
56
  let pageToken;
@@ -57,10 +64,16 @@ export function createGoogleMailProvider(config) {
57
64
  }while (pageToken != null)
58
65
  return ids;
59
66
  }
60
- async function getMessage(id, signal) {
61
- return fetchJSON(buildMessageGetURL({
62
- messageID: id
63
- }), config.credential, fetchFn, signal);
67
+ // Bulk-listing message fetch (fetchAll/fetchChanges) — delegates to the
68
+ // shared api.ts getMessage so both the bulk and targeted (fetchEntity)
69
+ // paths share one 404-tagging choke point.
70
+ async function getMessageResource(id, signal) {
71
+ return getMessage({
72
+ accessToken: config.credential.accessToken,
73
+ messageID: id,
74
+ fetch: fetchFn,
75
+ signal
76
+ });
64
77
  }
65
78
  async function profileHistoryId(signal) {
66
79
  const profile = await fetchJSON(buildProfileURL(), config.credential, fetchFn, signal);
@@ -71,9 +84,14 @@ export function createGoogleMailProvider(config) {
71
84
  // create/edit) but carries id, name, type, and the visibility fields.
72
85
  async function labelBatch(signal) {
73
86
  const data = await fetchJSON(buildLabelsListURL(), config.credential, fetchFn, signal);
87
+ const sourceFetchHLC = mintFetchHLC();
74
88
  return {
75
89
  modelID: LABEL_MODEL_ID,
76
- entities: (data.labels ?? []).map(mapLabelToEntity),
90
+ entities: (data.labels ?? []).map((label)=>({
91
+ present: true,
92
+ record: mapLabelToEntity(label),
93
+ sourceFetchHLC
94
+ })),
77
95
  hasMore: true
78
96
  };
79
97
  }
@@ -87,7 +105,7 @@ export function createGoogleMailProvider(config) {
87
105
  const ids = await listMessageIDs(query, params.signal);
88
106
  const messages = [];
89
107
  for (const { id } of ids){
90
- messages.push(await getMessage(id, params.signal));
108
+ messages.push(await getMessageResource(id, params.signal));
91
109
  }
92
110
  // Thread batch: one thread per distinct threadId, titled by its first message's subject.
93
111
  const threadSubjects = new Map();
@@ -96,11 +114,16 @@ export function createGoogleMailProvider(config) {
96
114
  threadSubjects.set(m.threadId, getHeader(m.payload?.headers, 'Subject'));
97
115
  }
98
116
  }
117
+ const threadToken = mintFetchHLC();
99
118
  yield {
100
119
  modelID: THREAD_MODEL_ID,
101
120
  entities: [
102
121
  ...threadSubjects
103
- ].map(([threadId, subject])=>mapThreadToEntity(threadId, subject)),
122
+ ].map(([threadId, subject])=>({
123
+ present: true,
124
+ record: mapThreadToEntity(threadId, subject),
125
+ sourceFetchHLC: threadToken
126
+ })),
104
127
  hasMore: true
105
128
  };
106
129
  const checkpoint = {
@@ -109,9 +132,14 @@ export function createGoogleMailProvider(config) {
109
132
  windowStart
110
133
  } : {}
111
134
  };
135
+ const messageToken = mintFetchHLC();
112
136
  yield {
113
137
  modelID: MESSAGE_MODEL_ID,
114
- entities: messages.map(mapMessageToEntity),
138
+ entities: messages.map((message)=>({
139
+ present: true,
140
+ record: mapMessageToEntity(message),
141
+ sourceFetchHLC: messageToken
142
+ })),
115
143
  hasMore: false,
116
144
  checkpoint
117
145
  };
@@ -167,7 +195,7 @@ export function createGoogleMailProvider(config) {
167
195
  ].filter((id)=>!deletedIDs.has(id));
168
196
  const messages = [];
169
197
  for (const id of survivingIDs){
170
- messages.push(await getMessage(id, params.signal));
198
+ messages.push(await getMessageResource(id, params.signal));
171
199
  }
172
200
  const threadSubjects = new Map();
173
201
  for (const m of messages){
@@ -175,21 +203,32 @@ export function createGoogleMailProvider(config) {
175
203
  threadSubjects.set(m.threadId, getHeader(m.payload?.headers, 'Subject'));
176
204
  }
177
205
  }
206
+ const threadToken = mintFetchHLC();
178
207
  yield {
179
208
  modelID: THREAD_MODEL_ID,
180
209
  entities: [
181
210
  ...threadSubjects
182
- ].map(([threadId, subject])=>mapThreadToEntity(threadId, subject)),
211
+ ].map(([threadId, subject])=>({
212
+ present: true,
213
+ record: mapThreadToEntity(threadId, subject),
214
+ sourceFetchHLC: threadToken
215
+ })),
183
216
  hasMore: true
184
217
  };
218
+ const messageToken = mintFetchHLC();
185
219
  yield {
186
220
  modelID: MESSAGE_MODEL_ID,
187
- entities: messages.map(mapMessageToEntity),
221
+ entities: messages.map((message)=>({
222
+ present: true,
223
+ record: mapMessageToEntity(message),
224
+ sourceFetchHLC: messageToken
225
+ })),
188
226
  deleted: [
189
227
  ...deletedIDs
190
228
  ].map((id)=>({
191
- sourceService: 'google-mail',
192
- sourceID: id
229
+ present: false,
230
+ sourceID: id,
231
+ sourceFetchHLC: messageToken
193
232
  })),
194
233
  hasMore: false,
195
234
  checkpoint: {
@@ -200,11 +239,76 @@ export function createGoogleMailProvider(config) {
200
239
  }
201
240
  };
202
241
  }
242
+ // Targeted single-entity fetch (anti-entropy repair verification / write-back
243
+ // recovery). Mints its own fetch-order token at request start, independent of
244
+ // any batch token. `model` distinguishes the three GoogleMail* resources —
245
+ // Thread uses a real threads.get (not the bulk-listing subject synthesis
246
+ // fetchAll/fetchChanges use); Label uses labels.get (carries color, unlike
247
+ // the list endpoint labelBatch uses).
248
+ async function fetchEntity(model, sourceID) {
249
+ const sourceFetchHLC = mintFetchHLC();
250
+ try {
251
+ if (model === THREAD_MODEL_ID) {
252
+ const thread = await getThread({
253
+ accessToken: config.credential.accessToken,
254
+ threadID: sourceID,
255
+ fetch: fetchFn
256
+ });
257
+ const first = thread.messages?.[0];
258
+ const subject = first != null ? getHeader(first.payload?.headers, 'Subject') : undefined;
259
+ return {
260
+ present: true,
261
+ record: mapThreadToEntity(thread.id, subject),
262
+ sourceFetchHLC
263
+ };
264
+ }
265
+ if (model === LABEL_MODEL_ID) {
266
+ const label = await getLabel({
267
+ accessToken: config.credential.accessToken,
268
+ labelID: sourceID,
269
+ fetch: fetchFn
270
+ });
271
+ return {
272
+ present: true,
273
+ record: mapLabelToEntity(label),
274
+ sourceFetchHLC
275
+ };
276
+ }
277
+ const message = await getMessage({
278
+ accessToken: config.credential.accessToken,
279
+ messageID: sourceID,
280
+ fetch: fetchFn
281
+ });
282
+ return {
283
+ present: true,
284
+ record: mapMessageToEntity(message),
285
+ sourceFetchHLC
286
+ };
287
+ } catch (err) {
288
+ if (isNotFound(err)) {
289
+ return {
290
+ present: false,
291
+ sourceID,
292
+ sourceFetchHLC
293
+ };
294
+ }
295
+ throw err;
296
+ }
297
+ }
298
+ // Provider-authoritative re-read for write-back recovery. Gmail's
299
+ // resources carry no dependable ETag on a direct get, so this mirrors
300
+ // fetchEntity without a providerETag — present for contract parity with the
301
+ // other connectors' reconcile.
302
+ async function reconcile(model, sourceID) {
303
+ return fetchEntity(model, sourceID);
304
+ }
203
305
  return {
204
306
  supportedStrategies: [
205
307
  'on-demand'
206
308
  ],
207
309
  fetchAll: fetchAllEntities,
208
- fetchChanges: fetchChangesEntities
310
+ fetchChanges: fetchChangesEntities,
311
+ fetchEntity,
312
+ reconcile
209
313
  };
210
314
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/connector-google-mail",
3
- "version": "0.17.1",
3
+ "version": "0.17.2",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -15,10 +15,11 @@
15
15
  "LICENSE.md"
16
16
  ],
17
17
  "dependencies": {
18
- "@kubun/connector": "^0.17.1",
19
- "@kubun/credential-types": "^0.17.0",
18
+ "@kubun/connector": "^0.17.2",
19
+ "@kubun/credential-types": "^0.17.2",
20
+ "@kubun/hlc": "^0.17.0",
20
21
  "@kubun/models": "^0.17.0",
21
- "@kubun/protocol": "^0.17.0",
22
+ "@kubun/protocol": "^0.17.1",
22
23
  "@kubun/provider-google": "^0.17.0",
23
24
  "@kubun/scalars": "^0.17.0",
24
25
  "@sozai/codec": "^0.4.0"