@open-mercato/channel-gmail 0.6.4

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.
Files changed (79) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/AGENTS.md +47 -0
  3. package/build.mjs +7 -0
  4. package/dist/index.js +5 -0
  5. package/dist/index.js.map +7 -0
  6. package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-006.spec.js +17 -0
  7. package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-006.spec.js.map +7 -0
  8. package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-007.spec.js +16 -0
  9. package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-007.spec.js.map +7 -0
  10. package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-008.spec.js +16 -0
  11. package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-008.spec.js.map +7 -0
  12. package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-A01-token-refresh.spec.js +17 -0
  13. package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-A01-token-refresh.spec.js.map +7 -0
  14. package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-C01.spec.js +26 -0
  15. package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-C01.spec.js.map +7 -0
  16. package/dist/modules/channel_gmail/acl.js +10 -0
  17. package/dist/modules/channel_gmail/acl.js.map +7 -0
  18. package/dist/modules/channel_gmail/di.js +23 -0
  19. package/dist/modules/channel_gmail/di.js.map +7 -0
  20. package/dist/modules/channel_gmail/index.js +9 -0
  21. package/dist/modules/channel_gmail/index.js.map +7 -0
  22. package/dist/modules/channel_gmail/integration.js +69 -0
  23. package/dist/modules/channel_gmail/integration.js.map +7 -0
  24. package/dist/modules/channel_gmail/lib/adapter.js +542 -0
  25. package/dist/modules/channel_gmail/lib/adapter.js.map +7 -0
  26. package/dist/modules/channel_gmail/lib/capabilities.js +10 -0
  27. package/dist/modules/channel_gmail/lib/capabilities.js.map +7 -0
  28. package/dist/modules/channel_gmail/lib/convert-outbound.js +84 -0
  29. package/dist/modules/channel_gmail/lib/convert-outbound.js.map +7 -0
  30. package/dist/modules/channel_gmail/lib/credentials.js +48 -0
  31. package/dist/modules/channel_gmail/lib/credentials.js.map +7 -0
  32. package/dist/modules/channel_gmail/lib/gmail-client.js +160 -0
  33. package/dist/modules/channel_gmail/lib/gmail-client.js.map +7 -0
  34. package/dist/modules/channel_gmail/lib/health.js +10 -0
  35. package/dist/modules/channel_gmail/lib/health.js.map +7 -0
  36. package/dist/modules/channel_gmail/lib/normalize-inbound.js +28 -0
  37. package/dist/modules/channel_gmail/lib/normalize-inbound.js.map +7 -0
  38. package/dist/modules/channel_gmail/lib/oauth.js +77 -0
  39. package/dist/modules/channel_gmail/lib/oauth.js.map +7 -0
  40. package/dist/modules/channel_gmail/setup.js +25 -0
  41. package/dist/modules/channel_gmail/setup.js.map +7 -0
  42. package/dist/modules/channel_gmail/widgets/injection/connect/widget.client.js +24 -0
  43. package/dist/modules/channel_gmail/widgets/injection/connect/widget.client.js.map +7 -0
  44. package/dist/modules/channel_gmail/widgets/injection/connect/widget.js +17 -0
  45. package/dist/modules/channel_gmail/widgets/injection/connect/widget.js.map +7 -0
  46. package/dist/modules/channel_gmail/widgets/injection-table.js +14 -0
  47. package/dist/modules/channel_gmail/widgets/injection-table.js.map +7 -0
  48. package/jest.config.cjs +34 -0
  49. package/package.json +95 -0
  50. package/src/index.ts +1 -0
  51. package/src/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-006.spec.ts +24 -0
  52. package/src/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-007.spec.ts +23 -0
  53. package/src/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-008.spec.ts +23 -0
  54. package/src/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-A01-token-refresh.spec.ts +39 -0
  55. package/src/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-C01.spec.ts +48 -0
  56. package/src/modules/channel_gmail/acl.ts +6 -0
  57. package/src/modules/channel_gmail/di.ts +21 -0
  58. package/src/modules/channel_gmail/index.ts +6 -0
  59. package/src/modules/channel_gmail/integration.ts +67 -0
  60. package/src/modules/channel_gmail/lib/__tests__/adapter.test.ts +838 -0
  61. package/src/modules/channel_gmail/lib/__tests__/convert-outbound.test.ts +128 -0
  62. package/src/modules/channel_gmail/lib/__tests__/credentials.test.ts +76 -0
  63. package/src/modules/channel_gmail/lib/__tests__/gmail-client.test.ts +209 -0
  64. package/src/modules/channel_gmail/lib/__tests__/normalize-inbound.test.ts +106 -0
  65. package/src/modules/channel_gmail/lib/__tests__/oauth.test.ts +148 -0
  66. package/src/modules/channel_gmail/lib/adapter.ts +734 -0
  67. package/src/modules/channel_gmail/lib/capabilities.ts +22 -0
  68. package/src/modules/channel_gmail/lib/convert-outbound.ts +136 -0
  69. package/src/modules/channel_gmail/lib/credentials.ts +90 -0
  70. package/src/modules/channel_gmail/lib/gmail-client.ts +305 -0
  71. package/src/modules/channel_gmail/lib/health.ts +14 -0
  72. package/src/modules/channel_gmail/lib/normalize-inbound.ts +57 -0
  73. package/src/modules/channel_gmail/lib/oauth.ts +128 -0
  74. package/src/modules/channel_gmail/setup.ts +36 -0
  75. package/src/modules/channel_gmail/widgets/injection/connect/widget.client.tsx +28 -0
  76. package/src/modules/channel_gmail/widgets/injection/connect/widget.ts +16 -0
  77. package/src/modules/channel_gmail/widgets/injection-table.ts +12 -0
  78. package/tsconfig.json +9 -0
  79. package/watch.mjs +7 -0
@@ -0,0 +1,734 @@
1
+ import type {
2
+ ApplyPushNotificationInput,
3
+ ChannelAdapter,
4
+ ChannelNativeContent,
5
+ ConvertOutboundInput,
6
+ BuildOAuthAuthorizeUrlInput,
7
+ BuildOAuthAuthorizeUrlResult,
8
+ DeleteChannelMessageInput,
9
+ ExchangeOAuthCodeInput,
10
+ ExchangeOAuthCodeResult,
11
+ FetchHistoryInput,
12
+ GetMessageStatusInput,
13
+ HistoryPage,
14
+ InboundMessage,
15
+ MessageStatus,
16
+ NormalizedInboundMessage,
17
+ PushRegistration,
18
+ RefreshCredentialsInput,
19
+ RefreshedCredentials,
20
+ RegisterPushInput,
21
+ ResolveContactInput,
22
+ ContactHint,
23
+ SendMessageInput,
24
+ SendMessageResult,
25
+ UnregisterPushInput,
26
+ VerifyWebhookInput,
27
+ } from '@open-mercato/core/modules/communication_channels/lib/adapter'
28
+ import { gmailCapabilities } from './capabilities'
29
+ import {
30
+ gmailChannelStateSchema,
31
+ gmailClientCredentialsSchema,
32
+ gmailUserCredentialsSchema,
33
+ parseScopes,
34
+ type GmailChannelState,
35
+ type GmailClientCredentials,
36
+ type GmailUserCredentials,
37
+ } from './credentials'
38
+ import {
39
+ decodeBase64Url,
40
+ encodeBase64Url,
41
+ getGmailApiClient,
42
+ GmailApiError,
43
+ type GmailGetMessageRawResponse,
44
+ } from './gmail-client'
45
+ import {
46
+ getGoogleOAuthClient,
47
+ tokenResponseToExpiresAt,
48
+ } from './oauth'
49
+ import {
50
+ convertOutboundForGmail,
51
+ type GmailEmailNativeMetadata,
52
+ } from './convert-outbound'
53
+ import { normalizeInboundGmailMessage } from './normalize-inbound'
54
+ import { emailResolveContact } from '@open-mercato/core/modules/communication_channels/lib/email-contact'
55
+ import { encodeCursor } from '@open-mercato/core/modules/communication_channels/lib/email-mime'
56
+
57
+ /**
58
+ * Gmail `ChannelAdapter`. OAuth2-based, polling-driven (`realtimePush: false`).
59
+ *
60
+ * Credential shape on `CommunicationChannel.credentials`:
61
+ * - Per-user: `{ accessToken, refreshToken?, expiresAt?, scopes?, email? }`
62
+ * - Tenant OAuth client config sits on `IntegrationCredentials.credentials`
63
+ * for the `gmail` provider: `{ clientId, clientSecret, scopes? }`. The hub
64
+ * looks it up by `(tenantId, providerKey='gmail')` and passes it to
65
+ * `buildOAuthAuthorizeUrl` + `exchangeOAuthCode` + `refreshCredentials`.
66
+ *
67
+ * Sync model:
68
+ * - First poll on a fresh channel reads `gmail.users.getProfile.historyId`
69
+ * and persists it; no message fetch is done on the bootstrap call (the
70
+ * existing inbox is intentionally not back-filled, matching the spec).
71
+ * - Subsequent polls call `gmail.users.history.list?startHistoryId=…` and
72
+ * fetch each `messagesAdded` entry's RAW payload.
73
+ * - If the server returns `404` for the history id (Gmail keeps ~7 days),
74
+ * we fall back to `gmail.users.messages.list?labelIds=INBOX` and persist
75
+ * the new historyId from the next `getProfile` call.
76
+ */
77
+ class GmailChannelAdapter implements ChannelAdapter {
78
+ readonly providerKey = 'gmail'
79
+ readonly channelType = 'email'
80
+ readonly capabilities = gmailCapabilities
81
+
82
+ async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {
83
+ const userCredentials = parseUserCredentialsOrThrow(input.credentials)
84
+ let native: ChannelNativeContent
85
+ try {
86
+ native = await convertOutboundForGmail({
87
+ body: input.content.html ?? input.content.text ?? '',
88
+ bodyFormat: input.content.bodyFormat ?? (input.content.html ? 'html' : 'text'),
89
+ attachments: input.content.attachments,
90
+ channelMetadata: input.metadata,
91
+ fromAddress: userCredentials.email ?? 'me',
92
+ })
93
+ } catch (error) {
94
+ const message = error instanceof Error ? error.message : 'Outbound conversion failed'
95
+ return { externalMessageId: '', status: 'failed', error: message }
96
+ }
97
+
98
+ const nativeMeta = native.metadata as unknown as GmailEmailNativeMetadata
99
+ const rawBase64Url = encodeBase64Url(nativeMeta.rawMessage)
100
+
101
+ try {
102
+ const response = await getGmailApiClient().sendRawMessage(
103
+ { accessToken: userCredentials.accessToken },
104
+ { rawBase64Url, threadId: nativeMeta.threadId },
105
+ )
106
+ return {
107
+ externalMessageId: nativeMeta.messageId ?? response.id,
108
+ conversationId: response.threadId,
109
+ status: 'sent',
110
+ metadata: { gmailMessageId: response.id, gmailThreadId: response.threadId, labelIds: response.labelIds ?? [] },
111
+ }
112
+ } catch (error) {
113
+ if (error instanceof GmailApiError && error.status === 401) {
114
+ // `requires_reauth` is a protocol sentinel the hub keys on (see
115
+ // communication_channels error-classification.isReauthError), not a
116
+ // user/log message — do NOT prefix or translate it.
117
+ return { externalMessageId: '', status: 'failed', error: 'requires_reauth' }
118
+ }
119
+ const message = error instanceof Error ? error.message : 'Gmail send failed'
120
+ return { externalMessageId: '', status: 'failed', error: message }
121
+ }
122
+ }
123
+
124
+ async verifyWebhook(_input: VerifyWebhookInput): Promise<InboundMessage> {
125
+ // Gmail Pub/Sub push (Spec C) is handled by the dedicated `/webhooks/gmail`
126
+ // route + `applyPushNotification`, not this generic hub-webhook hook, so this
127
+ // returns an unhandled event for the generic route to ack 2xx.
128
+ return { raw: {}, eventType: 'other', metadata: { reason: 'gmail-uses-polling-not-push' } }
129
+ }
130
+
131
+ async getStatus(_input: GetMessageStatusInput): Promise<MessageStatus> {
132
+ // Gmail exposes no per-message delivery-status API, so we return a
133
+ // best-effort `'sent'` placeholder (a later bounce is not reflected here).
134
+ // Mirrors the IMAP adapter's documented behavior.
135
+ return { status: 'sent' }
136
+ }
137
+
138
+ async convertOutbound(input: ConvertOutboundInput): Promise<ChannelNativeContent> {
139
+ return convertOutboundForGmail({ ...input, fromAddress: 'me' })
140
+ }
141
+
142
+ async normalizeInbound(raw: InboundMessage): Promise<NormalizedInboundMessage> {
143
+ const payload = raw.raw as {
144
+ rawBase64Url?: unknown
145
+ rawBody?: unknown
146
+ gmailMessageId?: unknown
147
+ gmailThreadId?: unknown
148
+ labelIds?: unknown
149
+ accountIdentifier?: unknown
150
+ }
151
+ const rawMessage = pickRawMimeBuffer(payload)
152
+ const gmailMessageId = typeof payload.gmailMessageId === 'string' ? payload.gmailMessageId : 'unknown'
153
+ const gmailThreadId = typeof payload.gmailThreadId === 'string' ? payload.gmailThreadId : gmailMessageId
154
+ const labelIds = Array.isArray(payload.labelIds) ? (payload.labelIds.filter((v) => typeof v === 'string') as string[]) : []
155
+ const accountIdentifier = typeof payload.accountIdentifier === 'string' ? payload.accountIdentifier : 'unknown@gmail'
156
+ return normalizeInboundGmailMessage({
157
+ rawMessage,
158
+ gmailMessageId,
159
+ gmailThreadId,
160
+ gmailLabelIds: labelIds,
161
+ accountIdentifier,
162
+ })
163
+ }
164
+
165
+ async buildOAuthAuthorizeUrl(input: BuildOAuthAuthorizeUrlInput): Promise<BuildOAuthAuthorizeUrlResult> {
166
+ const client = parseClientCredentialsOrThrow(input.credentials)
167
+ const scopes = parseScopes(client.scopes)
168
+ const url = getGoogleOAuthClient().buildAuthorizeUrl({
169
+ clientId: client.clientId,
170
+ redirectUri: input.redirectUri,
171
+ state: input.state,
172
+ scopes,
173
+ loginHint: input.loginHint,
174
+ })
175
+ return { authorizeUrl: url, extra: { scopes } }
176
+ }
177
+
178
+ async exchangeOAuthCode(input: ExchangeOAuthCodeInput): Promise<ExchangeOAuthCodeResult> {
179
+ const client = parseClientCredentialsOrThrow(input.credentials)
180
+ const token = await getGoogleOAuthClient().exchangeCode({
181
+ clientId: client.clientId,
182
+ clientSecret: client.clientSecret,
183
+ redirectUri: input.redirectUri,
184
+ code: input.code,
185
+ })
186
+ let email: string | undefined
187
+ let displayName: string | undefined
188
+ try {
189
+ const userInfo = await getGoogleOAuthClient().fetchUserInfo(token.access_token)
190
+ email = userInfo.email
191
+ displayName = userInfo.name ?? userInfo.email
192
+ } catch {
193
+ // Userinfo failure is non-fatal; fall back to the optional `id_token` parser later.
194
+ }
195
+ const expiresAt = tokenResponseToExpiresAt(token)
196
+ const credentials: GmailUserCredentials = {
197
+ accessToken: token.access_token,
198
+ refreshToken: token.refresh_token,
199
+ expiresAt: expiresAt?.toISOString(),
200
+ scopes: token.scope ? token.scope.split(' ').filter(Boolean) : undefined,
201
+ email,
202
+ }
203
+ return {
204
+ credentials: credentials as unknown as Record<string, unknown>,
205
+ externalIdentifier: email,
206
+ displayName: displayName ?? email,
207
+ expiresAt,
208
+ }
209
+ }
210
+
211
+ async refreshCredentials(input: RefreshCredentialsInput): Promise<RefreshedCredentials> {
212
+ const current = parseUserCredentialsOrThrow(input.credentials)
213
+ if (!current.refreshToken) {
214
+ throw new Error('requires_reauth')
215
+ }
216
+ // Spec A: prefer the new `input.oauthClient` slot (resolved by the hub from
217
+ // the `channel_gmail` integration's tenant-scoped client credentials). Fall
218
+ // back to the deprecated `credentials._client` path for one minor release so
219
+ // existing test fixtures keep working.
220
+ const clientFromState = resolveGmailOAuthClient(input)
221
+ const token = await getGoogleOAuthClient().refreshToken({
222
+ clientId: clientFromState.clientId,
223
+ clientSecret: clientFromState.clientSecret,
224
+ refreshToken: current.refreshToken,
225
+ })
226
+ const expiresAt = tokenResponseToExpiresAt(token)
227
+ const refreshed: GmailUserCredentials = {
228
+ accessToken: token.access_token,
229
+ // Google does NOT always return a new refresh token — keep the existing one.
230
+ refreshToken: token.refresh_token ?? current.refreshToken,
231
+ expiresAt: expiresAt?.toISOString(),
232
+ scopes: token.scope ? token.scope.split(' ').filter(Boolean) : current.scopes,
233
+ email: current.email,
234
+ }
235
+ return {
236
+ credentials: refreshed as unknown as Record<string, unknown>,
237
+ expiresAt,
238
+ }
239
+ }
240
+
241
+ async fetchHistory(input: FetchHistoryInput): Promise<HistoryPage> {
242
+ const userCredentials = parseUserCredentialsOrThrow(input.credentials)
243
+ const channelState = gmailChannelStateSchema.parse(input.channelState ?? {})
244
+ const auth = { accessToken: userCredentials.accessToken }
245
+ const api = getGmailApiClient()
246
+ const limit = input.limit ?? 50
247
+
248
+ // L3 first-page retry: a prior fallback scan hard-failed on its FIRST page and
249
+ // pinned only the history snapshot (no page token). Re-enter the fallback scan
250
+ // from the first INBOX page so unprocessed messages are retried, not skipped by
251
+ // the bootstrap path below.
252
+ if (channelState.pendingMessagesHistoryIdSnapshot && !channelState.pendingMessagesPageToken) {
253
+ return await this.startMessagesListFallback(
254
+ api,
255
+ auth,
256
+ userCredentials.email ?? 'me',
257
+ channelState.pendingMessagesHistoryIdSnapshot,
258
+ limit,
259
+ )
260
+ }
261
+
262
+ // Bootstrap path: no historyId yet → just persist current historyId and skip fetch.
263
+ if (!channelState.historyId && !channelState.pendingMessagesPageToken) {
264
+ const profile = await api.getProfile(auth)
265
+ const nextState: GmailChannelState = {
266
+ historyId: profile.historyId,
267
+ lastSyncedAt: new Date().toISOString(),
268
+ }
269
+ return {
270
+ messages: [],
271
+ nextCursor: encodeCursor(nextState),
272
+ hasMore: false,
273
+ }
274
+ }
275
+
276
+ // Mid-drain fallback (404 path): we previously fell back to messages.list
277
+ // for an expired historyId and still have pages to drain. Resume from the
278
+ // stored pageToken without re-issuing the history.list call.
279
+ if (channelState.pendingMessagesPageToken) {
280
+ return await this.continueMessagesListDrain(
281
+ api,
282
+ auth,
283
+ userCredentials.email ?? 'me',
284
+ channelState,
285
+ limit,
286
+ )
287
+ }
288
+
289
+ // Incremental path: history.list since stored historyId, walking
290
+ // nextPageToken until either (a) all pages drained, or (b) we've collected
291
+ // `limit` messages.
292
+ // CRITICAL: terminal historyId is ONLY advanced after full drain. While a
293
+ // pending pageToken exists in channelState, the original startHistoryId is
294
+ // retained so the next tick re-enters the same history window.
295
+ let messages: NormalizedInboundMessage[] = []
296
+ try {
297
+ return await this.continueHistoryListDrain(
298
+ api,
299
+ auth,
300
+ userCredentials.email ?? 'me',
301
+ channelState,
302
+ String(channelState.historyId),
303
+ limit,
304
+ )
305
+ } catch (error) {
306
+ if (error instanceof GmailApiError && error.status === 404) {
307
+ // Gmail history expired (~7-day retention). Fall back to a paged inbox
308
+ // scan via messages.list. Snapshot the post-fallback historyId now so
309
+ // we can advance it once the messages.list drain completes.
310
+ const profile = await api.getProfile(auth)
311
+ return await this.startMessagesListFallback(
312
+ api,
313
+ auth,
314
+ userCredentials.email ?? 'me',
315
+ profile.historyId,
316
+ limit,
317
+ )
318
+ }
319
+ throw error
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Spec C § Phase C2 — Register Gmail Pub/Sub watch.
325
+ *
326
+ * Calls `gmail.users.watch` with the operator-configured Pub/Sub topic.
327
+ * Returns `historyId` (cursor for subsequent `history.list` calls) and
328
+ * `expiration` (ms since epoch — Gmail caps at ~7 days). Persists both
329
+ * onto `CommunicationChannel.channelState` via the hub's
330
+ * `push.register` command.
331
+ */
332
+ async registerPush(input: RegisterPushInput): Promise<PushRegistration> {
333
+ const userCredentials = parseUserCredentialsOrThrow(input.credentials)
334
+ const auth = { accessToken: userCredentials.accessToken }
335
+ const api = getGmailApiClient()
336
+ const topicName = (input.providerConfig?.pubsubTopic as string | undefined) ?? ''
337
+ if (!topicName) {
338
+ return {
339
+ providerKey: this.providerKey,
340
+ status: 'failed',
341
+ channelStatePatch: {
342
+ pushStatus: 'failed',
343
+ lastPushError: {
344
+ code: 'missing_topic',
345
+ message: 'Pub/Sub topic not configured',
346
+ at: new Date().toISOString(),
347
+ },
348
+ },
349
+ error: {
350
+ code: 'missing_topic',
351
+ message: 'OM_GMAIL_PUBSUB_TOPIC not configured for this tenant',
352
+ },
353
+ }
354
+ }
355
+ try {
356
+ const result = await api.watchInbox(auth, { topicName, labelIds: ['INBOX'] })
357
+ const expirationMs = Number(result.expiration)
358
+ return {
359
+ providerKey: this.providerKey,
360
+ status: 'active',
361
+ channelStatePatch: {
362
+ historyId: result.historyId,
363
+ watchExpirationMs: Number.isFinite(expirationMs) ? expirationMs : Date.now() + 6 * 24 * 3600 * 1000,
364
+ pubsubTopic: topicName,
365
+ pushStatus: 'active',
366
+ lastPushError: null,
367
+ },
368
+ recommendedPollIntervalSeconds: 1800,
369
+ }
370
+ } catch (error) {
371
+ const status = error instanceof GmailApiError ? error.status : 0
372
+ const detail = error instanceof Error ? error.message : 'watch failed'
373
+ return {
374
+ providerKey: this.providerKey,
375
+ status: 'failed',
376
+ channelStatePatch: {
377
+ pushStatus: 'failed',
378
+ lastPushError: {
379
+ code: `gmail_watch_${status || 'error'}`,
380
+ message: detail.slice(0, 500),
381
+ at: new Date().toISOString(),
382
+ },
383
+ },
384
+ error: { code: `gmail_watch_${status || 'error'}`, message: detail },
385
+ }
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Spec C § Phase C2 — Tear down Gmail watch via `gmail.users.stop`.
391
+ * Idempotent: a 404 (no active watch) is swallowed.
392
+ */
393
+ async unregisterPush(input: UnregisterPushInput): Promise<void> {
394
+ const userCredentials = parseUserCredentialsOrThrow(input.credentials)
395
+ const auth = { accessToken: userCredentials.accessToken }
396
+ const api = getGmailApiClient()
397
+ try {
398
+ await api.stopWatch(auth)
399
+ } catch (error) {
400
+ if (error instanceof GmailApiError && error.status === 404) return
401
+ throw error
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Spec C § Phase C2 — Convert a verified Pub/Sub notification into a
407
+ * `HistoryPage`. The notification body itself is just `{ emailAddress,
408
+ * historyId }`; the actual messages come from `history.list` against
409
+ * `channelState.historyId`. We delegate to `fetchHistory` so the
410
+ * pagination / 404-fallback logic stays in one place.
411
+ */
412
+ async applyPushNotification(input: ApplyPushNotificationInput): Promise<HistoryPage> {
413
+ // The notification's `historyId` is informational — Gmail guarantees
414
+ // it is `>= channelState.historyId`, but a multi-event batch may
415
+ // advance further than what `history.list` returns in a single page.
416
+ // Treat the call as "drain whatever is new since the stored cursor".
417
+ // If `channelState.historyId` is absent (a push arrived before the cursor
418
+ // was seeded), `fetchHistory` bootstraps — persists the current historyId
419
+ // and returns zero messages — so this notification's delta is picked up on
420
+ // the next call. In practice `registerPush` seeds the cursor before any
421
+ // push flows, so this edge is not hit in the normal lifecycle.
422
+ return this.fetchHistory({
423
+ conversationId: 'INBOX',
424
+ credentials: input.credentials,
425
+ channelState: input.channelState,
426
+ scope: input.scope,
427
+ // Push notifications are bursty (1/s max per Gmail user); use a
428
+ // smaller per-call limit so the worker drains quickly between
429
+ // notifications without holding the API quota.
430
+ limit: 50,
431
+ } as FetchHistoryInput)
432
+ }
433
+
434
+ private async continueHistoryListDrain(
435
+ api: ReturnType<typeof getGmailApiClient>,
436
+ auth: { accessToken: string },
437
+ accountIdentifier: string,
438
+ channelState: GmailChannelState,
439
+ startHistoryId: string,
440
+ limit: number,
441
+ ): Promise<HistoryPage> {
442
+ const collected: Array<{ id: string; threadId: string; labelIds?: string[] }> = []
443
+ const seen = new Set<string>()
444
+ let pageToken: string | undefined = channelState.pendingHistoryPageToken
445
+ let lastResponseHistoryId: string | undefined
446
+ let drained = false
447
+
448
+ // Fully consume each page before deciding to stop: `pageToken` must only ever
449
+ // advance past refs we have actually collected, otherwise a page carrying more
450
+ // than `limit` new refs would silently drop the overflow. `collected` may
451
+ // therefore exceed `limit` by up to one page — bounded and intentional.
452
+ while (true) {
453
+ const history = await api.listHistory(auth, {
454
+ startHistoryId,
455
+ pageToken,
456
+ })
457
+ lastResponseHistoryId = history.historyId ?? lastResponseHistoryId
458
+ for (const ref of collectMessageRefs(history.history ?? [])) {
459
+ if (seen.has(ref.id)) continue
460
+ seen.add(ref.id)
461
+ collected.push(ref)
462
+ }
463
+ if (!history.nextPageToken) {
464
+ drained = true
465
+ break
466
+ }
467
+ pageToken = history.nextPageToken
468
+ if (collected.length >= limit) break
469
+ }
470
+
471
+ const { messages, hardFailed } = await this.fetchAndNormalize(api, auth, collected, accountIdentifier)
472
+ const nextState: GmailChannelState = {
473
+ lastSyncedAt: new Date().toISOString(),
474
+ }
475
+ if (hardFailed) {
476
+ // L3: a message failed transiently. Restart the window from startHistoryId
477
+ // on the next tick (drop any page token) so every page — including the one
478
+ // carrying the failed message — is re-read; already-ingested messages dedup
479
+ // at the hub. Do NOT advance the terminal historyId or pin a forward token,
480
+ // which would skip the failed message's page.
481
+ nextState.historyId = startHistoryId
482
+ } else if (drained) {
483
+ // All pages drained — advance the terminal historyId.
484
+ nextState.historyId = lastResponseHistoryId ?? startHistoryId
485
+ } else {
486
+ // Mid-drain — keep the prior startHistoryId pinned + remember the next
487
+ // unconsumed pageToken so the following tick resumes without re-walking.
488
+ nextState.historyId = startHistoryId
489
+ nextState.pendingHistoryPageToken = pageToken
490
+ }
491
+ return {
492
+ messages,
493
+ nextCursor: encodeCursor(nextState),
494
+ // Re-enqueue immediately when a transient failure left work behind.
495
+ hasMore: hardFailed || !drained,
496
+ }
497
+ }
498
+
499
+ private async startMessagesListFallback(
500
+ api: ReturnType<typeof getGmailApiClient>,
501
+ auth: { accessToken: string },
502
+ accountIdentifier: string,
503
+ historyIdSnapshot: string,
504
+ limit: number,
505
+ ): Promise<HistoryPage> {
506
+ const list = await api.listMessages(auth, {
507
+ labelIds: ['INBOX'],
508
+ maxResults: limit,
509
+ })
510
+ const refs = (list.messages ?? []).map((m) => ({
511
+ id: m.id,
512
+ threadId: m.threadId,
513
+ labelIds: ['INBOX'],
514
+ }))
515
+ const { messages, hardFailed } = await this.fetchAndNormalize(api, auth, refs, accountIdentifier)
516
+ const drained = !list.nextPageToken
517
+ const nextState: GmailChannelState = {
518
+ lastSyncedAt: new Date().toISOString(),
519
+ }
520
+ if (hardFailed) {
521
+ // L3: this is the FIRST fallback page (no prior page token), so there is
522
+ // nothing to pin. Deliberately leave `historyId` unset so the cursor does
523
+ // NOT advance past the unprocessed messages — the next tick re-enters the
524
+ // same fallback scan and retries them.
525
+ nextState.pendingMessagesHistoryIdSnapshot = historyIdSnapshot
526
+ } else if (drained) {
527
+ nextState.historyId = historyIdSnapshot
528
+ } else {
529
+ nextState.pendingMessagesPageToken = list.nextPageToken
530
+ nextState.pendingMessagesHistoryIdSnapshot = historyIdSnapshot
531
+ }
532
+ return {
533
+ messages,
534
+ nextCursor: encodeCursor(nextState),
535
+ hasMore: hardFailed || !drained,
536
+ }
537
+ }
538
+
539
+ private async continueMessagesListDrain(
540
+ api: ReturnType<typeof getGmailApiClient>,
541
+ auth: { accessToken: string },
542
+ accountIdentifier: string,
543
+ channelState: GmailChannelState,
544
+ limit: number,
545
+ ): Promise<HistoryPage> {
546
+ const list = await api.listMessages(auth, {
547
+ labelIds: ['INBOX'],
548
+ maxResults: limit,
549
+ pageToken: channelState.pendingMessagesPageToken,
550
+ })
551
+ const refs = (list.messages ?? []).map((m) => ({
552
+ id: m.id,
553
+ threadId: m.threadId,
554
+ labelIds: ['INBOX'],
555
+ }))
556
+ const { messages, hardFailed } = await this.fetchAndNormalize(api, auth, refs, accountIdentifier)
557
+ const drained = !list.nextPageToken
558
+ const nextState: GmailChannelState = {
559
+ lastSyncedAt: new Date().toISOString(),
560
+ }
561
+ if (hardFailed) {
562
+ // L3: re-pin the SAME page token (not list.nextPageToken) so the next
563
+ // tick re-fetches this page and retries the unprocessed messages.
564
+ nextState.pendingMessagesPageToken = channelState.pendingMessagesPageToken
565
+ nextState.pendingMessagesHistoryIdSnapshot = channelState.pendingMessagesHistoryIdSnapshot
566
+ } else if (drained) {
567
+ nextState.historyId = channelState.pendingMessagesHistoryIdSnapshot ?? channelState.historyId
568
+ } else {
569
+ nextState.pendingMessagesPageToken = list.nextPageToken
570
+ nextState.pendingMessagesHistoryIdSnapshot = channelState.pendingMessagesHistoryIdSnapshot
571
+ }
572
+ return {
573
+ messages,
574
+ nextCursor: encodeCursor(nextState),
575
+ hasMore: hardFailed || !drained,
576
+ }
577
+ }
578
+
579
+ async deleteMessage(input: DeleteChannelMessageInput): Promise<void> {
580
+ const userCredentials = parseUserCredentialsOrThrow(input.credentials)
581
+ const api = getGmailApiClient()
582
+ // Gmail's "delete" capability is delivered as Trash to match the user's
583
+ // mental model and avoid permanent loss on accidental clicks. The user can
584
+ // restore from Trash in the Gmail web UI within 30 days.
585
+ await api.trashMessage({ accessToken: userCredentials.accessToken }, input.externalMessageId)
586
+ }
587
+
588
+ async resolveContact(input: ResolveContactInput): Promise<ContactHint | null> {
589
+ return emailResolveContact(input)
590
+ }
591
+
592
+ /**
593
+ * Fetch + normalize each collected message ref.
594
+ *
595
+ * L3 fix: a non-404/410 error on `getMessageRaw` (e.g. a transient 500/403)
596
+ * used to re-throw and abort the whole tick, discarding messages that had
597
+ * already normalized in the same page. Worse, because the cursor could be
598
+ * advanced from a different source (a push notification carrying a higher
599
+ * historyId), the transiently-failed messages could be skipped permanently.
600
+ *
601
+ * We now treat a hard failure as a stop point: keep the messages normalized
602
+ * BEFORE the failure and signal `hardFailed: true` so the caller pins the
603
+ * persisted `historyId` (does NOT advance past the failure) and re-fetches on
604
+ * the next tick. 404/410 stay skipped (the message is genuinely gone).
605
+ */
606
+ private async fetchAndNormalize(
607
+ api: ReturnType<typeof getGmailApiClient>,
608
+ auth: { accessToken: string },
609
+ refs: Array<{ id: string; threadId: string; labelIds?: string[] }>,
610
+ accountIdentifier: string,
611
+ ): Promise<{ messages: NormalizedInboundMessage[]; hardFailed: boolean }> {
612
+ const out: NormalizedInboundMessage[] = []
613
+ for (const ref of refs) {
614
+ let raw: GmailGetMessageRawResponse
615
+ try {
616
+ raw = await api.getMessageRaw(auth, ref.id)
617
+ } catch (error) {
618
+ // 404/410: the message is gone — skip it and keep draining.
619
+ if (error instanceof GmailApiError && (error.status === 404 || error.status === 410)) continue
620
+ // Any other failure is potentially transient. Stop here without
621
+ // advancing past the unprocessed messages so the next tick retries them.
622
+ return { messages: out, hardFailed: true }
623
+ }
624
+ const rawBuffer = decodeBase64Url(raw.raw)
625
+ const fallbackDate = raw.internalDate ? new Date(Number(raw.internalDate)) : undefined
626
+ const normalized = await normalizeInboundGmailMessage({
627
+ rawMessage: rawBuffer,
628
+ gmailMessageId: raw.id,
629
+ gmailThreadId: raw.threadId,
630
+ gmailLabelIds: raw.labelIds ?? ref.labelIds ?? [],
631
+ accountIdentifier,
632
+ fallbackDate,
633
+ })
634
+ out.push(normalized)
635
+ }
636
+ return { messages: out, hardFailed: false }
637
+ }
638
+ }
639
+
640
+ function collectMessageRefs(
641
+ history: Array<{
642
+ messagesAdded?: Array<{ message: { id: string; threadId: string; labelIds?: string[] } }>
643
+ }>,
644
+ ): Array<{ id: string; threadId: string; labelIds?: string[] }> {
645
+ const seen = new Set<string>()
646
+ const refs: Array<{ id: string; threadId: string; labelIds?: string[] }> = []
647
+ for (const entry of history) {
648
+ for (const added of entry.messagesAdded ?? []) {
649
+ if (seen.has(added.message.id)) continue
650
+ seen.add(added.message.id)
651
+ refs.push({ id: added.message.id, threadId: added.message.threadId, labelIds: added.message.labelIds })
652
+ }
653
+ }
654
+ return refs
655
+ }
656
+
657
+ function parseUserCredentialsOrThrow(value: unknown): GmailUserCredentials {
658
+ const parsed = gmailUserCredentialsSchema.safeParse(value)
659
+ if (!parsed.success) {
660
+ const first = parsed.error.issues[0]
661
+ throw new Error(`Invalid Gmail credentials: ${first?.message ?? 'unknown validation error'}`)
662
+ }
663
+ return parsed.data
664
+ }
665
+
666
+ function parseClientCredentialsOrThrow(value: unknown): GmailClientCredentials {
667
+ const parsed = gmailClientCredentialsSchema.safeParse(value)
668
+ if (!parsed.success) {
669
+ const first = parsed.error.issues[0]
670
+ throw new Error(`Invalid Gmail OAuth client credentials: ${first?.message ?? 'unknown validation error'}`)
671
+ }
672
+ return parsed.data
673
+ }
674
+
675
+ let warnedLegacyClientPath = false
676
+
677
+ /**
678
+ * Resolve the OAuth client config for a Gmail refresh, preferring the new
679
+ * `RefreshCredentialsInput.oauthClient` field (Spec A,
680
+ * .ai/specs/2026-05-27-email-integration-inbound-reliability-and-threading.md).
681
+ *
682
+ * Falls back to the deprecated `credentials._client` read path for one
683
+ * minor release so existing tests keep working. The legacy path emits a
684
+ * one-time deprecation warning per process so production logs stay quiet.
685
+ */
686
+ function resolveGmailOAuthClient(input: RefreshCredentialsInput): GmailClientCredentials {
687
+ if (input.oauthClient) {
688
+ const client = input.oauthClient
689
+ if (!client.clientId) {
690
+ throw new Error('[internal] Invalid Gmail OAuth client credentials: OAuth Client ID required')
691
+ }
692
+ if (!client.clientSecret) {
693
+ throw new Error('[internal] Invalid Gmail OAuth client credentials: clientSecret required')
694
+ }
695
+ return {
696
+ clientId: client.clientId,
697
+ clientSecret: client.clientSecret,
698
+ // `GmailClientCredentials.scopes` is the wire format the legacy
699
+ // `credentials._client` blob carried — comma/space-separated string.
700
+ // Spec A's `OAuthClientConfig.scopes` is the canonical `string[]`.
701
+ // `parseScopes` accepts either separator, so join with a single space.
702
+ ...(client.scopes !== undefined ? { scopes: client.scopes.join(' ') } : {}),
703
+ }
704
+ }
705
+ // Legacy path — DEPRECATED. Remove in the next minor release.
706
+ if (!warnedLegacyClientPath) {
707
+ warnedLegacyClientPath = true
708
+ console.warn(
709
+ '[channel-gmail] reading OAuth client config from credentials._client is deprecated;' +
710
+ ' pass via RefreshCredentialsInput.oauthClient instead (Spec A).',
711
+ )
712
+ }
713
+ return parseClientCredentialsOrThrow(
714
+ (input.credentials as unknown as { _client?: unknown })._client ?? input.credentials,
715
+ )
716
+ }
717
+
718
+ function pickRawMimeBuffer(payload: { rawBase64Url?: unknown; rawBody?: unknown }): Buffer {
719
+ if (typeof payload.rawBase64Url === 'string') return decodeBase64Url(payload.rawBase64Url)
720
+ const value = payload.rawBody
721
+ if (Buffer.isBuffer(value)) return value
722
+ if (value instanceof Uint8Array) return Buffer.from(value)
723
+ if (typeof value === 'string') return Buffer.from(value, 'utf-8')
724
+ throw new Error('[internal] Gmail normalizeInbound requires `raw.rawBase64Url` or `raw.rawBody`')
725
+ }
726
+
727
+ let cachedAdapter: GmailChannelAdapter | null = null
728
+
729
+ export function getGmailChannelAdapter(): GmailChannelAdapter {
730
+ if (!cachedAdapter) cachedAdapter = new GmailChannelAdapter()
731
+ return cachedAdapter
732
+ }
733
+
734
+ export { GmailChannelAdapter }