@kubun/connector-google-mail 0.17.2 → 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/api.d.ts CHANGED
@@ -1,6 +1,7 @@
1
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;
package/lib/api.js CHANGED
@@ -1,22 +1,14 @@
1
1
  import { connectorHTTPError, markWriteSent } from '@kubun/connector';
2
+ import { createGoogleAPIClient } from '@kubun/provider-google';
2
3
  import { b64uFromUTF, b64uToUTF, fromB64U } from '@sozai/codec';
3
4
  export const MAIL_BASE_URL = 'https://gmail.googleapis.com/gmail/v1';
4
- export async function parseGoogleError(response) {
5
- try {
6
- const body = await response.json();
7
- return body.error?.message ?? `${response.status} ${response.statusText}`;
8
- } catch {
9
- return `${response.status} ${response.statusText}`;
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);
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
+ });
20
12
  }
21
13
  export function decodeBase64Url(data) {
22
14
  return b64uToUTF(data);
@@ -147,52 +139,34 @@ export function mapMessageToEntity(message) {
147
139
  };
148
140
  }
149
141
  // 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.
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.
152
144
  export async function getMessage(config) {
153
- const fetchFn = config.fetch ?? globalThis.fetch;
154
- const response = await fetchFn(buildMessageGetURL({
145
+ return mailAPIClient(config.accessToken, config.fetch).get(buildMessageGetURL({
155
146
  messageID: config.messageID
156
147
  }), {
157
- headers: {
158
- Authorization: `Bearer ${config.accessToken}`
159
- },
160
148
  signal: config.signal
161
149
  });
162
- if (!response.ok) throw await googleMailAPIError(response);
163
- return await response.json();
164
150
  }
165
151
  // Targeted single-thread fetch — a real threads.get(format=full) read, mapped
166
152
  // from its own resource (id + constituent messages), never synthesized from a
167
153
  // bulk message listing's locally-scanned subject.
168
154
  export async function getThread(config) {
169
- const fetchFn = config.fetch ?? globalThis.fetch;
170
- const response = await fetchFn(buildThreadGetURL({
155
+ return mailAPIClient(config.accessToken, config.fetch).get(buildThreadGetURL({
171
156
  threadID: config.threadID
172
157
  }), {
173
- headers: {
174
- Authorization: `Bearer ${config.accessToken}`
175
- },
176
158
  signal: config.signal
177
159
  });
178
- if (!response.ok) throw await googleMailAPIError(response);
179
- return await response.json();
180
160
  }
181
161
  // Targeted single-label fetch. Unlike labels.list (buildLabelsListURL, used by
182
162
  // the bulk labelBatch), a direct labels.get returns the full resource
183
163
  // including `color` — the list endpoint omits it.
184
164
  export async function getLabel(config) {
185
- const fetchFn = config.fetch ?? globalThis.fetch;
186
- const response = await fetchFn(buildLabelGetURL({
165
+ return mailAPIClient(config.accessToken, config.fetch).get(buildLabelGetURL({
187
166
  labelID: config.labelID
188
167
  }), {
189
- headers: {
190
- Authorization: `Bearer ${config.accessToken}`
191
- },
192
168
  signal: config.signal
193
169
  });
194
- if (!response.ok) throw await googleMailAPIError(response);
195
- return await response.json();
196
170
  }
197
171
  function streamOfBytes(bytes) {
198
172
  return new ReadableStream({
@@ -215,17 +189,10 @@ export async function importMessageAttachment(config) {
215
189
  if (target == null) {
216
190
  throw new Error(`Google Mail: no attachment with providerRef "${config.providerRef}"`);
217
191
  }
218
- const fetchFn = config.fetch ?? globalThis.fetch;
219
- const response = await fetchFn(buildAttachmentGetURL({
192
+ const body = await mailAPIClient(config.accessToken, config.fetch).get(buildAttachmentGetURL({
220
193
  messageID: config.messageID,
221
194
  attachmentID: config.providerRef
222
- }), {
223
- headers: {
224
- Authorization: `Bearer ${config.accessToken}`
225
- }
226
- });
227
- if (!response.ok) throw await googleMailAPIError(response);
228
- const body = await response.json();
195
+ }));
229
196
  if (body.data == null) throw new Error('Google Mail: attachment response has no data');
230
197
  const bytes = decodeBase64UrlBytes(body.data);
231
198
  const attachmentID = await config.writeAttachment(streamOfBytes(bytes), {
@@ -301,22 +268,16 @@ function requireString(params, key) {
301
268
  async function readBackWrittenMessage(config) {
302
269
  try {
303
270
  const messageID = config.messageIDOf(await config.written.json());
304
- const fetchFn = config.fetch ?? globalThis.fetch;
305
- const response = await fetchFn(buildMessageGetURL({
271
+ const message = await mailAPIClient(config.accessToken, config.fetch).get(buildMessageGetURL({
306
272
  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());
273
+ }));
274
+ return mapMessageToEntity(message);
314
275
  } catch (err) {
315
276
  throw markWriteSent(err);
316
277
  }
317
278
  }
318
279
  export async function sendMessage(config) {
319
- const fetchFn = config.fetch ?? globalThis.fetch;
280
+ const client = mailAPIClient(config.accessToken, config.fetch);
320
281
  const raw = buildRawMessage({
321
282
  to: config.params.to,
322
283
  cc: config.params.cc,
@@ -324,17 +285,13 @@ export async function sendMessage(config) {
324
285
  subject: requireString(config.params, 'subject'),
325
286
  body: requireString(config.params, 'body')
326
287
  });
327
- const response = await fetchFn(`${MAIL_BASE_URL}/users/me/messages/send`, {
288
+ const response = await client.request(`${MAIL_BASE_URL}/users/me/messages/send`, {
328
289
  method: 'POST',
329
- headers: {
330
- Authorization: `Bearer ${config.accessToken}`,
331
- 'Content-Type': 'application/json'
332
- },
333
- body: JSON.stringify({
290
+ body: {
334
291
  raw
335
- })
292
+ }
336
293
  });
337
- if (!response.ok) throw await googleMailAPIError(response);
294
+ if (!response.ok) return client.fail(response);
338
295
  return readBackWrittenMessage({
339
296
  accessToken: config.accessToken,
340
297
  written: response,
@@ -343,7 +300,7 @@ export async function sendMessage(config) {
343
300
  });
344
301
  }
345
302
  export async function createDraft(config) {
346
- const fetchFn = config.fetch ?? globalThis.fetch;
303
+ const client = mailAPIClient(config.accessToken, config.fetch);
347
304
  const raw = buildRawMessage({
348
305
  to: config.params.to,
349
306
  cc: config.params.cc,
@@ -351,19 +308,15 @@ export async function createDraft(config) {
351
308
  subject: requireString(config.params, 'subject'),
352
309
  body: requireString(config.params, 'body')
353
310
  });
354
- const response = await fetchFn(`${MAIL_BASE_URL}/users/me/drafts`, {
311
+ const response = await client.request(`${MAIL_BASE_URL}/users/me/drafts`, {
355
312
  method: 'POST',
356
- headers: {
357
- Authorization: `Bearer ${config.accessToken}`,
358
- 'Content-Type': 'application/json'
359
- },
360
- body: JSON.stringify({
313
+ body: {
361
314
  message: {
362
315
  raw
363
316
  }
364
- })
317
+ }
365
318
  });
366
- if (!response.ok) throw await googleMailAPIError(response);
319
+ if (!response.ok) return client.fail(response);
367
320
  return readBackWrittenMessage({
368
321
  accessToken: config.accessToken,
369
322
  written: response,
@@ -372,20 +325,16 @@ export async function createDraft(config) {
372
325
  });
373
326
  }
374
327
  export async function modifyLabels(config) {
375
- const fetchFn = config.fetch ?? globalThis.fetch;
328
+ const client = mailAPIClient(config.accessToken, config.fetch);
376
329
  const url = `${MAIL_BASE_URL}/users/me/messages/${encodeURIComponent(config.messageID)}/modify`;
377
- const response = await fetchFn(url, {
330
+ const response = await client.request(url, {
378
331
  method: 'POST',
379
- headers: {
380
- Authorization: `Bearer ${config.accessToken}`,
381
- 'Content-Type': 'application/json'
382
- },
383
- body: JSON.stringify({
332
+ body: {
384
333
  addLabelIds: config.addLabelIds ?? [],
385
334
  removeLabelIds: config.removeLabelIds ?? []
386
- })
335
+ }
387
336
  });
388
- if (!response.ok) throw await googleMailAPIError(response);
337
+ if (!response.ok) return client.fail(response);
389
338
  return readBackWrittenMessage({
390
339
  accessToken: config.accessToken,
391
340
  written: response,
@@ -414,31 +363,19 @@ function buildLabelBody(params) {
414
363
  return body;
415
364
  }
416
365
  export async function createLabel(config) {
417
- const fetchFn = config.fetch ?? globalThis.fetch;
418
366
  const body = buildLabelBody(config.params);
419
367
  if (typeof body.name !== 'string') throw new Error('Google Mail: "name" is required');
420
- const response = await fetchFn(buildLabelsListURL(), {
368
+ const label = await mailAPIClient(config.accessToken, config.fetch).send(buildLabelsListURL(), {
421
369
  method: 'POST',
422
- headers: {
423
- Authorization: `Bearer ${config.accessToken}`,
424
- 'Content-Type': 'application/json'
425
- },
426
- body: JSON.stringify(body)
370
+ body
427
371
  });
428
- if (!response.ok) throw await googleMailAPIError(response);
429
- return mapLabelToEntity(await response.json());
372
+ return mapLabelToEntity(label);
430
373
  }
431
374
  export async function updateLabel(config) {
432
- const fetchFn = config.fetch ?? globalThis.fetch;
433
375
  const url = `${MAIL_BASE_URL}/users/me/labels/${encodeURIComponent(config.labelID)}`;
434
- const response = await fetchFn(url, {
376
+ const label = await mailAPIClient(config.accessToken, config.fetch).send(url, {
435
377
  method: 'PATCH',
436
- headers: {
437
- Authorization: `Bearer ${config.accessToken}`,
438
- 'Content-Type': 'application/json'
439
- },
440
- body: JSON.stringify(buildLabelBody(config.params))
378
+ body: buildLabelBody(config.params)
441
379
  });
442
- if (!response.ok) throw await googleMailAPIError(response);
443
- return mapLabelToEntity(await response.json());
380
+ return mapLabelToEntity(label);
444
381
  }
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,7 +36,7 @@ export const googleMailThreadDefinition = {
52
36
  ],
53
37
  additionalProperties: false,
54
38
  definitions: {
55
- ...commonDefinitions
39
+ ...externalEntityDefinition.schema.definitions
56
40
  }
57
41
  },
58
42
  fieldsMeta: {},
@@ -155,7 +139,7 @@ export const googleMailMessageDefinition = {
155
139
  ],
156
140
  additionalProperties: false,
157
141
  definitions: {
158
- ...commonDefinitions,
142
+ ...externalEntityDefinition.schema.definitions,
159
143
  ThreadDocID: docIDSchema,
160
144
  Attachment: {
161
145
  type: 'object',
@@ -274,7 +258,7 @@ export const googleMailLabelDefinition = {
274
258
  ],
275
259
  additionalProperties: false,
276
260
  definitions: {
277
- ...commonDefinitions
261
+ ...externalEntityDefinition.schema.definitions
278
262
  }
279
263
  },
280
264
  fieldsMeta: {},
package/lib/provider.js CHANGED
@@ -1,32 +1,18 @@
1
- import { resolveModelID } from '@kubun/connector';
2
- import { buildHistoryListURL, buildLabelsListURL, buildMessagesListURL, buildProfileURL, getHeader, getLabel, getMessage, getThread, 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,21 +30,18 @@ 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);
47
34
  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
- }
54
35
  async function listMessageIDs(query, signal) {
55
36
  const ids = [];
56
37
  let pageToken;
57
38
  do {
58
- const data = await fetchJSON(buildMessagesListURL({
39
+ const data = await client.get(buildMessagesListURL({
59
40
  query,
60
41
  pageToken
61
- }), config.credential, fetchFn, signal);
42
+ }), {
43
+ signal
44
+ });
62
45
  for (const m of data.messages ?? [])ids.push(m);
63
46
  pageToken = data.nextPageToken;
64
47
  }while (pageToken != null)
@@ -76,14 +59,18 @@ export function createGoogleMailProvider(config) {
76
59
  });
77
60
  }
78
61
  async function profileHistoryId(signal) {
79
- const profile = await fetchJSON(buildProfileURL(), config.credential, fetchFn, signal);
62
+ const profile = await client.get(buildProfileURL(), {
63
+ signal
64
+ });
80
65
  return profile.historyId ?? '0';
81
66
  }
82
67
  // Labels have no incremental history API, so every sync re-lists them in full.
83
68
  // The list endpoint is a single cheap call; it omits color (captured only on
84
69
  // create/edit) but carries id, name, type, and the visibility fields.
85
70
  async function labelBatch(signal) {
86
- const data = await fetchJSON(buildLabelsListURL(), config.credential, fetchFn, signal);
71
+ const data = await client.get(buildLabelsListURL(), {
72
+ signal
73
+ });
87
74
  const sourceFetchHLC = mintFetchHLC();
88
75
  return {
89
76
  modelID: LABEL_MODEL_ID,
@@ -157,22 +144,18 @@ export function createGoogleMailProvider(config) {
157
144
  let latestHistoryId = checkpoint.historyId;
158
145
  let pageToken;
159
146
  do {
160
- const response = await fetchFn(buildHistoryListURL({
147
+ const response = await client.request(buildHistoryListURL({
161
148
  startHistoryId: checkpoint.historyId,
162
149
  pageToken
163
150
  }), {
164
- headers: {
165
- Authorization: `Bearer ${config.credential.accessToken}`
166
- },
151
+ method: 'GET',
167
152
  signal: params.signal
168
153
  });
169
154
  if (response.status === 404) {
170
155
  yield* fetchAllEntities(params);
171
156
  return;
172
157
  }
173
- if (!response.ok) {
174
- throw new Error(`Google Mail API error: ${await parseGoogleError(response)}`);
175
- }
158
+ if (!response.ok) await client.fail(response);
176
159
  const data = await response.json();
177
160
  for (const record of data.history ?? []){
178
161
  for (const ref of [
@@ -247,53 +230,42 @@ export function createGoogleMailProvider(config) {
247
230
  // the list endpoint labelBatch uses).
248
231
  async function fetchEntity(model, sourceID) {
249
232
  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({
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({
267
260
  accessToken: config.credential.accessToken,
268
- labelID: sourceID,
261
+ messageID: sourceID,
269
262
  fetch: fetchFn
270
263
  });
271
264
  return {
272
- present: true,
273
- record: mapLabelToEntity(label),
274
- sourceFetchHLC
265
+ record: mapMessageToEntity(message)
275
266
  };
276
267
  }
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
- }
268
+ });
297
269
  }
298
270
  // Provider-authoritative re-read for write-back recovery. Gmail's
299
271
  // resources carry no dependable ETag on a direct get, so this mirrors
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/connector-google-mail",
3
- "version": "0.17.2",
3
+ "version": "0.17.3",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -15,12 +15,12 @@
15
15
  "LICENSE.md"
16
16
  ],
17
17
  "dependencies": {
18
- "@kubun/connector": "^0.17.2",
18
+ "@kubun/connector": "^0.17.3",
19
19
  "@kubun/credential-types": "^0.17.2",
20
20
  "@kubun/hlc": "^0.17.0",
21
21
  "@kubun/models": "^0.17.0",
22
- "@kubun/protocol": "^0.17.1",
23
- "@kubun/provider-google": "^0.17.0",
22
+ "@kubun/protocol": "^0.17.2",
23
+ "@kubun/provider-google": "^0.17.1",
24
24
  "@kubun/scalars": "^0.17.0",
25
25
  "@sozai/codec": "^0.4.0"
26
26
  },