@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,128 @@
1
+ import { convertOutboundForGmail } from '../convert-outbound'
2
+
3
+ describe('convertOutboundForGmail', () => {
4
+ it('assembles a multipart/alternative RFC2822 message when html and text both present', async () => {
5
+ const result = await convertOutboundForGmail({
6
+ body: '<p>Hello <b>world</b></p>',
7
+ bodyFormat: 'html',
8
+ channelMetadata: { subject: 'Hi', to: ['bob@example.com'] },
9
+ fromAddress: 'alice@gmail.com',
10
+ fromName: 'Alice',
11
+ })
12
+ const meta = result.metadata as Record<string, unknown>
13
+ const raw = (meta.rawMessage as Buffer).toString('utf-8')
14
+ expect(raw).toContain('From: "Alice" <alice@gmail.com>')
15
+ expect(raw).toContain('To: bob@example.com')
16
+ expect(raw).toContain('Subject: Hi')
17
+ expect(raw).toContain('MIME-Version: 1.0')
18
+ expect(raw).toContain('multipart/alternative')
19
+ expect(raw).toContain('Content-Type: text/plain')
20
+ expect(raw).toContain('Content-Type: text/html')
21
+ expect(raw).toContain('<p>Hello <b>world</b></p>')
22
+ })
23
+
24
+ it('preserves the inReplyTo / references headers when threading', async () => {
25
+ const result = await convertOutboundForGmail({
26
+ body: 'reply',
27
+ bodyFormat: 'text',
28
+ channelMetadata: {
29
+ to: ['root@example.com'],
30
+ inReplyTo: '<root@example.com>',
31
+ references: ['<root@example.com>', '<parent@example.com>'],
32
+ },
33
+ fromAddress: 'alice@gmail.com',
34
+ })
35
+ const meta = result.metadata as Record<string, unknown>
36
+ const raw = (meta.rawMessage as Buffer).toString('utf-8')
37
+ expect(raw).toContain('In-Reply-To: <root@example.com>')
38
+ expect(raw).toContain('References: <root@example.com> <parent@example.com>')
39
+ })
40
+
41
+ it('passes gmailThreadId through metadata.threadId', async () => {
42
+ const result = await convertOutboundForGmail({
43
+ body: 'hi',
44
+ bodyFormat: 'text',
45
+ channelMetadata: { to: 'bob@example.com', gmailThreadId: 'thread-abc' },
46
+ fromAddress: 'alice@gmail.com',
47
+ })
48
+ expect((result.metadata as Record<string, unknown>).threadId).toBe('thread-abc')
49
+ })
50
+
51
+ it('derives the Gmail threadId from the hub gmail-thread conversation ref', async () => {
52
+ const result = await convertOutboundForGmail({
53
+ body: 'reply',
54
+ bodyFormat: 'text',
55
+ channelMetadata: { to: ['bob@example.com'], thread_id: 'gmail-thread:18cabc123' },
56
+ fromAddress: 'alice@gmail.com',
57
+ })
58
+ expect((result.metadata as Record<string, unknown>).threadId).toBe('18cabc123')
59
+ })
60
+
61
+ it('leaves threadId unset for a new outbound thread ref so Gmail starts a fresh thread', async () => {
62
+ const result = await convertOutboundForGmail({
63
+ body: 'new',
64
+ bodyFormat: 'text',
65
+ channelMetadata: { to: ['bob@example.com'], thread_id: 'outbound:550e8400-e29b-41d4-a716-446655440000' },
66
+ fromAddress: 'alice@gmail.com',
67
+ })
68
+ expect((result.metadata as Record<string, unknown>).threadId).toBeUndefined()
69
+ })
70
+
71
+ it('preserves the Gmail threadId across the hub convert→send double-conversion', async () => {
72
+ const firstPass = await convertOutboundForGmail({
73
+ body: 'reply',
74
+ bodyFormat: 'text',
75
+ channelMetadata: { to: ['bob@example.com'], thread_id: 'gmail-thread:18cabc123' },
76
+ fromAddress: 'alice@gmail.com',
77
+ })
78
+ const secondPass = await convertOutboundForGmail({
79
+ body: 'reply',
80
+ bodyFormat: 'text',
81
+ channelMetadata: firstPass.metadata as Record<string, unknown>,
82
+ fromAddress: 'alice@gmail.com',
83
+ })
84
+ expect((secondPass.metadata as Record<string, unknown>).threadId).toBe('18cabc123')
85
+ })
86
+
87
+ it('rejects when there are no recipients', async () => {
88
+ await expect(
89
+ convertOutboundForGmail({
90
+ body: 'hi',
91
+ bodyFormat: 'text',
92
+ channelMetadata: {},
93
+ fromAddress: 'alice@gmail.com',
94
+ }),
95
+ ).rejects.toThrow(/at least one recipient/i)
96
+ })
97
+
98
+ it('auto-generates a Message-ID rooted in the From address when missing', async () => {
99
+ const result = await convertOutboundForGmail({
100
+ body: 'hi',
101
+ bodyFormat: 'text',
102
+ channelMetadata: { to: ['bob@example.com'] },
103
+ fromAddress: 'alice@gmail.com',
104
+ })
105
+ const meta = result.metadata as Record<string, unknown>
106
+ const generated = meta.messageId as string
107
+ expect(generated).toMatch(/^<[^@]+@gmail\.com>$/)
108
+ })
109
+
110
+ it('neutralizes CRLF header injection in subject / fromName', async () => {
111
+ const result = await convertOutboundForGmail({
112
+ body: 'hi',
113
+ bodyFormat: 'text',
114
+ channelMetadata: {
115
+ subject: 'Hello\r\nBcc: attacker@evil.com',
116
+ to: ['bob@example.com'],
117
+ },
118
+ fromAddress: 'alice@gmail.com',
119
+ fromName: 'Alice\r\nX-Injected: 1',
120
+ })
121
+ const meta = result.metadata as Record<string, unknown>
122
+ const raw = (meta.rawMessage as Buffer).toString('utf-8')
123
+ // The injected CRLF must collapse into the header value, not start a new header.
124
+ expect(raw).not.toMatch(/^Bcc: attacker@evil\.com/m)
125
+ expect(raw).not.toMatch(/^X-Injected:/m)
126
+ expect(raw).toMatch(/^Subject: /m)
127
+ })
128
+ })
@@ -0,0 +1,76 @@
1
+ import {
2
+ gmailClientCredentialsSchema,
3
+ gmailUserCredentialsSchema,
4
+ gmailChannelStateSchema,
5
+ GMAIL_DEFAULT_SCOPES,
6
+ parseScopes,
7
+ } from '../credentials'
8
+
9
+ describe('gmailClientCredentialsSchema', () => {
10
+ it('accepts a fully populated tenant OAuth client config', () => {
11
+ expect(
12
+ gmailClientCredentialsSchema.parse({
13
+ clientId: '1234.apps.googleusercontent.com',
14
+ clientSecret: 'secret',
15
+ scopes: 'https://www.googleapis.com/auth/gmail.modify',
16
+ }),
17
+ ).toMatchObject({ clientId: '1234.apps.googleusercontent.com', clientSecret: 'secret' })
18
+ })
19
+
20
+ it('rejects missing required fields', () => {
21
+ expect(() => gmailClientCredentialsSchema.parse({ clientId: '', clientSecret: 'x' })).toThrow(/OAuth Client ID/i)
22
+ expect(() => gmailClientCredentialsSchema.parse({ clientId: 'a', clientSecret: '' })).toThrow(/OAuth Client Secret/i)
23
+ })
24
+ })
25
+
26
+ describe('gmailUserCredentialsSchema', () => {
27
+ it('accepts an access+refresh token pair', () => {
28
+ expect(
29
+ gmailUserCredentialsSchema.parse({
30
+ accessToken: 'a',
31
+ refreshToken: 'r',
32
+ expiresAt: '2026-05-26T10:00:00.000Z',
33
+ scopes: ['https://www.googleapis.com/auth/gmail.modify'],
34
+ email: 'alice@gmail.com',
35
+ }),
36
+ ).toMatchObject({ accessToken: 'a', refreshToken: 'r', email: 'alice@gmail.com' })
37
+ })
38
+
39
+ it('treats refresh token as optional but access token as required', () => {
40
+ expect(gmailUserCredentialsSchema.parse({ accessToken: 'a' })).toMatchObject({ accessToken: 'a' })
41
+ expect(() => gmailUserCredentialsSchema.parse({ refreshToken: 'r' })).toThrow(/Access token/i)
42
+ })
43
+
44
+ it('rejects invalid expiresAt format', () => {
45
+ expect(() => gmailUserCredentialsSchema.parse({ accessToken: 'a', expiresAt: 'soon' })).toThrow()
46
+ })
47
+
48
+ it('rejects non-email email', () => {
49
+ expect(() => gmailUserCredentialsSchema.parse({ accessToken: 'a', email: 'not-an-email' })).toThrow()
50
+ })
51
+ })
52
+
53
+ describe('gmailChannelStateSchema', () => {
54
+ it('accepts a numeric or string historyId', () => {
55
+ expect(gmailChannelStateSchema.parse({ historyId: 12345 })).toMatchObject({ historyId: 12345 })
56
+ expect(gmailChannelStateSchema.parse({ historyId: '12345' })).toMatchObject({ historyId: '12345' })
57
+ })
58
+
59
+ it('accepts an empty state', () => {
60
+ expect(gmailChannelStateSchema.parse({})).toEqual({})
61
+ })
62
+ })
63
+
64
+ describe('parseScopes', () => {
65
+ it('returns Gmail defaults when input is blank or undefined', () => {
66
+ expect(parseScopes(undefined)).toEqual(GMAIL_DEFAULT_SCOPES)
67
+ expect(parseScopes('')).toEqual(GMAIL_DEFAULT_SCOPES)
68
+ expect(parseScopes(' ')).toEqual(GMAIL_DEFAULT_SCOPES)
69
+ })
70
+
71
+ it('splits comma and whitespace separated scope lists', () => {
72
+ expect(parseScopes('a,b,c')).toEqual(['a', 'b', 'c'])
73
+ expect(parseScopes('a b c')).toEqual(['a', 'b', 'c'])
74
+ expect(parseScopes('a, b, c\n d')).toEqual(['a', 'b', 'c', 'd'])
75
+ })
76
+ })
@@ -0,0 +1,209 @@
1
+ import { decodeBase64Url, encodeBase64Url, getGmailApiClient, GmailApiError, setGmailApiClient } from '../gmail-client'
2
+
3
+ describe('base64url encoding helpers', () => {
4
+ it('encodeBase64Url uses URL-safe alphabet without padding', () => {
5
+ const buffer = Buffer.from('Hello, world?', 'utf-8')
6
+ const encoded = encodeBase64Url(buffer)
7
+ expect(encoded).not.toContain('+')
8
+ expect(encoded).not.toContain('/')
9
+ expect(encoded).not.toContain('=')
10
+ expect(decodeBase64Url(encoded).toString('utf-8')).toBe('Hello, world?')
11
+ })
12
+
13
+ it('decodeBase64Url tolerates padded inputs', () => {
14
+ const buffer = Buffer.from('1', 'utf-8')
15
+ const encoded = buffer.toString('base64') // produces '1' → 'MQ=='
16
+ expect(decodeBase64Url(encoded).toString('utf-8')).toBe('1')
17
+ })
18
+
19
+ it('round-trips arbitrary binary data', () => {
20
+ const input = Buffer.from([0, 1, 2, 250, 251, 252, 253, 254, 255])
21
+ expect(decodeBase64Url(encodeBase64Url(input))).toEqual(input)
22
+ })
23
+ })
24
+
25
+ describe('GmailApiError', () => {
26
+ it('captures status + detail for downstream classification', () => {
27
+ const e = new GmailApiError('Gmail API GET /history failed: invalid_grant', 401, 'invalid_grant')
28
+ expect(e.name).toBe('GmailApiError')
29
+ expect(e.status).toBe(401)
30
+ expect(e.detail).toBe('invalid_grant')
31
+ })
32
+ })
33
+
34
+ type FakeResponseInit = {
35
+ ok?: boolean
36
+ status: number
37
+ statusText?: string
38
+ body?: string
39
+ headers?: Record<string, string>
40
+ }
41
+
42
+ function fakeResponse(init: FakeResponseInit): Response {
43
+ const headerMap = new Map(
44
+ Object.entries(init.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]),
45
+ )
46
+ return {
47
+ ok: init.ok ?? (init.status >= 200 && init.status < 300),
48
+ status: init.status,
49
+ statusText: init.statusText ?? '',
50
+ headers: { get: (name: string) => headerMap.get(name.toLowerCase()) ?? null },
51
+ text: async () => init.body ?? '',
52
+ } as unknown as Response
53
+ }
54
+
55
+ describe('FetchGmailApiClient.requestJson retry/backoff', () => {
56
+ const originalFetch = globalThis.fetch
57
+ const originalSetTimeout = globalThis.setTimeout
58
+ const originalRandom = Math.random
59
+ let capturedDelays: number[]
60
+
61
+ beforeEach(() => {
62
+ // Reset the cached singleton so each test gets the real FetchGmailApiClient
63
+ // (other suites may have swapped in a mock via setGmailApiClient).
64
+ setGmailApiClient(null)
65
+ capturedDelays = []
66
+ // Replace the backoff sleep with a synchronous no-wait shim that records the
67
+ // requested delay and fires the callback immediately, so the retry loop runs
68
+ // without real timers while we assert the computed wait durations.
69
+ globalThis.setTimeout = ((callback: () => void, ms?: number) => {
70
+ capturedDelays.push(typeof ms === 'number' ? ms : 0)
71
+ callback()
72
+ return 0 as unknown as ReturnType<typeof setTimeout>
73
+ }) as unknown as typeof globalThis.setTimeout
74
+ })
75
+
76
+ afterEach(() => {
77
+ globalThis.fetch = originalFetch
78
+ globalThis.setTimeout = originalSetTimeout
79
+ Math.random = originalRandom
80
+ setGmailApiClient(null)
81
+ })
82
+
83
+ it('retries a 429 then succeeds on the following 200', async () => {
84
+ let calls = 0
85
+ globalThis.fetch = (() => {
86
+ calls += 1
87
+ if (calls === 1) {
88
+ return Promise.resolve(
89
+ fakeResponse({ status: 429, statusText: 'Too Many Requests', body: '', headers: { 'retry-after': '2' } }),
90
+ )
91
+ }
92
+ return Promise.resolve(
93
+ fakeResponse({ status: 200, statusText: 'OK', body: JSON.stringify({ emailAddress: 'a@gmail.com', historyId: '7' }) }),
94
+ )
95
+ }) as unknown as typeof globalThis.fetch
96
+
97
+ const profile = await getGmailApiClient().getProfile({ accessToken: 'token' })
98
+
99
+ expect(calls).toBe(2)
100
+ expect(profile.historyId).toBe('7')
101
+ // Numeric Retry-After: `2` seconds → 2000ms, bounded by the 8s cap.
102
+ expect(capturedDelays).toEqual([2000])
103
+ })
104
+
105
+ it('retries a transient 503 then succeeds, honoring computeBackoff when no Retry-After header', async () => {
106
+ Math.random = () => 0 // strip jitter so the backoff is deterministic
107
+ let calls = 0
108
+ globalThis.fetch = (() => {
109
+ calls += 1
110
+ if (calls <= 2) {
111
+ return Promise.resolve(fakeResponse({ status: 503, statusText: 'Service Unavailable', body: '' }))
112
+ }
113
+ return Promise.resolve(
114
+ fakeResponse({ status: 200, statusText: 'OK', body: JSON.stringify({ emailAddress: 'a@gmail.com', historyId: '9' }) }),
115
+ )
116
+ }) as unknown as typeof globalThis.fetch
117
+
118
+ const profile = await getGmailApiClient().getProfile({ accessToken: 'token' })
119
+
120
+ expect(calls).toBe(3)
121
+ expect(profile.historyId).toBe('9')
122
+ // computeBackoff(attempt) = 500 * 2^attempt (+0 jitter): attempt 0 → 500ms, attempt 1 → 1000ms.
123
+ expect(capturedDelays).toEqual([500, 1000])
124
+ })
125
+
126
+ it('caps computeBackoff growth at the 8s ceiling across successive attempts', async () => {
127
+ Math.random = () => 0
128
+ // Always-5xx so the client exhausts all retries; this walks attempts 0..2 of
129
+ // computeBackoff (the 4th call has no further retry). 500, 1000, 2000 — all
130
+ // below the cap, but the doubling growth + ceiling math is exercised; assert
131
+ // each step is min(500 * 2^attempt, 8000) and monotonically non-decreasing.
132
+ globalThis.fetch = (() =>
133
+ Promise.resolve(fakeResponse({ status: 500, statusText: 'Server Error', body: '' }))) as unknown as typeof globalThis.fetch
134
+
135
+ await expect(getGmailApiClient().getProfile({ accessToken: 'token' })).rejects.toBeInstanceOf(GmailApiError)
136
+
137
+ expect(capturedDelays).toEqual([500, 1000, 2000])
138
+ for (const delay of capturedDelays) expect(delay).toBeLessThanOrEqual(8000)
139
+ for (let i = 1; i < capturedDelays.length; i += 1) {
140
+ expect(capturedDelays[i]).toBeGreaterThanOrEqual(capturedDelays[i - 1])
141
+ }
142
+ })
143
+
144
+ it('honors an HTTP-date Retry-After value, bounded by the 8s cap', async () => {
145
+ let calls = 0
146
+ // 3 seconds in the future → ~3000ms wait, still under the 8s ceiling.
147
+ const retryAt = new Date(Date.now() + 3_000).toUTCString()
148
+ globalThis.fetch = (() => {
149
+ calls += 1
150
+ if (calls === 1) {
151
+ return Promise.resolve(
152
+ fakeResponse({ status: 429, statusText: 'Too Many Requests', body: '', headers: { 'retry-after': retryAt } }),
153
+ )
154
+ }
155
+ return Promise.resolve(
156
+ fakeResponse({ status: 200, statusText: 'OK', body: JSON.stringify({ emailAddress: 'a@gmail.com', historyId: '1' }) }),
157
+ )
158
+ }) as unknown as typeof globalThis.fetch
159
+
160
+ await getGmailApiClient().getProfile({ accessToken: 'token' })
161
+
162
+ expect(calls).toBe(2)
163
+ expect(capturedDelays).toHaveLength(1)
164
+ // Date.parse(retryAt) drops sub-second precision, so the delta is ~2000-3000ms.
165
+ expect(capturedDelays[0]).toBeGreaterThan(1000)
166
+ expect(capturedDelays[0]).toBeLessThanOrEqual(8000)
167
+ })
168
+
169
+ it('throws GmailApiError carrying the upstream status after exhausting retries', async () => {
170
+ Math.random = () => 0
171
+ let calls = 0
172
+ globalThis.fetch = (() => {
173
+ calls += 1
174
+ return Promise.resolve(
175
+ fakeResponse({ status: 503, statusText: 'Service Unavailable', body: JSON.stringify({ error: { message: 'backend overloaded' } }) }),
176
+ )
177
+ }) as unknown as typeof globalThis.fetch
178
+
179
+ const thrown = await getGmailApiClient()
180
+ .getProfile({ accessToken: 'token' })
181
+ .catch((error: unknown) => error)
182
+
183
+ expect(thrown).toBeInstanceOf(GmailApiError)
184
+ expect((thrown as GmailApiError).status).toBe(503)
185
+ expect((thrown as GmailApiError).detail).toBe('backend overloaded')
186
+ // GMAIL_MAX_RETRIES = 3 → 1 initial + 3 retries = 4 total attempts.
187
+ expect(calls).toBe(4)
188
+ expect(capturedDelays).toEqual([500, 1000, 2000])
189
+ })
190
+
191
+ it('does not retry a permanent 401 — fails fast with no backoff', async () => {
192
+ let calls = 0
193
+ globalThis.fetch = (() => {
194
+ calls += 1
195
+ return Promise.resolve(
196
+ fakeResponse({ status: 401, statusText: 'Unauthorized', body: JSON.stringify({ error: { message: 'invalid_grant' } }) }),
197
+ )
198
+ }) as unknown as typeof globalThis.fetch
199
+
200
+ const thrown = await getGmailApiClient()
201
+ .getProfile({ accessToken: 'token' })
202
+ .catch((error: unknown) => error)
203
+
204
+ expect(thrown).toBeInstanceOf(GmailApiError)
205
+ expect((thrown as GmailApiError).status).toBe(401)
206
+ expect(calls).toBe(1)
207
+ expect(capturedDelays).toEqual([])
208
+ })
209
+ })
@@ -0,0 +1,106 @@
1
+ import { normalizeInboundGmailMessage } from '../normalize-inbound'
2
+
3
+ function buildMime(parts: {
4
+ messageId?: string
5
+ inReplyTo?: string
6
+ references?: string
7
+ subject?: string
8
+ from?: string
9
+ to?: string
10
+ date?: string
11
+ text?: string
12
+ html?: string
13
+ }): Buffer {
14
+ const headers: string[] = []
15
+ if (parts.messageId) headers.push(`Message-ID: ${parts.messageId}`)
16
+ if (parts.inReplyTo) headers.push(`In-Reply-To: ${parts.inReplyTo}`)
17
+ if (parts.references) headers.push(`References: ${parts.references}`)
18
+ if (parts.subject) headers.push(`Subject: ${parts.subject}`)
19
+ if (parts.from) headers.push(`From: ${parts.from}`)
20
+ if (parts.to) headers.push(`To: ${parts.to}`)
21
+ if (parts.date) headers.push(`Date: ${parts.date}`)
22
+ headers.push('MIME-Version: 1.0')
23
+ if (parts.html) {
24
+ headers.push('Content-Type: text/html; charset=utf-8')
25
+ return Buffer.from(headers.join('\r\n') + '\r\n\r\n' + parts.html, 'utf-8')
26
+ }
27
+ headers.push('Content-Type: text/plain; charset=utf-8')
28
+ return Buffer.from(headers.join('\r\n') + '\r\n\r\n' + (parts.text ?? ''), 'utf-8')
29
+ }
30
+
31
+ describe('normalizeInboundGmailMessage', () => {
32
+ it('uses Gmail threadId for externalConversationId, not the RFC2822 root', async () => {
33
+ const result = await normalizeInboundGmailMessage({
34
+ rawMessage: buildMime({
35
+ messageId: '<reply@example.com>',
36
+ inReplyTo: '<parent@example.com>',
37
+ references: '<root@example.com> <parent@example.com>',
38
+ from: '"Alice" <alice@gmail.com>',
39
+ to: 'bob@example.com',
40
+ subject: 'Re: original',
41
+ text: 'replying',
42
+ }),
43
+ gmailMessageId: 'gm-msg-100',
44
+ gmailThreadId: 'gm-thread-1',
45
+ gmailLabelIds: ['INBOX', 'IMPORTANT'],
46
+ accountIdentifier: 'bob@example.com',
47
+ })
48
+ expect(result.externalMessageId).toBe('reply@example.com')
49
+ expect(result.externalConversationId).toBe('gmail-thread:gm-thread-1')
50
+ expect(result.replyToExternalId).toBe('parent@example.com')
51
+ expect((result.channelMetadata as { gmailLabelIds: string[] }).gmailLabelIds).toEqual(['INBOX', 'IMPORTANT'])
52
+ })
53
+
54
+ it('synthesises a deterministic fallback message id when missing', async () => {
55
+ const result = await normalizeInboundGmailMessage({
56
+ rawMessage: buildMime({
57
+ from: 'eve@example.com',
58
+ to: 'bob@example.com',
59
+ subject: 'no id',
60
+ text: 'hi',
61
+ }),
62
+ gmailMessageId: 'gm-msg-7',
63
+ gmailThreadId: 'gm-thread-2',
64
+ accountIdentifier: 'bob@example.com',
65
+ })
66
+ expect(result.externalMessageId).toBe('gmail:gm-msg-7@bob@example.com')
67
+ expect(result.externalConversationId).toBe('gmail-thread:gm-thread-2')
68
+ })
69
+
70
+ it('prefers html body when html is present', async () => {
71
+ const result = await normalizeInboundGmailMessage({
72
+ rawMessage: buildMime({
73
+ messageId: '<html@example.com>',
74
+ from: 'alice@example.com',
75
+ to: 'bob@example.com',
76
+ subject: 'rich',
77
+ html: '<p><b>rich</b></p>',
78
+ }),
79
+ gmailMessageId: 'gm-msg-1',
80
+ gmailThreadId: 'gm-thread-1',
81
+ accountIdentifier: 'bob@example.com',
82
+ })
83
+ expect(result.bodyFormat).toBe('html')
84
+ expect(result.body).toContain('<b>rich</b>')
85
+ })
86
+
87
+ it('exposes Gmail ids via channelPayload for downstream widgets', async () => {
88
+ const result = await normalizeInboundGmailMessage({
89
+ rawMessage: buildMime({
90
+ messageId: '<msg@example.com>',
91
+ from: 'alice@example.com',
92
+ to: 'bob@example.com',
93
+ subject: 'subj',
94
+ text: 'hi',
95
+ }),
96
+ gmailMessageId: 'gm-msg-9',
97
+ gmailThreadId: 'gm-thread-9',
98
+ gmailLabelIds: ['INBOX'],
99
+ accountIdentifier: 'bob@example.com',
100
+ })
101
+ const payload = result.channelPayload as Record<string, unknown>
102
+ expect(payload.gmailMessageId).toBe('gm-msg-9')
103
+ expect(payload.gmailThreadId).toBe('gm-thread-9')
104
+ expect(payload.gmailLabelIds).toEqual(['INBOX'])
105
+ })
106
+ })
@@ -0,0 +1,148 @@
1
+ import {
2
+ GMAIL_OAUTH_AUTHORIZE_URL,
3
+ GMAIL_OAUTH_TOKEN_URL,
4
+ GMAIL_OAUTH_USERINFO_URL,
5
+ getGoogleOAuthClient,
6
+ setGoogleOAuthClient,
7
+ tokenResponseToExpiresAt,
8
+ } from '../oauth'
9
+
10
+ afterEach(() => setGoogleOAuthClient(null))
11
+
12
+ describe('buildAuthorizeUrl', () => {
13
+ it('builds a Google OAuth2 URL with all required params', () => {
14
+ const url = new URL(
15
+ getGoogleOAuthClient().buildAuthorizeUrl({
16
+ clientId: 'cid',
17
+ redirectUri: 'https://example.com/cb',
18
+ state: 'state-123',
19
+ scopes: ['https://www.googleapis.com/auth/gmail.modify'],
20
+ }),
21
+ )
22
+ expect(url.origin + url.pathname).toBe(GMAIL_OAUTH_AUTHORIZE_URL)
23
+ expect(url.searchParams.get('client_id')).toBe('cid')
24
+ expect(url.searchParams.get('redirect_uri')).toBe('https://example.com/cb')
25
+ expect(url.searchParams.get('response_type')).toBe('code')
26
+ expect(url.searchParams.get('state')).toBe('state-123')
27
+ expect(url.searchParams.get('access_type')).toBe('offline')
28
+ expect(url.searchParams.get('prompt')).toBe('consent')
29
+ expect(url.searchParams.get('include_granted_scopes')).toBe('true')
30
+ expect(url.searchParams.get('scope')).toBe('https://www.googleapis.com/auth/gmail.modify')
31
+ })
32
+
33
+ it('includes login_hint when provided', () => {
34
+ const url = new URL(
35
+ getGoogleOAuthClient().buildAuthorizeUrl({
36
+ clientId: 'cid',
37
+ redirectUri: 'https://example.com/cb',
38
+ state: 's',
39
+ scopes: ['scope'],
40
+ loginHint: 'alice@example.com',
41
+ }),
42
+ )
43
+ expect(url.searchParams.get('login_hint')).toBe('alice@example.com')
44
+ })
45
+
46
+ it('falls back to default scopes when scopes is empty', () => {
47
+ const url = new URL(
48
+ getGoogleOAuthClient().buildAuthorizeUrl({
49
+ clientId: 'cid',
50
+ redirectUri: 'https://example.com/cb',
51
+ state: 's',
52
+ scopes: [],
53
+ }),
54
+ )
55
+ expect(url.searchParams.get('scope')).toContain('gmail.modify')
56
+ })
57
+ })
58
+
59
+ describe('exchangeCode + refreshToken (transport-level)', () => {
60
+ const originalFetch = globalThis.fetch
61
+ afterEach(() => {
62
+ globalThis.fetch = originalFetch
63
+ })
64
+
65
+ it('POSTs form-encoded params and parses the token response', async () => {
66
+ const captured: { url?: string; body?: string; headers?: Record<string, string> } = {}
67
+ globalThis.fetch = ((url: string, init?: RequestInit) => {
68
+ captured.url = url
69
+ captured.body = typeof init?.body === 'string' ? init.body : ''
70
+ captured.headers = init?.headers as Record<string, string>
71
+ return Promise.resolve({
72
+ ok: true,
73
+ status: 200,
74
+ statusText: 'OK',
75
+ text: async () =>
76
+ JSON.stringify({
77
+ access_token: 'access',
78
+ refresh_token: 'refresh',
79
+ expires_in: 3600,
80
+ scope: 'https://www.googleapis.com/auth/gmail.modify',
81
+ token_type: 'Bearer',
82
+ }),
83
+ } as unknown as Response)
84
+ }) as unknown as typeof globalThis.fetch
85
+ const token = await getGoogleOAuthClient().exchangeCode({
86
+ clientId: 'cid',
87
+ clientSecret: 'secret',
88
+ redirectUri: 'https://example.com/cb',
89
+ code: 'code123',
90
+ })
91
+ expect(captured.url).toBe(GMAIL_OAUTH_TOKEN_URL)
92
+ expect(captured.body).toContain('grant_type=authorization_code')
93
+ expect(captured.body).toContain('code=code123')
94
+ expect(captured.body).toContain('client_id=cid')
95
+ expect(captured.body).toContain('client_secret=secret')
96
+ expect(token.access_token).toBe('access')
97
+ expect(token.refresh_token).toBe('refresh')
98
+ })
99
+
100
+ it('throws when token endpoint responds with error', async () => {
101
+ globalThis.fetch = (() =>
102
+ Promise.resolve({
103
+ ok: false,
104
+ status: 401,
105
+ statusText: 'Unauthorized',
106
+ text: async () => JSON.stringify({ error: 'invalid_grant', error_description: 'Token expired' }),
107
+ } as unknown as Response)) as unknown as typeof globalThis.fetch
108
+ await expect(
109
+ getGoogleOAuthClient().refreshToken({ clientId: 'c', clientSecret: 's', refreshToken: 'r' }),
110
+ ).rejects.toThrow(/Token expired|invalid_grant/i)
111
+ })
112
+ })
113
+
114
+ describe('fetchUserInfo', () => {
115
+ const originalFetch = globalThis.fetch
116
+ afterEach(() => {
117
+ globalThis.fetch = originalFetch
118
+ })
119
+ it('GETs the userinfo endpoint with Bearer auth and parses the response', async () => {
120
+ const captured: { url?: string; headers?: Record<string, string> } = {}
121
+ globalThis.fetch = ((url: string, init?: RequestInit) => {
122
+ captured.url = url
123
+ captured.headers = init?.headers as Record<string, string>
124
+ return Promise.resolve({
125
+ ok: true,
126
+ status: 200,
127
+ statusText: 'OK',
128
+ json: async () => ({ sub: 'g-123', email: 'alice@gmail.com', name: 'Alice' }),
129
+ } as unknown as Response)
130
+ }) as unknown as typeof globalThis.fetch
131
+ const info = await getGoogleOAuthClient().fetchUserInfo('access')
132
+ expect(captured.url).toBe(GMAIL_OAUTH_USERINFO_URL)
133
+ expect(captured.headers?.Authorization).toBe('Bearer access')
134
+ expect(info.email).toBe('alice@gmail.com')
135
+ })
136
+ })
137
+
138
+ describe('tokenResponseToExpiresAt', () => {
139
+ it('returns the absolute expiry derived from expires_in seconds', () => {
140
+ const t = tokenResponseToExpiresAt({ access_token: 'x', expires_in: 60 }, 1_700_000_000_000)
141
+ expect(t).toBeInstanceOf(Date)
142
+ expect(t!.getTime()).toBe(1_700_000_060_000)
143
+ })
144
+
145
+ it('returns undefined when expires_in missing', () => {
146
+ expect(tokenResponseToExpiresAt({ access_token: 'x' })).toBeUndefined()
147
+ })
148
+ })