@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.
- package/.turbo/turbo-build.log +2 -0
- package/AGENTS.md +47 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-006.spec.js +17 -0
- package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-006.spec.js.map +7 -0
- package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-007.spec.js +16 -0
- package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-007.spec.js.map +7 -0
- package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-008.spec.js +16 -0
- package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-008.spec.js.map +7 -0
- package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-A01-token-refresh.spec.js +17 -0
- package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-A01-token-refresh.spec.js.map +7 -0
- package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-C01.spec.js +26 -0
- package/dist/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-C01.spec.js.map +7 -0
- package/dist/modules/channel_gmail/acl.js +10 -0
- package/dist/modules/channel_gmail/acl.js.map +7 -0
- package/dist/modules/channel_gmail/di.js +23 -0
- package/dist/modules/channel_gmail/di.js.map +7 -0
- package/dist/modules/channel_gmail/index.js +9 -0
- package/dist/modules/channel_gmail/index.js.map +7 -0
- package/dist/modules/channel_gmail/integration.js +69 -0
- package/dist/modules/channel_gmail/integration.js.map +7 -0
- package/dist/modules/channel_gmail/lib/adapter.js +542 -0
- package/dist/modules/channel_gmail/lib/adapter.js.map +7 -0
- package/dist/modules/channel_gmail/lib/capabilities.js +10 -0
- package/dist/modules/channel_gmail/lib/capabilities.js.map +7 -0
- package/dist/modules/channel_gmail/lib/convert-outbound.js +84 -0
- package/dist/modules/channel_gmail/lib/convert-outbound.js.map +7 -0
- package/dist/modules/channel_gmail/lib/credentials.js +48 -0
- package/dist/modules/channel_gmail/lib/credentials.js.map +7 -0
- package/dist/modules/channel_gmail/lib/gmail-client.js +160 -0
- package/dist/modules/channel_gmail/lib/gmail-client.js.map +7 -0
- package/dist/modules/channel_gmail/lib/health.js +10 -0
- package/dist/modules/channel_gmail/lib/health.js.map +7 -0
- package/dist/modules/channel_gmail/lib/normalize-inbound.js +28 -0
- package/dist/modules/channel_gmail/lib/normalize-inbound.js.map +7 -0
- package/dist/modules/channel_gmail/lib/oauth.js +77 -0
- package/dist/modules/channel_gmail/lib/oauth.js.map +7 -0
- package/dist/modules/channel_gmail/setup.js +25 -0
- package/dist/modules/channel_gmail/setup.js.map +7 -0
- package/dist/modules/channel_gmail/widgets/injection/connect/widget.client.js +24 -0
- package/dist/modules/channel_gmail/widgets/injection/connect/widget.client.js.map +7 -0
- package/dist/modules/channel_gmail/widgets/injection/connect/widget.js +17 -0
- package/dist/modules/channel_gmail/widgets/injection/connect/widget.js.map +7 -0
- package/dist/modules/channel_gmail/widgets/injection-table.js +14 -0
- package/dist/modules/channel_gmail/widgets/injection-table.js.map +7 -0
- package/jest.config.cjs +34 -0
- package/package.json +95 -0
- package/src/index.ts +1 -0
- package/src/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-006.spec.ts +24 -0
- package/src/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-007.spec.ts +23 -0
- package/src/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-008.spec.ts +23 -0
- package/src/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-A01-token-refresh.spec.ts +39 -0
- package/src/modules/channel_gmail/__integration__/TC-CHANNEL-EMAIL-C01.spec.ts +48 -0
- package/src/modules/channel_gmail/acl.ts +6 -0
- package/src/modules/channel_gmail/di.ts +21 -0
- package/src/modules/channel_gmail/index.ts +6 -0
- package/src/modules/channel_gmail/integration.ts +67 -0
- package/src/modules/channel_gmail/lib/__tests__/adapter.test.ts +838 -0
- package/src/modules/channel_gmail/lib/__tests__/convert-outbound.test.ts +128 -0
- package/src/modules/channel_gmail/lib/__tests__/credentials.test.ts +76 -0
- package/src/modules/channel_gmail/lib/__tests__/gmail-client.test.ts +209 -0
- package/src/modules/channel_gmail/lib/__tests__/normalize-inbound.test.ts +106 -0
- package/src/modules/channel_gmail/lib/__tests__/oauth.test.ts +148 -0
- package/src/modules/channel_gmail/lib/adapter.ts +734 -0
- package/src/modules/channel_gmail/lib/capabilities.ts +22 -0
- package/src/modules/channel_gmail/lib/convert-outbound.ts +136 -0
- package/src/modules/channel_gmail/lib/credentials.ts +90 -0
- package/src/modules/channel_gmail/lib/gmail-client.ts +305 -0
- package/src/modules/channel_gmail/lib/health.ts +14 -0
- package/src/modules/channel_gmail/lib/normalize-inbound.ts +57 -0
- package/src/modules/channel_gmail/lib/oauth.ts +128 -0
- package/src/modules/channel_gmail/setup.ts +36 -0
- package/src/modules/channel_gmail/widgets/injection/connect/widget.client.tsx +28 -0
- package/src/modules/channel_gmail/widgets/injection/connect/widget.ts +16 -0
- package/src/modules/channel_gmail/widgets/injection-table.ts +12 -0
- package/tsconfig.json +9 -0
- 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
|
+
}
|