@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,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
|
+
})
|