@kubun/connector-google-mail 0.17.2 → 0.18.0

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
@@ -1,4 +1,4 @@
1
- import type { ActionHandler, WriteAttachment } from '@kubun/connector';
1
+ import { type ActionHandler, type WriteAttachment } from '@kubun/connector';
2
2
  export type GoogleMailActionHandlerConfig = {
3
3
  credential: {
4
4
  accessToken: string;
package/lib/actions.js CHANGED
@@ -1,14 +1,16 @@
1
+ import { carryField, refuseCutLists } from '@kubun/connector';
1
2
  import { createDraft, createLabel, importMessageAttachment, modifyLabels, sendMessage, updateLabel } from './api.js';
2
3
  import { GoogleMailError } from './errors.js';
3
4
  // Gmail takes a label color only as the textColor/backgroundColor pair, so a
4
5
  // write that changes one of them carries the other from the target document.
5
6
  // 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) {
7
+ // rejected before any request, rather than sent without the change. A
8
+ // companion trimmed at sync is refused too (`carryField`), never sent.
9
+ function withColorPair(params, existing, context) {
8
10
  const changesOne = 'textColor' in params !== 'backgroundColor' in params;
9
11
  if (!changesOne) return params;
10
12
  const missing = 'textColor' in params ? 'backgroundColor' : 'textColor';
11
- const companion = existing?.[missing];
13
+ const companion = carryField(existing, missing, context);
12
14
  if (typeof companion !== 'string' || companion === '') {
13
15
  throw new GoogleMailError(`Google Mail: a label color needs both textColor and backgroundColor, and the label has no ${missing} to keep`, {
14
16
  kind: 'rejected'
@@ -58,12 +60,13 @@ export function createGoogleMailActionHandler(config) {
58
60
  sourceFetchHLC: mintActionFetchHLC()
59
61
  };
60
62
  },
61
- async update (model, sourceID, params, existing) {
63
+ async update (model, sourceID, params, existing, context) {
64
+ refuseCutLists(params, context);
62
65
  if (model === 'GoogleMailLabel') {
63
66
  const record = await updateLabel({
64
67
  accessToken: config.credential.accessToken,
65
68
  labelID: sourceID,
66
- params: withColorPair(params, existing),
69
+ params: withColorPair(params, existing, context),
67
70
  fetch: config.fetch
68
71
  });
69
72
  return {
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;
@@ -102,6 +103,17 @@ export declare function getHeader(headers: Array<{
102
103
  }> | undefined, name: string): string | undefined;
103
104
  export declare function extractEmail(headerValue: string): string;
104
105
  export declare function extractEmails(headerValue: string | undefined): Array<string>;
106
+ export type MailParty = {
107
+ name?: string;
108
+ email?: string;
109
+ };
110
+ /**
111
+ * Parses one mailbox out of a header value: `"Alice A" <alice@example.com>`,
112
+ * `Alice A <alice@example.com>`, or a bare `alice@example.com`. A value that
113
+ * names no address at all -- malformed or empty -- returns `{}` rather than
114
+ * a name with no email, or an email this parser only guessed at.
115
+ */
116
+ export declare function parseMailParty(value: string): MailParty;
105
117
  export declare function mapMessageToEntity(message: GmailMessageResource): EntityRecord;
106
118
  export declare function getMessage(config: {
107
119
  accessToken: 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);
@@ -86,6 +78,93 @@ export function extractEmails(headerValue) {
86
78
  if (headerValue == null || headerValue.trim() === '') return [];
87
79
  return headerValue.split(',').map((part)=>extractEmail(part));
88
80
  }
81
+ // A mailbox is either `Display Name <addr@example.com>` (the display name
82
+ // optionally quoted) or a bare `addr@example.com`. This is a liberal but
83
+ // bounded check for "looks like an email address" -- no whitespace, angle
84
+ // bracket or quote characters around a single '@', with at least one '.' in
85
+ // the domain -- never a full RFC 5322 grammar. Gmail headers are already
86
+ // provider-validated, so this only guards against a header this parser
87
+ // cannot make sense of; it never invents an address that is not there.
88
+ const MAILBOX_WITH_NAME = /^([^<>]*)<([^<>]*)>\s*$/;
89
+ const PLAUSIBLE_EMAIL = /^[^\s<>"@]+@[^\s<>"@]+\.[^\s<>"@]+$/;
90
+ function stripQuotes(value) {
91
+ const trimmed = value.trim();
92
+ const match = /^"(.*)"$/.exec(trimmed);
93
+ return match?.[1]?.replace(/\\(["\\])/g, '$1') ?? trimmed;
94
+ }
95
+ /**
96
+ * Parses one mailbox out of a header value: `"Alice A" <alice@example.com>`,
97
+ * `Alice A <alice@example.com>`, or a bare `alice@example.com`. A value that
98
+ * names no address at all -- malformed or empty -- returns `{}` rather than
99
+ * a name with no email, or an email this parser only guessed at.
100
+ */ export function parseMailParty(value) {
101
+ const trimmed = value.trim();
102
+ if (trimmed === '') return {};
103
+ const withName = MAILBOX_WITH_NAME.exec(trimmed);
104
+ if (withName != null) {
105
+ const email = (withName[2] ?? '').trim();
106
+ if (!PLAUSIBLE_EMAIL.test(email)) return {};
107
+ const name = stripQuotes(withName[1] ?? '');
108
+ return name === '' ? {
109
+ email
110
+ } : {
111
+ name,
112
+ email
113
+ };
114
+ }
115
+ return PLAUSIBLE_EMAIL.test(trimmed) ? {
116
+ email: trimmed
117
+ } : {};
118
+ }
119
+ // Split only at mailbox boundaries outside quoted names and angle addresses.
120
+ // A group label is not a mailbox; its comma-separated members are.
121
+ function splitMailboxes(headerValue) {
122
+ const parts = [];
123
+ let current = '';
124
+ let inQuotes = false;
125
+ let inAngle = false;
126
+ let inGroup = false;
127
+ const push = ()=>{
128
+ if (current.trim() !== '') parts.push(current.trim());
129
+ current = '';
130
+ };
131
+ for(let i = 0; i < headerValue.length; i++){
132
+ const char = headerValue[i];
133
+ if (char === '\\' && inQuotes && i + 1 < headerValue.length) {
134
+ current += char + headerValue[++i];
135
+ continue;
136
+ }
137
+ if (char === '"' && !inAngle) inQuotes = !inQuotes;
138
+ if (!inQuotes && char === '<') inAngle = true;
139
+ if (!inQuotes && char === '>') inAngle = false;
140
+ if (!inQuotes && !inAngle) {
141
+ if (char === ':' && !inGroup && /^[^@<>:;]+$/.test(current.trim())) {
142
+ current = '';
143
+ inGroup = true;
144
+ continue;
145
+ }
146
+ if (char === ',' || char === ';' && inGroup) {
147
+ push();
148
+ if (char === ';') inGroup = false;
149
+ continue;
150
+ }
151
+ }
152
+ current += char;
153
+ if (char === '>' && !inQuotes) {
154
+ const next = headerValue.slice(i + 1).trimStart();
155
+ if (next.startsWith('<')) push();
156
+ }
157
+ }
158
+ push();
159
+ return parts;
160
+ }
161
+ function parseMailPartyList(headerValue) {
162
+ if (headerValue == null || headerValue.trim() === '') return [];
163
+ return splitMailboxes(headerValue).map(parseMailParty).filter((party)=>party.name != null || party.email != null);
164
+ }
165
+ function nonEmptyPartyList(parties) {
166
+ return parties.length === 0 ? undefined : parties;
167
+ }
89
168
  function findPartByMime(part, mime) {
90
169
  if (part.mimeType === mime && part.body?.data != null) return part;
91
170
  for (const child of part.parts ?? []){
@@ -125,16 +204,19 @@ export function mapMessageToEntity(message) {
125
204
  const headers = message.payload?.headers;
126
205
  const labels = message.labelIds ?? [];
127
206
  const sentAtMs = message.internalDate != null ? Number.parseInt(message.internalDate, 10) : Number.NaN;
207
+ const from = parseMailParty(getHeader(headers, 'From') ?? '');
128
208
  return {
129
209
  sourceService: 'google-mail',
130
210
  sourceID: message.id,
131
211
  sourceURL: `https://mail.google.com/mail/u/0/#inbox/${message.id}`,
132
212
  body: extractBody(message.payload),
133
- sender: extractEmail(getHeader(headers, 'From') ?? ''),
213
+ // A message with no From header, or one this parser cannot make sense
214
+ // of, carries no `from` rather than an empty Party.
215
+ from: from.name != null || from.email != null ? from : undefined,
134
216
  subject: getHeader(headers, 'Subject'),
135
217
  snippet: message.snippet,
136
- to: extractEmails(getHeader(headers, 'To')),
137
- cc: extractEmails(getHeader(headers, 'Cc')),
218
+ to: nonEmptyPartyList(parseMailPartyList(getHeader(headers, 'To'))),
219
+ cc: nonEmptyPartyList(parseMailPartyList(getHeader(headers, 'Cc'))),
138
220
  labels,
139
221
  unread: labels.includes('UNREAD'),
140
222
  sentAt: Number.isNaN(sentAtMs) ? undefined : new Date(sentAtMs).toISOString(),
@@ -147,52 +229,34 @@ export function mapMessageToEntity(message) {
147
229
  };
148
230
  }
149
231
  // 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.
232
+ // recovery's fetchEntity+reconcile). A 404 here is the deletion signal:
233
+ // fetchOrAbsent turns it into a `present: false` result instead of a throw.
152
234
  export async function getMessage(config) {
153
- const fetchFn = config.fetch ?? globalThis.fetch;
154
- const response = await fetchFn(buildMessageGetURL({
235
+ return mailAPIClient(config.accessToken, config.fetch).get(buildMessageGetURL({
155
236
  messageID: config.messageID
156
237
  }), {
157
- headers: {
158
- Authorization: `Bearer ${config.accessToken}`
159
- },
160
238
  signal: config.signal
161
239
  });
162
- if (!response.ok) throw await googleMailAPIError(response);
163
- return await response.json();
164
240
  }
165
241
  // Targeted single-thread fetch — a real threads.get(format=full) read, mapped
166
242
  // from its own resource (id + constituent messages), never synthesized from a
167
243
  // bulk message listing's locally-scanned subject.
168
244
  export async function getThread(config) {
169
- const fetchFn = config.fetch ?? globalThis.fetch;
170
- const response = await fetchFn(buildThreadGetURL({
245
+ return mailAPIClient(config.accessToken, config.fetch).get(buildThreadGetURL({
171
246
  threadID: config.threadID
172
247
  }), {
173
- headers: {
174
- Authorization: `Bearer ${config.accessToken}`
175
- },
176
248
  signal: config.signal
177
249
  });
178
- if (!response.ok) throw await googleMailAPIError(response);
179
- return await response.json();
180
250
  }
181
251
  // Targeted single-label fetch. Unlike labels.list (buildLabelsListURL, used by
182
252
  // the bulk labelBatch), a direct labels.get returns the full resource
183
253
  // including `color` — the list endpoint omits it.
184
254
  export async function getLabel(config) {
185
- const fetchFn = config.fetch ?? globalThis.fetch;
186
- const response = await fetchFn(buildLabelGetURL({
255
+ return mailAPIClient(config.accessToken, config.fetch).get(buildLabelGetURL({
187
256
  labelID: config.labelID
188
257
  }), {
189
- headers: {
190
- Authorization: `Bearer ${config.accessToken}`
191
- },
192
258
  signal: config.signal
193
259
  });
194
- if (!response.ok) throw await googleMailAPIError(response);
195
- return await response.json();
196
260
  }
197
261
  function streamOfBytes(bytes) {
198
262
  return new ReadableStream({
@@ -215,17 +279,10 @@ export async function importMessageAttachment(config) {
215
279
  if (target == null) {
216
280
  throw new Error(`Google Mail: no attachment with providerRef "${config.providerRef}"`);
217
281
  }
218
- const fetchFn = config.fetch ?? globalThis.fetch;
219
- const response = await fetchFn(buildAttachmentGetURL({
282
+ const body = await mailAPIClient(config.accessToken, config.fetch).get(buildAttachmentGetURL({
220
283
  messageID: config.messageID,
221
284
  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();
285
+ }));
229
286
  if (body.data == null) throw new Error('Google Mail: attachment response has no data');
230
287
  const bytes = decodeBase64UrlBytes(body.data);
231
288
  const attachmentID = await config.writeAttachment(streamOfBytes(bytes), {
@@ -295,28 +352,22 @@ function requireString(params, key) {
295
352
  }
296
353
  // Gmail's write endpoints (messages.send, drafts.create, messages.modify) return only
297
354
  // { id, threadId, labelIds } -- no payload/headers/body. Re-fetch the full message so the
298
- // mapped entity carries real sender/body/subject/etc. instead of empty placeholders.
355
+ // mapped entity carries real from/body/subject/etc. instead of empty placeholders.
299
356
  // Everything here runs after the write went out, so any failure is marked sent: the
300
357
  // write may have landed, whatever the read-back did.
301
358
  async function readBackWrittenMessage(config) {
302
359
  try {
303
360
  const messageID = config.messageIDOf(await config.written.json());
304
- const fetchFn = config.fetch ?? globalThis.fetch;
305
- const response = await fetchFn(buildMessageGetURL({
361
+ const message = await mailAPIClient(config.accessToken, config.fetch).get(buildMessageGetURL({
306
362
  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());
363
+ }));
364
+ return mapMessageToEntity(message);
314
365
  } catch (err) {
315
366
  throw markWriteSent(err);
316
367
  }
317
368
  }
318
369
  export async function sendMessage(config) {
319
- const fetchFn = config.fetch ?? globalThis.fetch;
370
+ const client = mailAPIClient(config.accessToken, config.fetch);
320
371
  const raw = buildRawMessage({
321
372
  to: config.params.to,
322
373
  cc: config.params.cc,
@@ -324,17 +375,13 @@ export async function sendMessage(config) {
324
375
  subject: requireString(config.params, 'subject'),
325
376
  body: requireString(config.params, 'body')
326
377
  });
327
- const response = await fetchFn(`${MAIL_BASE_URL}/users/me/messages/send`, {
378
+ const response = await client.request(`${MAIL_BASE_URL}/users/me/messages/send`, {
328
379
  method: 'POST',
329
- headers: {
330
- Authorization: `Bearer ${config.accessToken}`,
331
- 'Content-Type': 'application/json'
332
- },
333
- body: JSON.stringify({
380
+ body: {
334
381
  raw
335
- })
382
+ }
336
383
  });
337
- if (!response.ok) throw await googleMailAPIError(response);
384
+ if (!response.ok) return client.fail(response);
338
385
  return readBackWrittenMessage({
339
386
  accessToken: config.accessToken,
340
387
  written: response,
@@ -343,7 +390,7 @@ export async function sendMessage(config) {
343
390
  });
344
391
  }
345
392
  export async function createDraft(config) {
346
- const fetchFn = config.fetch ?? globalThis.fetch;
393
+ const client = mailAPIClient(config.accessToken, config.fetch);
347
394
  const raw = buildRawMessage({
348
395
  to: config.params.to,
349
396
  cc: config.params.cc,
@@ -351,19 +398,15 @@ export async function createDraft(config) {
351
398
  subject: requireString(config.params, 'subject'),
352
399
  body: requireString(config.params, 'body')
353
400
  });
354
- const response = await fetchFn(`${MAIL_BASE_URL}/users/me/drafts`, {
401
+ const response = await client.request(`${MAIL_BASE_URL}/users/me/drafts`, {
355
402
  method: 'POST',
356
- headers: {
357
- Authorization: `Bearer ${config.accessToken}`,
358
- 'Content-Type': 'application/json'
359
- },
360
- body: JSON.stringify({
403
+ body: {
361
404
  message: {
362
405
  raw
363
406
  }
364
- })
407
+ }
365
408
  });
366
- if (!response.ok) throw await googleMailAPIError(response);
409
+ if (!response.ok) return client.fail(response);
367
410
  return readBackWrittenMessage({
368
411
  accessToken: config.accessToken,
369
412
  written: response,
@@ -372,20 +415,16 @@ export async function createDraft(config) {
372
415
  });
373
416
  }
374
417
  export async function modifyLabels(config) {
375
- const fetchFn = config.fetch ?? globalThis.fetch;
418
+ const client = mailAPIClient(config.accessToken, config.fetch);
376
419
  const url = `${MAIL_BASE_URL}/users/me/messages/${encodeURIComponent(config.messageID)}/modify`;
377
- const response = await fetchFn(url, {
420
+ const response = await client.request(url, {
378
421
  method: 'POST',
379
- headers: {
380
- Authorization: `Bearer ${config.accessToken}`,
381
- 'Content-Type': 'application/json'
382
- },
383
- body: JSON.stringify({
422
+ body: {
384
423
  addLabelIds: config.addLabelIds ?? [],
385
424
  removeLabelIds: config.removeLabelIds ?? []
386
- })
425
+ }
387
426
  });
388
- if (!response.ok) throw await googleMailAPIError(response);
427
+ if (!response.ok) return client.fail(response);
389
428
  return readBackWrittenMessage({
390
429
  accessToken: config.accessToken,
391
430
  written: response,
@@ -414,31 +453,19 @@ function buildLabelBody(params) {
414
453
  return body;
415
454
  }
416
455
  export async function createLabel(config) {
417
- const fetchFn = config.fetch ?? globalThis.fetch;
418
456
  const body = buildLabelBody(config.params);
419
457
  if (typeof body.name !== 'string') throw new Error('Google Mail: "name" is required');
420
- const response = await fetchFn(buildLabelsListURL(), {
458
+ const label = await mailAPIClient(config.accessToken, config.fetch).send(buildLabelsListURL(), {
421
459
  method: 'POST',
422
- headers: {
423
- Authorization: `Bearer ${config.accessToken}`,
424
- 'Content-Type': 'application/json'
425
- },
426
- body: JSON.stringify(body)
460
+ body
427
461
  });
428
- if (!response.ok) throw await googleMailAPIError(response);
429
- return mapLabelToEntity(await response.json());
462
+ return mapLabelToEntity(label);
430
463
  }
431
464
  export async function updateLabel(config) {
432
- const fetchFn = config.fetch ?? globalThis.fetch;
433
465
  const url = `${MAIL_BASE_URL}/users/me/labels/${encodeURIComponent(config.labelID)}`;
434
- const response = await fetchFn(url, {
466
+ const label = await mailAPIClient(config.accessToken, config.fetch).send(url, {
435
467
  method: 'PATCH',
436
- headers: {
437
- Authorization: `Bearer ${config.accessToken}`,
438
- 'Content-Type': 'application/json'
439
- },
440
- body: JSON.stringify(buildLabelBody(config.params))
468
+ body: buildLabelBody(config.params)
441
469
  });
442
- if (!response.ok) throw await googleMailAPIError(response);
443
- return mapLabelToEntity(await response.json());
470
+ return mapLabelToEntity(label);
444
471
  }
package/lib/model.d.ts CHANGED
@@ -5,10 +5,6 @@ export declare const googleMailThreadDefinition: {
5
5
  readonly schema: {
6
6
  readonly type: "object";
7
7
  readonly properties: {
8
- readonly title: {
9
- readonly type: "string";
10
- readonly maxLength: 200;
11
- };
12
8
  readonly sourceService: {
13
9
  readonly $ref: "#/definitions/SourceService";
14
10
  };
@@ -47,7 +43,7 @@ export declare const googleMailThreadDefinition: {
47
43
  };
48
44
  readonly fieldsMeta: {};
49
45
  readonly providerMergePolicy: {
50
- readonly group: ["title", "sourceService", "sourceID", "sourceURL"];
46
+ readonly group: [...string[], "sourceService", "sourceID", "sourceURL"];
51
47
  };
52
48
  };
53
49
  export declare const googleMailMessageDefinition: {
@@ -57,43 +53,16 @@ export declare const googleMailMessageDefinition: {
57
53
  readonly schema: {
58
54
  readonly type: "object";
59
55
  readonly properties: {
60
- readonly body: {
61
- readonly type: "string";
62
- };
63
- readonly sender: {
64
- readonly type: "string";
65
- readonly minLength: 1;
66
- readonly maxLength: 320;
67
- };
68
- readonly subject: {
69
- readonly type: "string";
70
- };
71
56
  readonly snippet: {
72
57
  readonly type: "string";
73
58
  };
74
- readonly to: {
75
- readonly type: "array";
76
- readonly title: "To";
77
- readonly items: {
78
- readonly type: "string";
79
- };
80
- };
81
- readonly cc: {
82
- readonly type: "array";
83
- readonly title: "Cc";
84
- readonly items: {
85
- readonly type: "string";
86
- };
87
- };
88
59
  readonly labels: {
89
60
  readonly type: "array";
90
61
  readonly title: "Labels";
91
62
  readonly items: {
92
63
  readonly type: "string";
93
64
  };
94
- };
95
- readonly sentAt: {
96
- readonly $ref: "#/definitions/DateTime";
65
+ readonly maxItems: 100;
97
66
  };
98
67
  readonly unread: {
99
68
  readonly type: "boolean";
@@ -104,13 +73,11 @@ export declare const googleMailMessageDefinition: {
104
73
  readonly items: {
105
74
  readonly $ref: "#/definitions/Attachment";
106
75
  };
76
+ readonly maxItems: 100;
107
77
  };
108
78
  readonly threadID: {
109
79
  readonly type: "string";
110
80
  };
111
- readonly thread: {
112
- readonly $ref: "#/definitions/ThreadDocID";
113
- };
114
81
  readonly sourceService: {
115
82
  readonly $ref: "#/definitions/SourceService";
116
83
  };
@@ -124,14 +91,9 @@ export declare const googleMailMessageDefinition: {
124
91
  readonly $ref: "#/definitions/DateTime";
125
92
  };
126
93
  };
127
- readonly required: ["body", "sender", "threadID", "thread", "sourceService", "sourceID", "lastSyncedAt"];
94
+ readonly required: ["threadID", "thread", "sourceService", "sourceID", "lastSyncedAt"];
128
95
  readonly additionalProperties: false;
129
96
  readonly definitions: {
130
- readonly ThreadDocID: {
131
- readonly type: "string";
132
- readonly title: "DocID";
133
- readonly pattern: "^k[0-9a-z]{10,120}$";
134
- };
135
97
  readonly Attachment: {
136
98
  readonly type: "object";
137
99
  readonly title: "Attachment";
@@ -176,14 +138,9 @@ export declare const googleMailMessageDefinition: {
176
138
  readonly DateTime: import("json-schema-typed").JSONSchema.String;
177
139
  };
178
140
  };
179
- readonly fieldsMeta: {
180
- readonly thread: {
181
- readonly type: "document";
182
- readonly model: "#0";
183
- };
184
- };
141
+ readonly fieldsMeta: {};
185
142
  readonly providerMergePolicy: {
186
- readonly group: ["body", "sender", "subject", "snippet", "to", "cc", "labels", "sentAt", "unread", "threadID", "thread", "sourceService", "sourceID", "sourceURL"];
143
+ readonly group: [...string[], "sourceService", "sourceID", "sourceURL"];
187
144
  };
188
145
  };
189
146
  export declare const googleMailLabelDefinition: {
package/lib/model.js CHANGED
@@ -1,37 +1,25 @@
1
- import { connectorClusters, connectorModelIDs } from '@kubun/connector';
2
- import { modelIDs, modelsClusters } from '@kubun/models';
1
+ import { connectorClusters, connectorModelIDs, externalEntityDefinition } from '@kubun/connector';
2
+ import { SHORT_LIST_MAX_ITEMS } from '@kubun/models-core';
3
+ import { messagingClusters, messagingModelDefinitions, messagingModelIDs } from '@kubun/models-messaging';
3
4
  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
- };
5
+ const threadKindProviderFields = messagingModelDefinitions.find((definition)=>definition.name === 'ThreadKind')?.providerMergePolicy?.group;
6
+ const messageKindProviderFields = messagingModelDefinitions.find((definition)=>definition.name === 'MessageKind')?.providerMergePolicy?.group;
7
+ const emailKindProviderFields = messagingModelDefinitions.find((definition)=>definition.name === 'EmailKind')?.providerMergePolicy?.group;
8
+ if (threadKindProviderFields == null || messageKindProviderFields == null || emailKindProviderFields == null) {
9
+ throw new Error('Messaging provider fields are missing');
10
+ }
11
+ // The shared thread field (title) comes from ThreadKind; only what is
12
+ // Google's own is declared here.
21
13
  export const googleMailThreadDefinition = {
22
14
  name: 'GoogleMailThread',
23
15
  behavior: 'default',
24
16
  interfaces: [
25
17
  connectorModelIDs.ExternalEntity,
26
- modelIDs.ThreadKind
18
+ messagingModelIDs.ThreadKind
27
19
  ],
28
20
  schema: {
29
21
  type: 'object',
30
22
  properties: {
31
- title: {
32
- type: 'string',
33
- maxLength: 200
34
- },
35
23
  sourceService: {
36
24
  $ref: '#/definitions/SourceService'
37
25
  },
@@ -52,7 +40,7 @@ export const googleMailThreadDefinition = {
52
40
  ],
53
41
  additionalProperties: false,
54
42
  definitions: {
55
- ...commonDefinitions
43
+ ...externalEntityDefinition.schema.definitions
56
44
  }
57
45
  },
58
46
  fieldsMeta: {},
@@ -60,60 +48,35 @@ export const googleMailThreadDefinition = {
60
48
  // provider content, so it stays outside the group.
61
49
  providerMergePolicy: {
62
50
  group: [
63
- 'title',
51
+ ...threadKindProviderFields,
64
52
  'sourceService',
65
53
  'sourceID',
66
54
  'sourceURL'
67
55
  ]
68
56
  }
69
57
  };
58
+ // Generic message fields come from MessageKind, and mail fields from EmailKind.
59
+ // Only what is Google's own is declared here.
70
60
  export const googleMailMessageDefinition = {
71
61
  name: 'GoogleMailMessage',
72
62
  behavior: 'default',
73
63
  interfaces: [
74
64
  connectorModelIDs.ExternalEntity,
75
- modelIDs.MessageKind
65
+ messagingModelIDs.EmailKind
76
66
  ],
77
67
  schema: {
78
68
  type: 'object',
79
69
  properties: {
80
- body: {
81
- type: 'string'
82
- },
83
- sender: {
84
- type: 'string',
85
- minLength: 1,
86
- maxLength: 320
87
- },
88
- subject: {
89
- type: 'string'
90
- },
91
70
  snippet: {
92
71
  type: 'string'
93
72
  },
94
- to: {
95
- type: 'array',
96
- title: 'To',
97
- items: {
98
- type: 'string'
99
- }
100
- },
101
- cc: {
102
- type: 'array',
103
- title: 'Cc',
104
- items: {
105
- type: 'string'
106
- }
107
- },
108
73
  labels: {
109
74
  type: 'array',
110
75
  title: 'Labels',
111
76
  items: {
112
77
  type: 'string'
113
- }
114
- },
115
- sentAt: {
116
- $ref: '#/definitions/DateTime'
78
+ },
79
+ maxItems: SHORT_LIST_MAX_ITEMS
117
80
  },
118
81
  unread: {
119
82
  type: 'boolean'
@@ -123,14 +86,12 @@ export const googleMailMessageDefinition = {
123
86
  title: 'Attachments',
124
87
  items: {
125
88
  $ref: '#/definitions/Attachment'
126
- }
89
+ },
90
+ maxItems: SHORT_LIST_MAX_ITEMS
127
91
  },
128
92
  threadID: {
129
93
  type: 'string'
130
94
  },
131
- thread: {
132
- $ref: '#/definitions/ThreadDocID'
133
- },
134
95
  sourceService: {
135
96
  $ref: '#/definitions/SourceService'
136
97
  },
@@ -145,8 +106,6 @@ export const googleMailMessageDefinition = {
145
106
  }
146
107
  },
147
108
  required: [
148
- 'body',
149
- 'sender',
150
109
  'threadID',
151
110
  'thread',
152
111
  'sourceService',
@@ -155,8 +114,7 @@ export const googleMailMessageDefinition = {
155
114
  ],
156
115
  additionalProperties: false,
157
116
  definitions: {
158
- ...commonDefinitions,
159
- ThreadDocID: docIDSchema,
117
+ ...externalEntityDefinition.schema.definitions,
160
118
  Attachment: {
161
119
  type: 'object',
162
120
  title: 'Attachment',
@@ -190,29 +148,22 @@ export const googleMailMessageDefinition = {
190
148
  }
191
149
  }
192
150
  },
193
- fieldsMeta: {
194
- thread: {
195
- type: 'document',
196
- model: '#0'
197
- }
198
- },
151
+ fieldsMeta: {},
199
152
  // lastSyncedAt is device-local sync bookkeeping; attachments carries the
200
153
  // locally-materialized `blob` annotation (absent until an explicit import
201
154
  // fills it), so the whole field stays outside the group rather than
202
155
  // splitting a single top-level field between provider and device content.
203
156
  providerMergePolicy: {
204
157
  group: [
205
- 'body',
206
- 'sender',
207
- 'subject',
158
+ ...messageKindProviderFields.slice(0, 2),
159
+ ...emailKindProviderFields.slice(0, 1),
208
160
  'snippet',
209
- 'to',
210
- 'cc',
161
+ ...emailKindProviderFields.slice(1),
211
162
  'labels',
212
- 'sentAt',
163
+ ...messageKindProviderFields.slice(2, 3),
213
164
  'unread',
214
165
  'threadID',
215
- 'thread',
166
+ ...messageKindProviderFields.slice(3),
216
167
  'sourceService',
217
168
  'sourceID',
218
169
  'sourceURL'
@@ -274,7 +225,7 @@ export const googleMailLabelDefinition = {
274
225
  ],
275
226
  additionalProperties: false,
276
227
  definitions: {
277
- ...commonDefinitions
228
+ ...externalEntityDefinition.schema.definitions
278
229
  }
279
230
  },
280
231
  fieldsMeta: {},
@@ -297,7 +248,7 @@ export const googleMailLabelDefinition = {
297
248
  const builder = new GraphBuilder({
298
249
  externalClusters: {
299
250
  ...connectorClusters,
300
- ...modelsClusters
251
+ ...messagingClusters
301
252
  }
302
253
  });
303
254
  builder.add(googleMailThreadDefinition); // #0
package/lib/provider.js CHANGED
@@ -1,32 +1,38 @@
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 { mapConcurrent, retry } from '@sozai/async';
3
+ import { buildHistoryListURL, buildLabelsListURL, buildMessagesListURL, buildProfileURL, getHeader, getLabel, getMessage, getThread, mailAPIClient, mapLabelToEntity, mapMessageToEntity, mapThreadToEntity } from './api.js';
3
4
  import { googleMailClusters } from './model.js';
5
+ // messages.get costs 5 quota units against Gmail's 250 units/s per-user limit.
6
+ const MESSAGE_FETCH_CONCURRENCY = 4;
7
+ // A burst can still trip Gmail's per-user rate limit; a limited message is
8
+ // retried on its own so the rest of the sync keeps going.
9
+ const MESSAGE_RATE_LIMIT_POLICY = {
10
+ maxAttempts: 6,
11
+ backoff: {
12
+ initialMs: 250,
13
+ multiplier: 2,
14
+ maxMs: 8000,
15
+ jitter: true
16
+ }
17
+ };
18
+ // 429, or Gmail's 403 rateLimitExceeded / userRateLimitExceeded.
19
+ function isRateLimited(error) {
20
+ const { httpStatus, message } = error ?? {};
21
+ if (httpStatus === 429) return true;
22
+ return httpStatus === 403 && typeof message === 'string' && /rate ?limit/i.test(message);
23
+ }
4
24
  export const DEFAULT_QUERY = 'in:inbox';
5
25
  const THREAD_MODEL_ID = resolveModelID(googleMailClusters, 'GoogleMailThread');
6
26
  const MESSAGE_MODEL_ID = resolveModelID(googleMailClusters, 'GoogleMailMessage');
7
27
  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
28
  /**
21
29
  * The absolute lower bound (epoch seconds) for a full message listing: a window
22
30
  * already frozen in the checkpoint, else `now − maxAge` computed once. Null when
23
31
  * the boundary carries no `maxAge` (no floor).
24
32
  */ function windowStartSeconds(boundary, frozen) {
25
33
  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);
34
+ const days = parseMaxAgeDays(boundary);
35
+ if (days == null) return null;
30
36
  return Math.floor((Date.now() - days * 86_400_000) / 1000);
31
37
  }
32
38
  /** Appends an absolute `after:<epoch seconds>` floor to a Gmail search query. */ function applyAfter(query, windowStart) {
@@ -44,21 +50,18 @@ function maxHistoryId(messages) {
44
50
  }
45
51
  export function createGoogleMailProvider(config) {
46
52
  const fetchFn = config.fetch ?? globalThis.fetch;
53
+ const client = mailAPIClient(config.credential.accessToken, fetchFn);
47
54
  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
55
  async function listMessageIDs(query, signal) {
55
56
  const ids = [];
56
57
  let pageToken;
57
58
  do {
58
- const data = await fetchJSON(buildMessagesListURL({
59
+ const data = await client.get(buildMessagesListURL({
59
60
  query,
60
61
  pageToken
61
- }), config.credential, fetchFn, signal);
62
+ }), {
63
+ signal
64
+ });
62
65
  for (const m of data.messages ?? [])ids.push(m);
63
66
  pageToken = data.nextPageToken;
64
67
  }while (pageToken != null)
@@ -68,22 +71,36 @@ export function createGoogleMailProvider(config) {
68
71
  // shared api.ts getMessage so both the bulk and targeted (fetchEntity)
69
72
  // paths share one 404-tagging choke point.
70
73
  async function getMessageResource(id, signal) {
71
- return getMessage({
72
- accessToken: config.credential.accessToken,
73
- messageID: id,
74
- fetch: fetchFn,
74
+ return retry(({ signal: attemptSignal })=>getMessage({
75
+ accessToken: config.credential.accessToken,
76
+ messageID: id,
77
+ fetch: fetchFn,
78
+ signal: attemptSignal
79
+ }), {
80
+ policy: MESSAGE_RATE_LIMIT_POLICY,
81
+ signal,
82
+ retryable: isRateLimited
83
+ });
84
+ }
85
+ function getMessageResources(ids, signal) {
86
+ return mapConcurrent(ids, (id, _index, callSignal)=>getMessageResource(id, callSignal), {
87
+ limit: MESSAGE_FETCH_CONCURRENCY,
75
88
  signal
76
89
  });
77
90
  }
78
91
  async function profileHistoryId(signal) {
79
- const profile = await fetchJSON(buildProfileURL(), config.credential, fetchFn, signal);
92
+ const profile = await client.get(buildProfileURL(), {
93
+ signal
94
+ });
80
95
  return profile.historyId ?? '0';
81
96
  }
82
97
  // Labels have no incremental history API, so every sync re-lists them in full.
83
98
  // The list endpoint is a single cheap call; it omits color (captured only on
84
99
  // create/edit) but carries id, name, type, and the visibility fields.
85
100
  async function labelBatch(signal) {
86
- const data = await fetchJSON(buildLabelsListURL(), config.credential, fetchFn, signal);
101
+ const data = await client.get(buildLabelsListURL(), {
102
+ signal
103
+ });
87
104
  const sourceFetchHLC = mintFetchHLC();
88
105
  return {
89
106
  modelID: LABEL_MODEL_ID,
@@ -103,10 +120,7 @@ export function createGoogleMailProvider(config) {
103
120
  const windowStart = windowStartSeconds(params.boundary, prior?.windowStart);
104
121
  const query = applyAfter(config.query ?? DEFAULT_QUERY, windowStart);
105
122
  const ids = await listMessageIDs(query, params.signal);
106
- const messages = [];
107
- for (const { id } of ids){
108
- messages.push(await getMessageResource(id, params.signal));
109
- }
123
+ const messages = await getMessageResources(ids.map(({ id })=>id), params.signal);
110
124
  // Thread batch: one thread per distinct threadId, titled by its first message's subject.
111
125
  const threadSubjects = new Map();
112
126
  for (const m of messages){
@@ -157,22 +171,18 @@ export function createGoogleMailProvider(config) {
157
171
  let latestHistoryId = checkpoint.historyId;
158
172
  let pageToken;
159
173
  do {
160
- const response = await fetchFn(buildHistoryListURL({
174
+ const response = await client.request(buildHistoryListURL({
161
175
  startHistoryId: checkpoint.historyId,
162
176
  pageToken
163
177
  }), {
164
- headers: {
165
- Authorization: `Bearer ${config.credential.accessToken}`
166
- },
178
+ method: 'GET',
167
179
  signal: params.signal
168
180
  });
169
181
  if (response.status === 404) {
170
182
  yield* fetchAllEntities(params);
171
183
  return;
172
184
  }
173
- if (!response.ok) {
174
- throw new Error(`Google Mail API error: ${await parseGoogleError(response)}`);
175
- }
185
+ if (!response.ok) await client.fail(response);
176
186
  const data = await response.json();
177
187
  for (const record of data.history ?? []){
178
188
  for (const ref of [
@@ -193,10 +203,7 @@ export function createGoogleMailProvider(config) {
193
203
  const survivingIDs = [
194
204
  ...addedIDs.keys()
195
205
  ].filter((id)=>!deletedIDs.has(id));
196
- const messages = [];
197
- for (const id of survivingIDs){
198
- messages.push(await getMessageResource(id, params.signal));
199
- }
206
+ const messages = await getMessageResources(survivingIDs, params.signal);
200
207
  const threadSubjects = new Map();
201
208
  for (const m of messages){
202
209
  if (!threadSubjects.has(m.threadId)) {
@@ -247,53 +254,42 @@ export function createGoogleMailProvider(config) {
247
254
  // the list endpoint labelBatch uses).
248
255
  async function fetchEntity(model, sourceID) {
249
256
  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({
257
+ return fetchOrAbsent({
258
+ sourceID,
259
+ sourceFetchHLC,
260
+ read: async ()=>{
261
+ if (model === THREAD_MODEL_ID) {
262
+ const thread = await getThread({
263
+ accessToken: config.credential.accessToken,
264
+ threadID: sourceID,
265
+ fetch: fetchFn
266
+ });
267
+ const first = thread.messages?.[0];
268
+ const subject = first != null ? getHeader(first.payload?.headers, 'Subject') : undefined;
269
+ return {
270
+ record: mapThreadToEntity(thread.id, subject)
271
+ };
272
+ }
273
+ if (model === LABEL_MODEL_ID) {
274
+ const label = await getLabel({
275
+ accessToken: config.credential.accessToken,
276
+ labelID: sourceID,
277
+ fetch: fetchFn
278
+ });
279
+ return {
280
+ record: mapLabelToEntity(label)
281
+ };
282
+ }
283
+ const message = await getMessage({
267
284
  accessToken: config.credential.accessToken,
268
- labelID: sourceID,
285
+ messageID: sourceID,
269
286
  fetch: fetchFn
270
287
  });
271
288
  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
289
+ record: mapMessageToEntity(message)
293
290
  };
294
291
  }
295
- throw err;
296
- }
292
+ });
297
293
  }
298
294
  // Provider-authoritative re-read for write-back recovery. Gmail's
299
295
  // 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.18.0",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -15,14 +15,16 @@
15
15
  "LICENSE.md"
16
16
  ],
17
17
  "dependencies": {
18
- "@kubun/connector": "^0.17.2",
19
- "@kubun/credential-types": "^0.17.2",
20
- "@kubun/hlc": "^0.17.0",
21
- "@kubun/models": "^0.17.0",
22
- "@kubun/protocol": "^0.17.1",
23
- "@kubun/provider-google": "^0.17.0",
24
- "@kubun/scalars": "^0.17.0",
25
- "@sozai/codec": "^0.4.0"
18
+ "@kubun/connector": "^0.18.0",
19
+ "@kubun/credential-types": "^0.18.0",
20
+ "@kubun/hlc": "^0.18.0",
21
+ "@kubun/models-core": "^0.18.0",
22
+ "@kubun/models-messaging": "^0.18.0",
23
+ "@kubun/protocol": "^0.18.0",
24
+ "@kubun/provider-google": "^0.18.0",
25
+ "@kubun/scalars": "^0.18.0",
26
+ "@sozai/async": "^0.4.1",
27
+ "@sozai/codec": "^0.4.3"
26
28
  },
27
29
  "publishConfig": {
28
30
  "access": "public"