@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,22 @@
1
+ import type { ChannelCapabilities } from '@open-mercato/core/modules/communication_channels/lib/adapter'
2
+ import { baseEmailCapabilities } from '@open-mercato/core/modules/communication_channels/lib/email-capabilities'
3
+
4
+ /**
5
+ * Gmail capabilities. `realtimePush: false` is deliberate: Gmail Pub/Sub push IS
6
+ * implemented (the adapter registers/renews `users.watch` and applies history
7
+ * notifications), but the hub keeps polling as a belt-and-suspenders fallback, so
8
+ * the capability flag stays false to preserve the polling cadence.
9
+ *
10
+ * Threading is supported natively via Gmail `threadId` plus RFC2822
11
+ * In-Reply-To/References. Attachment ceiling matches the shared email baseline.
12
+ *
13
+ * `fileSharing: false` (R2-M4 / F11, 2026-05-26): the adapter's
14
+ * `convertOutbound` does not yet stitch attachment URLs into the base64-encoded
15
+ * RFC2822 body it sends via `users.messages.send`. Re-enable when the URL-fetch
16
+ * + MIME stitching flow lands.
17
+ */
18
+ export const gmailCapabilities: ChannelCapabilities = {
19
+ ...baseEmailCapabilities,
20
+ // Gmail supports moving a message to Trash via the API.
21
+ deleteMessage: true,
22
+ }
@@ -0,0 +1,136 @@
1
+ import type {
2
+ ChannelNativeContent,
3
+ ConvertOutboundInput,
4
+ } from '@open-mercato/core/modules/communication_channels/lib/adapter'
5
+ import {
6
+ assembleRfc2822,
7
+ escapeQuotes,
8
+ generateMessageId,
9
+ htmlToText,
10
+ referencesFromMeta,
11
+ stringOrUndefined,
12
+ toAddressList,
13
+ } from '@open-mercato/core/modules/communication_channels/lib/email-mime'
14
+ import { GMAIL_THREAD_REF_PREFIX } from './normalize-inbound'
15
+
16
+ /**
17
+ * Convert a hub-canonical outbound payload to a Gmail-ready native content shape.
18
+ *
19
+ * Unlike IMAP/SMTP (which hands the message to nodemailer), the Gmail adapter
20
+ * builds the RFC2822 message itself and sends via `gmail.users.messages.send`.
21
+ * The converter pre-builds the raw message so `sendMessage` is a pure
22
+ * "base64url-encode + POST" call, with no SMTP transport involved.
23
+ *
24
+ * Output metadata fields:
25
+ * - rawMessage: Buffer — the assembled RFC2822 message
26
+ * - threadId: string? — Gmail thread id (resolved by deriveGmailThreadId)
27
+ * - subject / to / cc / bcc / inReplyTo / references — diagnostic copies
28
+ */
29
+
30
+ export interface GmailEmailNativeMetadata {
31
+ subject?: string
32
+ to: string[]
33
+ cc?: string[]
34
+ bcc?: string[]
35
+ inReplyTo?: string
36
+ references?: string[]
37
+ messageId?: string
38
+ threadId?: string
39
+ fromAddress: string
40
+ fromName?: string
41
+ rawMessage: Buffer
42
+ }
43
+
44
+ export interface ConvertOutboundForGmailInput extends ConvertOutboundInput {
45
+ fromAddress: string
46
+ fromName?: string
47
+ }
48
+
49
+ /**
50
+ * Resolve the Gmail native `threadId` so replies attach to the original Gmail
51
+ * conversation. Order of precedence:
52
+ * 1. `gmailThreadId` — explicit, set by inbound normalization metadata.
53
+ * 2. `threadId` — survives the hub's convert→send double-conversion, where
54
+ * `sendMessage` re-converts the already-converted metadata.
55
+ * 3. `thread_id` — the hub's generic conversation ref; for an existing Gmail
56
+ * thread it is `gmail-thread:<rawId>` (see `normalize-inbound`). A new
57
+ * outbound thread uses an `outbound:<uuid>` ref with no Gmail thread yet,
58
+ * so `threadId` stays unset and Gmail opens a fresh server-side thread.
59
+ */
60
+ function deriveGmailThreadId(meta: Record<string, unknown>): string | undefined {
61
+ const explicit = stringOrUndefined(meta.gmailThreadId) ?? stringOrUndefined(meta.threadId)
62
+ if (explicit) return explicit
63
+ const conversationRef = stringOrUndefined(meta.thread_id)
64
+ if (conversationRef && conversationRef.startsWith(GMAIL_THREAD_REF_PREFIX)) {
65
+ const rawThreadId = conversationRef.slice(GMAIL_THREAD_REF_PREFIX.length)
66
+ return rawThreadId.length > 0 ? rawThreadId : undefined
67
+ }
68
+ return undefined
69
+ }
70
+
71
+ export async function convertOutboundForGmail(
72
+ input: ConvertOutboundForGmailInput,
73
+ ): Promise<ChannelNativeContent> {
74
+ const meta = (input.channelMetadata ?? {}) as Record<string, unknown>
75
+ const subject = stringOrUndefined(meta.subject)
76
+ const to = toAddressList(meta.to)
77
+ if (to.length === 0) {
78
+ throw new Error('[internal] Gmail outbound conversion requires at least one recipient (channelMetadata.to)')
79
+ }
80
+ const cc = toAddressList(meta.cc)
81
+ const bcc = toAddressList(meta.bcc)
82
+ const inReplyTo = stringOrUndefined(meta.inReplyTo)
83
+ const references = referencesFromMeta(meta.references)
84
+ const messageId = stringOrUndefined(meta.messageId) ?? generateMessageId(input.fromAddress, 'gmail.com')
85
+ const threadId = deriveGmailThreadId(meta)
86
+
87
+ const html = input.bodyFormat === 'html' ? input.body : undefined
88
+ const text = input.bodyFormat === 'html' ? htmlToText(input.body) : input.body
89
+
90
+ const rawMessage = assembleRfc2822({
91
+ from: input.fromName ? `"${escapeQuotes(input.fromName)}" <${input.fromAddress}>` : input.fromAddress,
92
+ to,
93
+ cc,
94
+ bcc,
95
+ subject,
96
+ text,
97
+ html,
98
+ inReplyTo,
99
+ references,
100
+ messageId,
101
+ })
102
+
103
+ const metadata: GmailEmailNativeMetadata = {
104
+ subject,
105
+ to,
106
+ cc: cc.length ? cc : undefined,
107
+ bcc: bcc.length ? bcc : undefined,
108
+ inReplyTo,
109
+ references,
110
+ messageId,
111
+ threadId,
112
+ fromAddress: input.fromAddress,
113
+ fromName: input.fromName,
114
+ rawMessage,
115
+ }
116
+
117
+ return {
118
+ content: {
119
+ text,
120
+ html,
121
+ bodyFormat: input.bodyFormat,
122
+ attachments: input.attachments,
123
+ raw: {
124
+ subject,
125
+ to,
126
+ cc,
127
+ bcc,
128
+ inReplyTo,
129
+ references,
130
+ messageId,
131
+ threadId,
132
+ },
133
+ },
134
+ metadata: metadata as unknown as Record<string, unknown>,
135
+ }
136
+ }
@@ -0,0 +1,90 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * Tenant-level OAuth client configuration. Stored on `IntegrationCredentials`
5
+ * for the `gmail` provider when the tenant has set up their own Google Cloud
6
+ * project. Per-user OAuth tokens layer on top via `userCredentialsSchema`.
7
+ */
8
+ export const gmailClientCredentialsSchema = z
9
+ .object({
10
+ clientId: z.string().min(1, 'OAuth Client ID required'),
11
+ clientSecret: z.string().min(1, 'OAuth Client Secret required'),
12
+ /** Comma-separated scopes; blank uses defaults. */
13
+ scopes: z.string().optional(),
14
+ })
15
+ .strict()
16
+
17
+ export type GmailClientCredentials = z.infer<typeof gmailClientCredentialsSchema>
18
+
19
+ /**
20
+ * Per-user OAuth tokens stored on `CommunicationChannel.credentials` (encrypted).
21
+ * The hub injects the tenant client_id / client_secret at exchange/refresh time;
22
+ * the per-channel blob only persists the user-bound tokens.
23
+ */
24
+ export const gmailUserCredentialsSchema = z
25
+ .object({
26
+ accessToken: z.string({ error: 'Access token required' }).min(1, 'Access token required'),
27
+ /**
28
+ * Gmail issues a refresh token only on the first consent. If the user
29
+ * re-authorises, Google does NOT send a new refresh token unless we pass
30
+ * `prompt=consent` and `access_type=offline`; we always do. We still mark
31
+ * the field optional so legacy migrations from accounts that never received
32
+ * one don't fail the schema — the runtime treats absence as "requires_reauth".
33
+ */
34
+ refreshToken: z.string().optional(),
35
+ /** ISO timestamp of access-token expiry. */
36
+ expiresAt: z.string().datetime().optional(),
37
+ /** Scopes that were actually granted (we may have requested a subset). */
38
+ scopes: z.array(z.string()).optional(),
39
+ /** Email address from the linked Google account. */
40
+ email: z.string().email().optional(),
41
+ })
42
+ .passthrough()
43
+
44
+ export type GmailUserCredentials = z.infer<typeof gmailUserCredentialsSchema>
45
+
46
+ /**
47
+ * Per-channel sync state stored on `CommunicationChannel.channelState`.
48
+ *
49
+ * historyId — Gmail's per-mailbox monotonic cursor used by `history.list`
50
+ * to fetch only changes since the previous poll. If history has
51
+ * expired (Gmail keeps roughly 7 days), we fall back to a full
52
+ * list using `gmail.users.messages.list`.
53
+ *
54
+ * pendingHistoryPageToken — mid-drain resumption state when a single tick
55
+ * can't ingest every page of `history.list` (e.g. a high-volume
56
+ * mailbox returned more than our per-tick budget). The terminal
57
+ * `historyId` is NOT advanced until the pages drain. The next tick
58
+ * resumes via the stored `historyId` + this `pageToken`.
59
+ *
60
+ * pendingMessagesPageToken / pendingMessagesHistoryIdSnapshot — same
61
+ * contract for the 404-fallback path (`messages.list`).
62
+ *
63
+ * See https://developers.google.com/gmail/api/guides/sync for the contract.
64
+ */
65
+ export const gmailChannelStateSchema = z
66
+ .object({
67
+ historyId: z.union([z.string(), z.number()]).optional(),
68
+ lastSyncedAt: z.string().datetime().optional(),
69
+ pendingHistoryPageToken: z.string().optional(),
70
+ pendingMessagesPageToken: z.string().optional(),
71
+ pendingMessagesHistoryIdSnapshot: z.string().optional(),
72
+ })
73
+ .partial()
74
+ .passthrough()
75
+
76
+ export type GmailChannelState = z.infer<typeof gmailChannelStateSchema>
77
+
78
+ export const GMAIL_DEFAULT_SCOPES = [
79
+ 'https://www.googleapis.com/auth/gmail.modify',
80
+ 'https://www.googleapis.com/auth/userinfo.email',
81
+ 'https://www.googleapis.com/auth/userinfo.profile',
82
+ ]
83
+
84
+ export function parseScopes(value: string | undefined): string[] {
85
+ if (!value || !value.trim()) return [...GMAIL_DEFAULT_SCOPES]
86
+ return value
87
+ .split(/[\s,]+/)
88
+ .map((s) => s.trim())
89
+ .filter((s) => s.length > 0)
90
+ }
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Thin Gmail REST API wrapper. Same trade-off as `oauth.ts`: we use `fetch`
3
+ * directly so the adapter doesn't import the `googleapis` SDK at runtime in
4
+ * environments that don't need it (tests, build-only checks). Production code
5
+ * paths still allow swapping to the SDK via `setGmailApiClient(...)` if a
6
+ * downstream package wants the SDK's extra ergonomics.
7
+ *
8
+ * Only the endpoints the adapter actually calls are exposed:
9
+ * - listHistory → gmail.users.history.list
10
+ * - listMessages → gmail.users.messages.list (fallback when historyId expired)
11
+ * - getMessageRaw → gmail.users.messages.get?format=raw
12
+ * - sendRawMessage → gmail.users.messages.send
13
+ * - getProfile → gmail.users.getProfile (health + initial historyId)
14
+ * - deleteMessage → gmail.users.messages.trash (move to trash; matches `deleteMessage: true` capability)
15
+ */
16
+
17
+ const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1'
18
+
19
+ export interface GmailApiAuth {
20
+ accessToken: string
21
+ }
22
+
23
+ export interface GmailHistoryListInput {
24
+ startHistoryId: string
25
+ /** Optional page token for paging through history results. */
26
+ pageToken?: string
27
+ /** Optional label filter; defaults to INBOX-only changes. */
28
+ labelId?: string
29
+ }
30
+
31
+ export interface GmailHistoryRecord {
32
+ id: string
33
+ messagesAdded?: Array<{ message: { id: string; threadId: string; labelIds?: string[] } }>
34
+ messagesDeleted?: Array<{ message: { id: string; threadId: string } }>
35
+ labelsAdded?: Array<{ message: { id: string; threadId: string }; labelIds: string[] }>
36
+ labelsRemoved?: Array<{ message: { id: string; threadId: string }; labelIds: string[] }>
37
+ }
38
+
39
+ export interface GmailHistoryListResponse {
40
+ history?: GmailHistoryRecord[]
41
+ nextPageToken?: string
42
+ historyId: string
43
+ }
44
+
45
+ export interface GmailMessagesListInput {
46
+ query?: string
47
+ labelIds?: string[]
48
+ pageToken?: string
49
+ maxResults?: number
50
+ }
51
+
52
+ export interface GmailMessagesListResponse {
53
+ messages?: Array<{ id: string; threadId: string }>
54
+ nextPageToken?: string
55
+ resultSizeEstimate?: number
56
+ }
57
+
58
+ export interface GmailGetMessageRawResponse {
59
+ id: string
60
+ threadId: string
61
+ labelIds?: string[]
62
+ /** Base64URL-encoded RFC2822 message. */
63
+ raw: string
64
+ internalDate?: string
65
+ sizeEstimate?: number
66
+ }
67
+
68
+ export interface GmailSendRawInput {
69
+ /** Base64URL-encoded RFC2822 message body. */
70
+ rawBase64Url: string
71
+ /** Optional thread to attach to. */
72
+ threadId?: string
73
+ }
74
+
75
+ export interface GmailSendResponse {
76
+ id: string
77
+ threadId: string
78
+ labelIds?: string[]
79
+ }
80
+
81
+ export interface GmailProfileResponse {
82
+ emailAddress: string
83
+ messagesTotal?: number
84
+ threadsTotal?: number
85
+ historyId: string
86
+ }
87
+
88
+ export interface GmailWatchInput {
89
+ /** Fully-qualified Pub/Sub topic, e.g. `projects/myproj/topics/gmail-inbound`. */
90
+ topicName: string
91
+ /** Defaults to `['INBOX']` so only inbox changes generate notifications. */
92
+ labelIds?: string[]
93
+ /** `include` (default) or `exclude`. */
94
+ labelFilterAction?: 'include' | 'exclude'
95
+ }
96
+
97
+ export interface GmailWatchResponse {
98
+ historyId: string
99
+ /** Watch expiration timestamp, ms since epoch. Gmail caps at ~7 days. */
100
+ expiration: string
101
+ }
102
+
103
+ export interface GmailApiClient {
104
+ listHistory(auth: GmailApiAuth, input: GmailHistoryListInput): Promise<GmailHistoryListResponse>
105
+ listMessages(auth: GmailApiAuth, input: GmailMessagesListInput): Promise<GmailMessagesListResponse>
106
+ getMessageRaw(auth: GmailApiAuth, messageId: string): Promise<GmailGetMessageRawResponse>
107
+ sendRawMessage(auth: GmailApiAuth, input: GmailSendRawInput): Promise<GmailSendResponse>
108
+ getProfile(auth: GmailApiAuth): Promise<GmailProfileResponse>
109
+ trashMessage(auth: GmailApiAuth, messageId: string): Promise<void>
110
+ /** Spec C — `gmail.users.watch` registers a Pub/Sub topic for push delivery. */
111
+ watchInbox(auth: GmailApiAuth, input: GmailWatchInput): Promise<GmailWatchResponse>
112
+ /** Spec C — `gmail.users.stop` tears down the Pub/Sub registration. */
113
+ stopWatch(auth: GmailApiAuth): Promise<void>
114
+ }
115
+
116
+ const GMAIL_MAX_RETRIES = 3
117
+ const GMAIL_BACKOFF_BASE_MS = 500
118
+ const GMAIL_BACKOFF_CAP_MS = 8_000
119
+
120
+ class FetchGmailApiClient implements GmailApiClient {
121
+ async listHistory(auth: GmailApiAuth, input: GmailHistoryListInput): Promise<GmailHistoryListResponse> {
122
+ const url = new URL(`${GMAIL_API_BASE}/users/me/history`)
123
+ url.searchParams.set('startHistoryId', input.startHistoryId)
124
+ if (input.pageToken) url.searchParams.set('pageToken', input.pageToken)
125
+ url.searchParams.set('labelId', input.labelId ?? 'INBOX')
126
+ url.searchParams.set('historyTypes', 'messageAdded')
127
+ return this.requestJson<GmailHistoryListResponse>(auth, url, 'GET')
128
+ }
129
+
130
+ async listMessages(auth: GmailApiAuth, input: GmailMessagesListInput): Promise<GmailMessagesListResponse> {
131
+ const url = new URL(`${GMAIL_API_BASE}/users/me/messages`)
132
+ if (input.query) url.searchParams.set('q', input.query)
133
+ for (const label of input.labelIds ?? []) url.searchParams.append('labelIds', label)
134
+ if (input.pageToken) url.searchParams.set('pageToken', input.pageToken)
135
+ if (input.maxResults) url.searchParams.set('maxResults', String(input.maxResults))
136
+ return this.requestJson<GmailMessagesListResponse>(auth, url, 'GET')
137
+ }
138
+
139
+ async getMessageRaw(auth: GmailApiAuth, messageId: string): Promise<GmailGetMessageRawResponse> {
140
+ const url = new URL(`${GMAIL_API_BASE}/users/me/messages/${encodeURIComponent(messageId)}`)
141
+ url.searchParams.set('format', 'raw')
142
+ return this.requestJson<GmailGetMessageRawResponse>(auth, url, 'GET')
143
+ }
144
+
145
+ async sendRawMessage(auth: GmailApiAuth, input: GmailSendRawInput): Promise<GmailSendResponse> {
146
+ const url = new URL(`${GMAIL_API_BASE}/users/me/messages/send`)
147
+ return this.requestJson<GmailSendResponse>(auth, url, 'POST', {
148
+ raw: input.rawBase64Url,
149
+ threadId: input.threadId,
150
+ })
151
+ }
152
+
153
+ async getProfile(auth: GmailApiAuth): Promise<GmailProfileResponse> {
154
+ const url = new URL(`${GMAIL_API_BASE}/users/me/profile`)
155
+ return this.requestJson<GmailProfileResponse>(auth, url, 'GET')
156
+ }
157
+
158
+ async trashMessage(auth: GmailApiAuth, messageId: string): Promise<void> {
159
+ const url = new URL(`${GMAIL_API_BASE}/users/me/messages/${encodeURIComponent(messageId)}/trash`)
160
+ await this.requestJson(auth, url, 'POST')
161
+ }
162
+
163
+ async watchInbox(auth: GmailApiAuth, input: GmailWatchInput): Promise<GmailWatchResponse> {
164
+ const url = new URL(`${GMAIL_API_BASE}/users/me/watch`)
165
+ return this.requestJson<GmailWatchResponse>(auth, url, 'POST', {
166
+ topicName: input.topicName,
167
+ labelIds: input.labelIds ?? ['INBOX'],
168
+ labelFilterAction: input.labelFilterAction ?? 'include',
169
+ })
170
+ }
171
+
172
+ async stopWatch(auth: GmailApiAuth): Promise<void> {
173
+ const url = new URL(`${GMAIL_API_BASE}/users/me/stop`)
174
+ await this.requestJson(auth, url, 'POST')
175
+ }
176
+
177
+ private async requestJson<T>(auth: GmailApiAuth, url: URL, method: 'GET' | 'POST', body?: unknown): Promise<T> {
178
+ const headers: Record<string, string> = {
179
+ Authorization: `Bearer ${auth.accessToken}`,
180
+ }
181
+ let payload: BodyInit | undefined
182
+ if (body !== undefined) {
183
+ headers['Content-Type'] = 'application/json'
184
+ payload = JSON.stringify(body)
185
+ }
186
+ // Retry transient failures (429, 5xx) with exponential backoff + jitter,
187
+ // honoring `Retry-After` when present. Per Gmail API docs at
188
+ // https://developers.google.com/gmail/api/guides/handle-errors this is the
189
+ // documented mitigation for rate-limit + server-side transient errors.
190
+ let attempt = 0
191
+ let lastError: GmailApiError | null = null
192
+ while (attempt <= GMAIL_MAX_RETRIES) {
193
+ const res = await fetch(url.toString(), { method, headers, body: payload })
194
+ const text = await res.text()
195
+ if (res.ok) {
196
+ if (!text) return undefined as unknown as T
197
+ return JSON.parse(text) as T
198
+ }
199
+ const detail = parseErrorMessage(text) ?? `${res.status} ${res.statusText}`
200
+ const apiError = new GmailApiError(
201
+ `Gmail API ${method} ${url.pathname} failed: ${detail}`,
202
+ res.status,
203
+ detail,
204
+ )
205
+ const transient =
206
+ res.status === 429 ||
207
+ (res.status >= 500 && res.status < 600) ||
208
+ // Gmail signals per-user/project quota exhaustion with HTTP 403 +
209
+ // `rateLimitExceeded`/`userRateLimitExceeded` (not only 429).
210
+ (res.status === 403 && isRateLimit403(text))
211
+ if (!transient || attempt === GMAIL_MAX_RETRIES) {
212
+ throw apiError
213
+ }
214
+ lastError = apiError
215
+ const retryAfterHeader = res.headers.get('retry-after')
216
+ const waitMs =
217
+ parseRetryAfter(retryAfterHeader) ?? computeBackoff(attempt)
218
+ await sleep(waitMs)
219
+ attempt += 1
220
+ }
221
+ throw lastError ?? new GmailApiError(`Gmail API ${method} ${url.pathname} exhausted retries`, 599, 'retries exhausted')
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Gmail signals quota exhaustion with HTTP 403 + an error reason of
227
+ * `rateLimitExceeded` / `userRateLimitExceeded` (not only 429). Treat those as
228
+ * transient so the backoff/retry path applies; a genuine permission 403 (no
229
+ * rate-limit reason) stays non-retryable.
230
+ */
231
+ function isRateLimit403(body: string): boolean {
232
+ return /rateLimitExceeded|userRateLimitExceeded/i.test(body)
233
+ }
234
+
235
+ function parseRetryAfter(value: string | null): number | null {
236
+ if (!value) return null
237
+ const asNumber = Number(value)
238
+ if (Number.isFinite(asNumber) && asNumber >= 0) {
239
+ return Math.min(asNumber * 1000, GMAIL_BACKOFF_CAP_MS)
240
+ }
241
+ const asDate = Date.parse(value)
242
+ if (Number.isFinite(asDate)) {
243
+ const delta = asDate - Date.now()
244
+ if (delta > 0) return Math.min(delta, GMAIL_BACKOFF_CAP_MS)
245
+ }
246
+ return null
247
+ }
248
+
249
+ function computeBackoff(attempt: number): number {
250
+ const raw = GMAIL_BACKOFF_BASE_MS * Math.pow(2, attempt)
251
+ const jitter = Math.floor(Math.random() * 100)
252
+ return Math.min(raw + jitter, GMAIL_BACKOFF_CAP_MS)
253
+ }
254
+
255
+ function sleep(ms: number): Promise<void> {
256
+ return new Promise((resolve) => setTimeout(resolve, ms))
257
+ }
258
+
259
+ export class GmailApiError extends Error {
260
+ readonly status: number
261
+ readonly detail: string
262
+ constructor(message: string, status: number, detail: string) {
263
+ super(message)
264
+ this.name = 'GmailApiError'
265
+ this.status = status
266
+ this.detail = detail
267
+ }
268
+ }
269
+
270
+ function parseErrorMessage(text: string): string | null {
271
+ if (!text) return null
272
+ try {
273
+ const parsed = JSON.parse(text) as { error?: { message?: string } | string }
274
+ if (parsed && typeof parsed.error === 'object' && parsed.error && typeof parsed.error.message === 'string') {
275
+ return parsed.error.message
276
+ }
277
+ if (typeof parsed?.error === 'string') return parsed.error
278
+ } catch {
279
+ /* fall through */
280
+ }
281
+ return text.length > 200 ? text.slice(0, 200) : text
282
+ }
283
+
284
+ let cachedClient: GmailApiClient | null = null
285
+
286
+ export function getGmailApiClient(): GmailApiClient {
287
+ if (!cachedClient) cachedClient = new FetchGmailApiClient()
288
+ return cachedClient
289
+ }
290
+
291
+ export function setGmailApiClient(client: GmailApiClient | null): void {
292
+ cachedClient = client
293
+ }
294
+
295
+ /** Encode an RFC2822 message buffer to base64url as required by gmail.users.messages.send. */
296
+ export function encodeBase64Url(buffer: Buffer): string {
297
+ return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
298
+ }
299
+
300
+ /** Decode a base64url payload (e.g. `gmail.users.messages.get?format=raw`) to a buffer. */
301
+ export function decodeBase64Url(value: string): Buffer {
302
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
303
+ const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4))
304
+ return Buffer.from(normalized + padding, 'base64')
305
+ }
@@ -0,0 +1,14 @@
1
+ import { makeClientConfigHealthCheck } from '@open-mercato/core/modules/communication_channels/lib/provider-health'
2
+ import { gmailClientCredentialsSchema } from './credentials'
3
+
4
+ /**
5
+ * Liveness probe for the Gmail integration. The hub resolves it by the service
6
+ * name declared in `integration.ts` (`channelGmailHealthCheck`) and passes the
7
+ * tenant-scoped OAuth client config (`clientId` / `clientSecret`), NOT per-user
8
+ * channel tokens — so the probe just confirms the client config is well-formed.
9
+ * Per-user token validity surfaces on the channel itself (`requires_reauth`).
10
+ */
11
+ export const channelGmailHealthCheck = makeClientConfigHealthCheck({
12
+ schema: gmailClientCredentialsSchema,
13
+ providerLabel: 'Gmail',
14
+ })
@@ -0,0 +1,57 @@
1
+ import type { NormalizedInboundMessage } from '@open-mercato/core/modules/communication_channels/lib/adapter'
2
+ import {
3
+ normalizeMimeInbound,
4
+ type ParsedMail,
5
+ } from '@open-mercato/core/modules/communication_channels/lib/email-mime'
6
+
7
+ /**
8
+ * Prefix for the hub conversation ref of a Gmail-threaded conversation; the raw
9
+ * Gmail `threadId` follows it. Single-sourced here (where the ref is formed) and
10
+ * re-used by the outbound converter to recover the thread id for replies.
11
+ */
12
+ export const GMAIL_THREAD_REF_PREFIX = 'gmail-thread:'
13
+
14
+ /**
15
+ * Convert a Gmail `messages.get?format=raw` response to the hub's canonical
16
+ * `NormalizedInboundMessage`. Gmail returns the full RFC2822 message base64url-encoded,
17
+ * so we parse with `mailparser` (same library the IMAP provider uses) and let the
18
+ * shared `normalizeMimeInbound` helper handle threading / attachments / headers,
19
+ * layering in Gmail-specific metadata (`threadId`, `labelIds`, Gmail message id).
20
+ *
21
+ * Threading uses Gmail's `threadId` (more reliable than In-Reply-To inside
22
+ * Gmail's mailbox).
23
+ */
24
+
25
+ export interface NormalizeInboundGmailOptions {
26
+ rawMessage: Buffer
27
+ gmailMessageId: string
28
+ gmailThreadId: string
29
+ gmailLabelIds?: string[]
30
+ accountIdentifier: string
31
+ fallbackDate?: Date
32
+ }
33
+
34
+ export async function normalizeInboundGmailMessage(
35
+ options: NormalizeInboundGmailOptions,
36
+ ): Promise<NormalizedInboundMessage> {
37
+ const mailparser = (await import('mailparser')) as unknown as {
38
+ simpleParser: (buf: Buffer | string) => Promise<ParsedMail>
39
+ }
40
+ const parsed = await mailparser.simpleParser(options.rawMessage)
41
+
42
+ const gmailFields = {
43
+ gmailMessageId: options.gmailMessageId,
44
+ gmailThreadId: options.gmailThreadId,
45
+ gmailLabelIds: options.gmailLabelIds ?? [],
46
+ }
47
+ return normalizeMimeInbound({
48
+ parsed,
49
+ accountIdentifier: options.accountIdentifier,
50
+ fallbackMessageId: `gmail:${options.gmailMessageId}@${options.accountIdentifier}`,
51
+ // Gmail's threadId is authoritative for conversation grouping.
52
+ resolveConversationId: () => `${GMAIL_THREAD_REF_PREFIX}${options.gmailThreadId}`,
53
+ fallbackDate: options.fallbackDate,
54
+ channelMetadata: () => gmailFields,
55
+ channelPayload: () => gmailFields,
56
+ })
57
+ }