@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,838 @@
|
|
|
1
|
+
import {
|
|
2
|
+
setGmailApiClient,
|
|
3
|
+
type GmailApiClient,
|
|
4
|
+
type GmailGetMessageRawResponse,
|
|
5
|
+
GmailApiError,
|
|
6
|
+
encodeBase64Url,
|
|
7
|
+
} from '../gmail-client'
|
|
8
|
+
import {
|
|
9
|
+
setGoogleOAuthClient,
|
|
10
|
+
type GoogleOAuthClient,
|
|
11
|
+
} from '../oauth'
|
|
12
|
+
import { getGmailChannelAdapter } from '../adapter'
|
|
13
|
+
import { gmailCapabilities } from '../capabilities'
|
|
14
|
+
|
|
15
|
+
const userCredentials = {
|
|
16
|
+
accessToken: 'access',
|
|
17
|
+
refreshToken: 'refresh',
|
|
18
|
+
expiresAt: '2026-05-26T10:00:00.000Z',
|
|
19
|
+
email: 'alice@gmail.com',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const clientCredentials = {
|
|
23
|
+
clientId: 'cid',
|
|
24
|
+
clientSecret: 'secret',
|
|
25
|
+
scopes: 'https://www.googleapis.com/auth/gmail.modify',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildRawMime(messageId: string, body: string): Buffer {
|
|
29
|
+
return Buffer.from(
|
|
30
|
+
[
|
|
31
|
+
`Message-ID: <${messageId}>`,
|
|
32
|
+
'From: alice@gmail.com',
|
|
33
|
+
'To: bob@example.com',
|
|
34
|
+
'Subject: Hello',
|
|
35
|
+
'Date: Wed, 21 May 2026 10:00:00 +0000',
|
|
36
|
+
'MIME-Version: 1.0',
|
|
37
|
+
'Content-Type: text/plain; charset=utf-8',
|
|
38
|
+
'',
|
|
39
|
+
body,
|
|
40
|
+
].join('\r\n'),
|
|
41
|
+
'utf-8',
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
setGmailApiClient(null)
|
|
47
|
+
setGoogleOAuthClient(null)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('GmailChannelAdapter wiring', () => {
|
|
51
|
+
it('exposes the right providerKey, channelType, and capabilities', () => {
|
|
52
|
+
const adapter = getGmailChannelAdapter()
|
|
53
|
+
expect(adapter.providerKey).toBe('gmail')
|
|
54
|
+
expect(adapter.channelType).toBe('email')
|
|
55
|
+
expect(adapter.capabilities).toBe(gmailCapabilities)
|
|
56
|
+
expect(adapter.capabilities.realtimePush).toBe(false)
|
|
57
|
+
expect(adapter.capabilities.reactions).toBe(false)
|
|
58
|
+
expect(adapter.capabilities.deleteMessage).toBe(true)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('exports the OAuth + refresh hooks but omits validateCredentials', () => {
|
|
62
|
+
const adapter = getGmailChannelAdapter()
|
|
63
|
+
expect(typeof adapter.buildOAuthAuthorizeUrl).toBe('function')
|
|
64
|
+
expect(typeof adapter.exchangeOAuthCode).toBe('function')
|
|
65
|
+
expect(typeof adapter.refreshCredentials).toBe('function')
|
|
66
|
+
expect(typeof adapter.fetchHistory).toBe('function')
|
|
67
|
+
expect(typeof adapter.deleteMessage).toBe('function')
|
|
68
|
+
expect(adapter.validateCredentials).toBeUndefined()
|
|
69
|
+
expect(adapter.sendReaction).toBeUndefined()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('GmailChannelAdapter push methods (Spec C)', () => {
|
|
74
|
+
function makeApi(overrides: Partial<GmailApiClient>): GmailApiClient {
|
|
75
|
+
return {
|
|
76
|
+
listHistory: async () => ({ historyId: '0' }),
|
|
77
|
+
listMessages: async () => ({}),
|
|
78
|
+
getMessageRaw: async () => ({ id: 'x', threadId: 'x', raw: '' }) as GmailGetMessageRawResponse,
|
|
79
|
+
sendRawMessage: async () => ({ id: 'x', threadId: 'x' }),
|
|
80
|
+
getProfile: async () => ({ emailAddress: 'alice@gmail.com', historyId: '100' }),
|
|
81
|
+
trashMessage: async () => undefined,
|
|
82
|
+
watchInbox: async () => ({ historyId: '200', expiration: String(Date.now() + 6 * 24 * 3600 * 1000) }),
|
|
83
|
+
stopWatch: async () => undefined,
|
|
84
|
+
...overrides,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
it('exposes registerPush, unregisterPush, applyPushNotification', () => {
|
|
89
|
+
const adapter = getGmailChannelAdapter()
|
|
90
|
+
expect(typeof adapter.registerPush).toBe('function')
|
|
91
|
+
expect(typeof adapter.unregisterPush).toBe('function')
|
|
92
|
+
expect(typeof adapter.applyPushNotification).toBe('function')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('registerPush returns active+state patch on success', async () => {
|
|
96
|
+
const watchCalls: Array<{ topicName: string; labelIds?: string[] }> = []
|
|
97
|
+
setGmailApiClient(
|
|
98
|
+
makeApi({
|
|
99
|
+
watchInbox: async (_auth, input) => {
|
|
100
|
+
watchCalls.push(input)
|
|
101
|
+
return { historyId: '999', expiration: String(Date.now() + 6 * 24 * 3600 * 1000) }
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
)
|
|
105
|
+
const adapter = getGmailChannelAdapter()
|
|
106
|
+
const result = await adapter.registerPush!({
|
|
107
|
+
channelId: 'c1',
|
|
108
|
+
credentials: userCredentials,
|
|
109
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
110
|
+
notificationUrl: 'https://app.example.com/api/communication_channels/webhooks/gmail',
|
|
111
|
+
providerConfig: { pubsubTopic: 'projects/p/topics/gmail-inbound' },
|
|
112
|
+
})
|
|
113
|
+
expect(result.status).toBe('active')
|
|
114
|
+
expect(result.channelStatePatch.historyId).toBe('999')
|
|
115
|
+
expect(result.channelStatePatch.pushStatus).toBe('active')
|
|
116
|
+
expect(result.channelStatePatch.pubsubTopic).toBe('projects/p/topics/gmail-inbound')
|
|
117
|
+
expect(result.recommendedPollIntervalSeconds).toBe(1800)
|
|
118
|
+
expect(watchCalls[0].topicName).toBe('projects/p/topics/gmail-inbound')
|
|
119
|
+
expect(watchCalls[0].labelIds).toEqual(['INBOX'])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('registerPush returns failed status when topic missing', async () => {
|
|
123
|
+
setGmailApiClient(makeApi({}))
|
|
124
|
+
const adapter = getGmailChannelAdapter()
|
|
125
|
+
const result = await adapter.registerPush!({
|
|
126
|
+
channelId: 'c1',
|
|
127
|
+
credentials: userCredentials,
|
|
128
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
129
|
+
notificationUrl: 'https://app.example.com/api/communication_channels/webhooks/gmail',
|
|
130
|
+
providerConfig: {},
|
|
131
|
+
})
|
|
132
|
+
expect(result.status).toBe('failed')
|
|
133
|
+
expect(result.channelStatePatch.pushStatus).toBe('failed')
|
|
134
|
+
expect(result.error?.code).toBe('missing_topic')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('registerPush reports failed when watch throws GmailApiError', async () => {
|
|
138
|
+
setGmailApiClient(
|
|
139
|
+
makeApi({
|
|
140
|
+
watchInbox: async () => {
|
|
141
|
+
throw new GmailApiError('forbidden', 403, 'forbidden')
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
)
|
|
145
|
+
const adapter = getGmailChannelAdapter()
|
|
146
|
+
const result = await adapter.registerPush!({
|
|
147
|
+
channelId: 'c1',
|
|
148
|
+
credentials: userCredentials,
|
|
149
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
150
|
+
notificationUrl: 'https://app.example.com/api/communication_channels/webhooks/gmail',
|
|
151
|
+
providerConfig: { pubsubTopic: 'projects/p/topics/inbound' },
|
|
152
|
+
})
|
|
153
|
+
expect(result.status).toBe('failed')
|
|
154
|
+
expect(result.error?.code).toBe('gmail_watch_403')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('unregisterPush calls stopWatch and swallows 404', async () => {
|
|
158
|
+
let stopCalls = 0
|
|
159
|
+
setGmailApiClient(
|
|
160
|
+
makeApi({
|
|
161
|
+
stopWatch: async () => {
|
|
162
|
+
stopCalls += 1
|
|
163
|
+
throw new GmailApiError('no watch', 404, 'not found')
|
|
164
|
+
},
|
|
165
|
+
}),
|
|
166
|
+
)
|
|
167
|
+
const adapter = getGmailChannelAdapter()
|
|
168
|
+
await adapter.unregisterPush!({
|
|
169
|
+
channelId: 'c1',
|
|
170
|
+
credentials: userCredentials,
|
|
171
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
172
|
+
channelState: {},
|
|
173
|
+
})
|
|
174
|
+
expect(stopCalls).toBe(1)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('unregisterPush rethrows non-404 errors', async () => {
|
|
178
|
+
setGmailApiClient(
|
|
179
|
+
makeApi({
|
|
180
|
+
stopWatch: async () => {
|
|
181
|
+
throw new GmailApiError('boom', 500, 'server')
|
|
182
|
+
},
|
|
183
|
+
}),
|
|
184
|
+
)
|
|
185
|
+
const adapter = getGmailChannelAdapter()
|
|
186
|
+
await expect(
|
|
187
|
+
adapter.unregisterPush!({
|
|
188
|
+
channelId: 'c1',
|
|
189
|
+
credentials: userCredentials,
|
|
190
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
191
|
+
channelState: {},
|
|
192
|
+
}),
|
|
193
|
+
).rejects.toThrow(/boom/)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('applyPushNotification delegates to fetchHistory and returns its page', async () => {
|
|
197
|
+
setGmailApiClient(
|
|
198
|
+
makeApi({
|
|
199
|
+
// No historyId in channelState → bootstrap branch returns 0 messages
|
|
200
|
+
// (matches Spec B § Gmail bootstrap behavior).
|
|
201
|
+
getProfile: async () => ({ emailAddress: 'alice@gmail.com', historyId: '500' }),
|
|
202
|
+
}),
|
|
203
|
+
)
|
|
204
|
+
const adapter = getGmailChannelAdapter()
|
|
205
|
+
const page = await adapter.applyPushNotification!({
|
|
206
|
+
credentials: userCredentials,
|
|
207
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
208
|
+
channelState: {},
|
|
209
|
+
notification: { emailAddress: 'alice@gmail.com', historyId: '500' },
|
|
210
|
+
})
|
|
211
|
+
expect(Array.isArray(page.messages)).toBe(true)
|
|
212
|
+
expect(page.hasMore).toBe(false)
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
describe('GmailChannelAdapter.deleteMessage', () => {
|
|
217
|
+
it('moves a Gmail message to trash via gmail.users.messages.trash', async () => {
|
|
218
|
+
const trashed: string[] = []
|
|
219
|
+
const api: GmailApiClient = {
|
|
220
|
+
...emptyApi(),
|
|
221
|
+
trashMessage: async (_auth, messageId) => {
|
|
222
|
+
trashed.push(messageId)
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
setGmailApiClient(api)
|
|
226
|
+
await getGmailChannelAdapter().deleteMessage!({
|
|
227
|
+
externalMessageId: 'gm-msg-42',
|
|
228
|
+
conversationId: 'gm-thread-1',
|
|
229
|
+
credentials: userCredentials,
|
|
230
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
231
|
+
})
|
|
232
|
+
expect(trashed).toEqual(['gm-msg-42'])
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
describe('GmailChannelAdapter.sendMessage', () => {
|
|
237
|
+
it('encodes RFC2822 + threadId and posts to gmail.users.messages.send', async () => {
|
|
238
|
+
const sent: Array<{ rawBase64Url: string; threadId?: string }> = []
|
|
239
|
+
const api: GmailApiClient = {
|
|
240
|
+
listHistory: async () => ({ historyId: '0' }),
|
|
241
|
+
listMessages: async () => ({}),
|
|
242
|
+
getMessageRaw: async () => ({ id: '', threadId: '', raw: '' }),
|
|
243
|
+
sendRawMessage: async (_auth, input) => {
|
|
244
|
+
sent.push({ rawBase64Url: input.rawBase64Url, threadId: input.threadId })
|
|
245
|
+
return { id: 'gm-out-1', threadId: 'gm-thread-out', labelIds: ['SENT'] }
|
|
246
|
+
},
|
|
247
|
+
getProfile: async () => ({ emailAddress: 'alice@gmail.com', historyId: '1' }),
|
|
248
|
+
trashMessage: async () => undefined,
|
|
249
|
+
}
|
|
250
|
+
setGmailApiClient(api)
|
|
251
|
+
const adapter = getGmailChannelAdapter()
|
|
252
|
+
const result = await adapter.sendMessage({
|
|
253
|
+
content: { html: '<p>hi</p>', bodyFormat: 'html' },
|
|
254
|
+
credentials: userCredentials,
|
|
255
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
256
|
+
metadata: { to: ['bob@example.com'], subject: 'Hi', gmailThreadId: 'gm-thread-1' },
|
|
257
|
+
})
|
|
258
|
+
expect(result.status).toBe('sent')
|
|
259
|
+
expect(result.externalMessageId).toMatch(/^<[^@]+@gmail\.com>$/)
|
|
260
|
+
expect(result.conversationId).toBe('gm-thread-out')
|
|
261
|
+
expect(sent).toHaveLength(1)
|
|
262
|
+
expect(sent[0].threadId).toBe('gm-thread-1')
|
|
263
|
+
// Decoded raw should contain our headers + body.
|
|
264
|
+
const decoded = Buffer.from(
|
|
265
|
+
sent[0].rawBase64Url.replace(/-/g, '+').replace(/_/g, '/') +
|
|
266
|
+
'='.repeat((4 - (sent[0].rawBase64Url.length % 4)) % 4),
|
|
267
|
+
'base64',
|
|
268
|
+
).toString('utf-8')
|
|
269
|
+
expect(decoded).toContain('To: bob@example.com')
|
|
270
|
+
expect(decoded).toContain('<p>hi</p>')
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('returns failed when send returns 401 (token expired)', async () => {
|
|
274
|
+
setGmailApiClient({
|
|
275
|
+
listHistory: async () => ({ historyId: '0' }),
|
|
276
|
+
listMessages: async () => ({}),
|
|
277
|
+
getMessageRaw: async () => ({ id: '', threadId: '', raw: '' }),
|
|
278
|
+
sendRawMessage: async () => {
|
|
279
|
+
throw new GmailApiError('Gmail API POST /send failed: token expired', 401, 'token expired')
|
|
280
|
+
},
|
|
281
|
+
getProfile: async () => ({ emailAddress: '', historyId: '0' }),
|
|
282
|
+
trashMessage: async () => undefined,
|
|
283
|
+
})
|
|
284
|
+
const result = await getGmailChannelAdapter().sendMessage({
|
|
285
|
+
content: { text: 'hi', bodyFormat: 'text' },
|
|
286
|
+
credentials: userCredentials,
|
|
287
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
288
|
+
metadata: { to: ['bob@example.com'] },
|
|
289
|
+
})
|
|
290
|
+
expect(result.status).toBe('failed')
|
|
291
|
+
expect(result.error).toBe('requires_reauth')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('returns failed when no recipients', async () => {
|
|
295
|
+
setGmailApiClient({
|
|
296
|
+
listHistory: async () => ({ historyId: '0' }),
|
|
297
|
+
listMessages: async () => ({}),
|
|
298
|
+
getMessageRaw: async () => ({ id: '', threadId: '', raw: '' }),
|
|
299
|
+
sendRawMessage: async () => ({ id: '', threadId: '' }),
|
|
300
|
+
getProfile: async () => ({ emailAddress: '', historyId: '0' }),
|
|
301
|
+
trashMessage: async () => undefined,
|
|
302
|
+
})
|
|
303
|
+
const result = await getGmailChannelAdapter().sendMessage({
|
|
304
|
+
content: { text: 'hi', bodyFormat: 'text' },
|
|
305
|
+
credentials: userCredentials,
|
|
306
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
307
|
+
metadata: {},
|
|
308
|
+
})
|
|
309
|
+
expect(result.status).toBe('failed')
|
|
310
|
+
expect(result.error).toMatch(/at least one recipient/i)
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
describe('GmailChannelAdapter OAuth flow', () => {
|
|
315
|
+
it('buildOAuthAuthorizeUrl delegates to the OAuth client + persists scopes in extra', async () => {
|
|
316
|
+
setGoogleOAuthClient(stubOAuth({ buildAuthorizeUrl: () => 'https://accounts.google.com/o/oauth2/v2/auth?...&state=s' }))
|
|
317
|
+
const adapter = getGmailChannelAdapter()
|
|
318
|
+
const result = await adapter.buildOAuthAuthorizeUrl!({
|
|
319
|
+
state: 's',
|
|
320
|
+
nonce: 'n',
|
|
321
|
+
redirectUri: 'https://example.com/cb',
|
|
322
|
+
credentials: clientCredentials,
|
|
323
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
324
|
+
loginHint: 'alice@example.com',
|
|
325
|
+
})
|
|
326
|
+
expect(result.authorizeUrl).toContain('https://accounts.google.com/o/oauth2/v2/auth')
|
|
327
|
+
expect(Array.isArray(result.extra?.scopes)).toBe(true)
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('exchangeOAuthCode persists tokens + fetches user email', async () => {
|
|
331
|
+
setGoogleOAuthClient(
|
|
332
|
+
stubOAuth({
|
|
333
|
+
exchangeCode: async () => ({
|
|
334
|
+
access_token: 'new-access',
|
|
335
|
+
refresh_token: 'new-refresh',
|
|
336
|
+
expires_in: 3600,
|
|
337
|
+
scope: 'https://www.googleapis.com/auth/gmail.modify',
|
|
338
|
+
token_type: 'Bearer',
|
|
339
|
+
}),
|
|
340
|
+
fetchUserInfo: async () => ({ email: 'alice@gmail.com', name: 'Alice' }),
|
|
341
|
+
}),
|
|
342
|
+
)
|
|
343
|
+
const result = await getGmailChannelAdapter().exchangeOAuthCode!({
|
|
344
|
+
code: 'code-1',
|
|
345
|
+
redirectUri: 'https://example.com/cb',
|
|
346
|
+
credentials: clientCredentials,
|
|
347
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
348
|
+
})
|
|
349
|
+
expect(result.externalIdentifier).toBe('alice@gmail.com')
|
|
350
|
+
expect(result.displayName).toBe('Alice')
|
|
351
|
+
expect((result.credentials as { accessToken: string }).accessToken).toBe('new-access')
|
|
352
|
+
expect((result.credentials as { refreshToken: string }).refreshToken).toBe('new-refresh')
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('refreshCredentials keeps the existing refresh token when Google does not return a new one', async () => {
|
|
356
|
+
setGoogleOAuthClient(
|
|
357
|
+
stubOAuth({
|
|
358
|
+
refreshToken: async () => ({
|
|
359
|
+
access_token: 'refreshed-access',
|
|
360
|
+
expires_in: 1800,
|
|
361
|
+
token_type: 'Bearer',
|
|
362
|
+
// No refresh_token in response — common case.
|
|
363
|
+
}),
|
|
364
|
+
}),
|
|
365
|
+
)
|
|
366
|
+
// Spec A: pass OAuth client via the new `oauthClient` field
|
|
367
|
+
// (resolved by the hub from `oauth_gmail` integration credentials).
|
|
368
|
+
const result = await getGmailChannelAdapter().refreshCredentials!({
|
|
369
|
+
channelId: 'channel-1',
|
|
370
|
+
credentials: userCredentials,
|
|
371
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
372
|
+
oauthClient: {
|
|
373
|
+
clientId: clientCredentials.clientId,
|
|
374
|
+
clientSecret: clientCredentials.clientSecret,
|
|
375
|
+
},
|
|
376
|
+
})
|
|
377
|
+
expect((result.credentials as { accessToken: string }).accessToken).toBe('refreshed-access')
|
|
378
|
+
expect((result.credentials as { refreshToken: string }).refreshToken).toBe('refresh')
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('refreshCredentials throws requires_reauth when refresh token is missing', async () => {
|
|
382
|
+
setGoogleOAuthClient(stubOAuth({}))
|
|
383
|
+
await expect(
|
|
384
|
+
getGmailChannelAdapter().refreshCredentials!({
|
|
385
|
+
channelId: 'channel-1',
|
|
386
|
+
credentials: { accessToken: 'a' },
|
|
387
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
388
|
+
oauthClient: {
|
|
389
|
+
clientId: clientCredentials.clientId,
|
|
390
|
+
clientSecret: clientCredentials.clientSecret,
|
|
391
|
+
},
|
|
392
|
+
}),
|
|
393
|
+
).rejects.toThrow(/requires_reauth/)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
// Spec A regression coverage — the new oauthClient path is the canonical
|
|
397
|
+
// production wiring; the legacy _client path remains for one minor
|
|
398
|
+
// release for backward compatibility.
|
|
399
|
+
describe('refreshCredentials — OAuth client wiring (Spec A)', () => {
|
|
400
|
+
it('refreshes successfully when oauthClient is provided (no _client on credentials)', async () => {
|
|
401
|
+
const refreshCalls: Array<{ clientId: string; clientSecret: string; refreshToken: string }> = []
|
|
402
|
+
setGoogleOAuthClient(
|
|
403
|
+
stubOAuth({
|
|
404
|
+
refreshToken: async (input) => {
|
|
405
|
+
refreshCalls.push(input)
|
|
406
|
+
return { access_token: 'new-access', expires_in: 1800, token_type: 'Bearer' }
|
|
407
|
+
},
|
|
408
|
+
}),
|
|
409
|
+
)
|
|
410
|
+
await getGmailChannelAdapter().refreshCredentials!({
|
|
411
|
+
channelId: 'channel-1',
|
|
412
|
+
credentials: userCredentials, // NO _client pre-packing
|
|
413
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
414
|
+
oauthClient: {
|
|
415
|
+
clientId: 'oauth-cid',
|
|
416
|
+
clientSecret: 'oauth-secret',
|
|
417
|
+
},
|
|
418
|
+
})
|
|
419
|
+
expect(refreshCalls).toEqual([
|
|
420
|
+
{
|
|
421
|
+
clientId: 'oauth-cid',
|
|
422
|
+
clientSecret: 'oauth-secret',
|
|
423
|
+
refreshToken: 'refresh',
|
|
424
|
+
},
|
|
425
|
+
])
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('falls back to legacy _client path with a deprecation warning when oauthClient is absent', async () => {
|
|
429
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
430
|
+
try {
|
|
431
|
+
setGoogleOAuthClient(
|
|
432
|
+
stubOAuth({
|
|
433
|
+
refreshToken: async () => ({ access_token: 'a', expires_in: 1800, token_type: 'Bearer' }),
|
|
434
|
+
}),
|
|
435
|
+
)
|
|
436
|
+
await getGmailChannelAdapter().refreshCredentials!({
|
|
437
|
+
channelId: 'channel-1',
|
|
438
|
+
credentials: { ...userCredentials, _client: clientCredentials },
|
|
439
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
440
|
+
})
|
|
441
|
+
// Legacy path emits a one-time deprecation warning per process.
|
|
442
|
+
expect(warn).toHaveBeenCalledWith(
|
|
443
|
+
expect.stringContaining('reading OAuth client config from credentials._client is deprecated'),
|
|
444
|
+
)
|
|
445
|
+
} finally {
|
|
446
|
+
warn.mockRestore()
|
|
447
|
+
}
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it('throws a clear error when neither oauthClient nor _client carries client config', async () => {
|
|
451
|
+
setGoogleOAuthClient(stubOAuth({}))
|
|
452
|
+
await expect(
|
|
453
|
+
getGmailChannelAdapter().refreshCredentials!({
|
|
454
|
+
channelId: 'channel-1',
|
|
455
|
+
credentials: userCredentials, // NO _client, NO oauthClient
|
|
456
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
457
|
+
}),
|
|
458
|
+
).rejects.toThrow(/Invalid Gmail OAuth client credentials/)
|
|
459
|
+
})
|
|
460
|
+
})
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
describe('GmailChannelAdapter.fetchHistory', () => {
|
|
464
|
+
it('bootstrap path: records profile.historyId and returns no messages', async () => {
|
|
465
|
+
const api: GmailApiClient = {
|
|
466
|
+
...emptyApi(),
|
|
467
|
+
getProfile: async () => ({ emailAddress: 'alice@gmail.com', historyId: '100' }),
|
|
468
|
+
}
|
|
469
|
+
setGmailApiClient(api)
|
|
470
|
+
const page = await getGmailChannelAdapter().fetchHistory!({
|
|
471
|
+
conversationId: 'inbox',
|
|
472
|
+
credentials: userCredentials,
|
|
473
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
474
|
+
} as Parameters<NonNullable<ReturnType<typeof getGmailChannelAdapter>['fetchHistory']>>[0])
|
|
475
|
+
expect(page.messages).toHaveLength(0)
|
|
476
|
+
const decoded = JSON.parse(Buffer.from(page.nextCursor!, 'base64').toString('utf-8'))
|
|
477
|
+
expect(decoded.historyId).toBe('100')
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it('incremental path: fetches added message bodies and updates the cursor', async () => {
|
|
481
|
+
const api: GmailApiClient = {
|
|
482
|
+
...emptyApi(),
|
|
483
|
+
listHistory: async () => ({
|
|
484
|
+
history: [
|
|
485
|
+
{
|
|
486
|
+
id: '200',
|
|
487
|
+
messagesAdded: [{ message: { id: 'gm-1', threadId: 'gm-t-1', labelIds: ['INBOX'] } }],
|
|
488
|
+
},
|
|
489
|
+
],
|
|
490
|
+
historyId: '201',
|
|
491
|
+
}),
|
|
492
|
+
getMessageRaw: async (_auth, id) => buildRawResponse(id, 'gm-t-1'),
|
|
493
|
+
}
|
|
494
|
+
setGmailApiClient(api)
|
|
495
|
+
const page = await getGmailChannelAdapter().fetchHistory!({
|
|
496
|
+
conversationId: 'inbox',
|
|
497
|
+
credentials: userCredentials,
|
|
498
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
499
|
+
...({ channelState: { historyId: '100' } } as unknown as Record<string, unknown>),
|
|
500
|
+
} as Parameters<NonNullable<ReturnType<typeof getGmailChannelAdapter>['fetchHistory']>>[0])
|
|
501
|
+
expect(page.messages).toHaveLength(1)
|
|
502
|
+
expect(page.messages[0].externalConversationId).toBe('gmail-thread:gm-t-1')
|
|
503
|
+
const decoded = JSON.parse(Buffer.from(page.nextCursor!, 'base64').toString('utf-8'))
|
|
504
|
+
expect(decoded.historyId).toBe('201')
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
it('falls back to messages.list when history returns 404 (expired cursor)', async () => {
|
|
508
|
+
const api: GmailApiClient = {
|
|
509
|
+
...emptyApi(),
|
|
510
|
+
listHistory: async () => {
|
|
511
|
+
throw new GmailApiError('history expired', 404, 'history expired')
|
|
512
|
+
},
|
|
513
|
+
listMessages: async () => ({
|
|
514
|
+
messages: [
|
|
515
|
+
{ id: 'gm-2', threadId: 'gm-t-2' },
|
|
516
|
+
{ id: 'gm-3', threadId: 'gm-t-3' },
|
|
517
|
+
],
|
|
518
|
+
}),
|
|
519
|
+
getMessageRaw: async (_auth, id) => buildRawResponse(id, `gm-t-${id.slice(-1)}`),
|
|
520
|
+
getProfile: async () => ({ emailAddress: 'alice@gmail.com', historyId: '999' }),
|
|
521
|
+
}
|
|
522
|
+
setGmailApiClient(api)
|
|
523
|
+
const page = await getGmailChannelAdapter().fetchHistory!({
|
|
524
|
+
conversationId: 'inbox',
|
|
525
|
+
credentials: userCredentials,
|
|
526
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
527
|
+
...({ channelState: { historyId: '100' } } as unknown as Record<string, unknown>),
|
|
528
|
+
} as Parameters<NonNullable<ReturnType<typeof getGmailChannelAdapter>['fetchHistory']>>[0])
|
|
529
|
+
expect(page.messages).toHaveLength(2)
|
|
530
|
+
const decoded = JSON.parse(Buffer.from(page.nextCursor!, 'base64').toString('utf-8'))
|
|
531
|
+
expect(decoded.historyId).toBe('999')
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it('L3: a transient getMessageRaw failure does not advance the cursor past unprocessed messages', async () => {
|
|
535
|
+
const api: GmailApiClient = {
|
|
536
|
+
...emptyApi(),
|
|
537
|
+
listHistory: async () => ({
|
|
538
|
+
history: [
|
|
539
|
+
{
|
|
540
|
+
id: '200',
|
|
541
|
+
messagesAdded: [
|
|
542
|
+
{ message: { id: 'gm-1', threadId: 'gm-t-1', labelIds: ['INBOX'] } },
|
|
543
|
+
{ message: { id: 'gm-2', threadId: 'gm-t-2', labelIds: ['INBOX'] } },
|
|
544
|
+
],
|
|
545
|
+
},
|
|
546
|
+
],
|
|
547
|
+
historyId: '201',
|
|
548
|
+
}),
|
|
549
|
+
getMessageRaw: async (_auth, id) => {
|
|
550
|
+
if (id === 'gm-2') throw new GmailApiError('server boom', 500, 'server')
|
|
551
|
+
return buildRawResponse(id, 'gm-t-1')
|
|
552
|
+
},
|
|
553
|
+
}
|
|
554
|
+
setGmailApiClient(api)
|
|
555
|
+
const page = await getGmailChannelAdapter().fetchHistory!({
|
|
556
|
+
conversationId: 'inbox',
|
|
557
|
+
credentials: userCredentials,
|
|
558
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
559
|
+
...({ channelState: { historyId: '100' } } as unknown as Record<string, unknown>),
|
|
560
|
+
} as Parameters<NonNullable<ReturnType<typeof getGmailChannelAdapter>['fetchHistory']>>[0])
|
|
561
|
+
// Only the message normalized BEFORE the failure is returned.
|
|
562
|
+
expect(page.messages).toHaveLength(1)
|
|
563
|
+
expect(page.messages[0].externalConversationId).toBe('gmail-thread:gm-t-1')
|
|
564
|
+
const decoded = JSON.parse(Buffer.from(page.nextCursor!, 'base64').toString('utf-8'))
|
|
565
|
+
// Cursor stays pinned to the start historyId — it must NOT advance to 201
|
|
566
|
+
// and skip the failed message.
|
|
567
|
+
expect(decoded.historyId).toBe('100')
|
|
568
|
+
// hub re-enqueues immediately so the failed message is retried next tick.
|
|
569
|
+
expect(page.hasMore).toBe(true)
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
it('multi-page drain: walks nextPageToken across pages without dropping messages', async () => {
|
|
573
|
+
const api: GmailApiClient = {
|
|
574
|
+
...emptyApi(),
|
|
575
|
+
listHistory: async (_auth, params) => {
|
|
576
|
+
if (params?.pageToken === 'page-2') {
|
|
577
|
+
return {
|
|
578
|
+
history: [{ id: '202', messagesAdded: [{ message: { id: 'gm-2', threadId: 'gm-t-2', labelIds: ['INBOX'] } }] }],
|
|
579
|
+
historyId: '203',
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
history: [{ id: '200', messagesAdded: [{ message: { id: 'gm-1', threadId: 'gm-t-1', labelIds: ['INBOX'] } }] }],
|
|
584
|
+
historyId: '201',
|
|
585
|
+
nextPageToken: 'page-2',
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
getMessageRaw: async (_auth, id) => buildRawResponse(id, `gm-t-${id.slice(-1)}`),
|
|
589
|
+
}
|
|
590
|
+
setGmailApiClient(api)
|
|
591
|
+
const page = await getGmailChannelAdapter().fetchHistory!({
|
|
592
|
+
conversationId: 'inbox',
|
|
593
|
+
credentials: userCredentials,
|
|
594
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
595
|
+
...({ channelState: { historyId: '100' } } as unknown as Record<string, unknown>),
|
|
596
|
+
} as Parameters<NonNullable<ReturnType<typeof getGmailChannelAdapter>['fetchHistory']>>[0])
|
|
597
|
+
expect(page.messages.map((m) => m.externalConversationId).sort()).toEqual([
|
|
598
|
+
'gmail-thread:gm-t-1',
|
|
599
|
+
'gmail-thread:gm-t-2',
|
|
600
|
+
])
|
|
601
|
+
const decoded = JSON.parse(Buffer.from(page.nextCursor!, 'base64').toString('utf-8'))
|
|
602
|
+
expect(decoded.historyId).toBe('203')
|
|
603
|
+
expect(page.hasMore).toBe(false)
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
it('per-page overflow: a single page carrying more refs than the limit drops none', async () => {
|
|
607
|
+
const api: GmailApiClient = {
|
|
608
|
+
...emptyApi(),
|
|
609
|
+
listHistory: async () => ({
|
|
610
|
+
history: [
|
|
611
|
+
{
|
|
612
|
+
id: '200',
|
|
613
|
+
messagesAdded: [
|
|
614
|
+
{ message: { id: 'gm-1', threadId: 'gm-t-1', labelIds: ['INBOX'] } },
|
|
615
|
+
{ message: { id: 'gm-2', threadId: 'gm-t-2', labelIds: ['INBOX'] } },
|
|
616
|
+
{ message: { id: 'gm-3', threadId: 'gm-t-3', labelIds: ['INBOX'] } },
|
|
617
|
+
],
|
|
618
|
+
},
|
|
619
|
+
],
|
|
620
|
+
historyId: '201',
|
|
621
|
+
}),
|
|
622
|
+
getMessageRaw: async (_auth, id) => buildRawResponse(id, `gm-t-${id.slice(-1)}`),
|
|
623
|
+
}
|
|
624
|
+
setGmailApiClient(api)
|
|
625
|
+
const page = await getGmailChannelAdapter().fetchHistory!({
|
|
626
|
+
conversationId: 'inbox',
|
|
627
|
+
credentials: userCredentials,
|
|
628
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
629
|
+
limit: 2,
|
|
630
|
+
...({ channelState: { historyId: '100' } } as unknown as Record<string, unknown>),
|
|
631
|
+
} as Parameters<NonNullable<ReturnType<typeof getGmailChannelAdapter>['fetchHistory']>>[0])
|
|
632
|
+
expect(page.messages).toHaveLength(3)
|
|
633
|
+
const decoded = JSON.parse(Buffer.from(page.nextCursor!, 'base64').toString('utf-8'))
|
|
634
|
+
expect(decoded.historyId).toBe('201')
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
it('L3 across pages: a transient failure restarts the window without a forward page-token skip', async () => {
|
|
638
|
+
const api: GmailApiClient = {
|
|
639
|
+
...emptyApi(),
|
|
640
|
+
listHistory: async (_auth, params) => {
|
|
641
|
+
if (params?.pageToken === 'page-2') {
|
|
642
|
+
return {
|
|
643
|
+
history: [{ id: '202', messagesAdded: [{ message: { id: 'gm-2', threadId: 'gm-t-2', labelIds: ['INBOX'] } }] }],
|
|
644
|
+
historyId: '203',
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
history: [{ id: '200', messagesAdded: [{ message: { id: 'gm-1', threadId: 'gm-t-1', labelIds: ['INBOX'] } }] }],
|
|
649
|
+
historyId: '201',
|
|
650
|
+
nextPageToken: 'page-2',
|
|
651
|
+
}
|
|
652
|
+
},
|
|
653
|
+
getMessageRaw: async (_auth, id) => {
|
|
654
|
+
if (id === 'gm-2') throw new GmailApiError('server boom', 500, 'server')
|
|
655
|
+
return buildRawResponse(id, 'gm-t-1')
|
|
656
|
+
},
|
|
657
|
+
}
|
|
658
|
+
setGmailApiClient(api)
|
|
659
|
+
const page = await getGmailChannelAdapter().fetchHistory!({
|
|
660
|
+
conversationId: 'inbox',
|
|
661
|
+
credentials: userCredentials,
|
|
662
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
663
|
+
...({ channelState: { historyId: '100' } } as unknown as Record<string, unknown>),
|
|
664
|
+
} as Parameters<NonNullable<ReturnType<typeof getGmailChannelAdapter>['fetchHistory']>>[0])
|
|
665
|
+
expect(page.messages).toHaveLength(1)
|
|
666
|
+
const decoded = JSON.parse(Buffer.from(page.nextCursor!, 'base64').toString('utf-8'))
|
|
667
|
+
expect(decoded.historyId).toBe('100')
|
|
668
|
+
expect(decoded.pendingHistoryPageToken).toBeUndefined()
|
|
669
|
+
expect(page.hasMore).toBe(true)
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
it('L3 first-page retry: a pinned history snapshot (no page token) re-enters the fallback scan instead of bootstrapping', async () => {
|
|
673
|
+
const listCalls: Array<{ labelIds?: string[]; pageToken?: string }> = []
|
|
674
|
+
let getProfileCalls = 0
|
|
675
|
+
const api: GmailApiClient = {
|
|
676
|
+
...emptyApi(),
|
|
677
|
+
getProfile: async () => {
|
|
678
|
+
getProfileCalls += 1
|
|
679
|
+
return { emailAddress: 'alice@gmail.com', historyId: '999' }
|
|
680
|
+
},
|
|
681
|
+
listMessages: async (_auth, params) => {
|
|
682
|
+
listCalls.push(params ?? {})
|
|
683
|
+
return {
|
|
684
|
+
messages: [
|
|
685
|
+
{ id: 'gm-2', threadId: 'gm-t-2' },
|
|
686
|
+
{ id: 'gm-3', threadId: 'gm-t-3' },
|
|
687
|
+
],
|
|
688
|
+
}
|
|
689
|
+
},
|
|
690
|
+
getMessageRaw: async (_auth, id) => buildRawResponse(id, `gm-t-${id.slice(-1)}`),
|
|
691
|
+
}
|
|
692
|
+
setGmailApiClient(api)
|
|
693
|
+
const page = await getGmailChannelAdapter().fetchHistory!({
|
|
694
|
+
conversationId: 'inbox',
|
|
695
|
+
credentials: userCredentials,
|
|
696
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
697
|
+
...({ channelState: { pendingMessagesHistoryIdSnapshot: '555' } } as unknown as Record<string, unknown>),
|
|
698
|
+
} as Parameters<NonNullable<ReturnType<typeof getGmailChannelAdapter>['fetchHistory']>>[0])
|
|
699
|
+
// Re-enters the messages.list fallback scan from the first INBOX page...
|
|
700
|
+
expect(listCalls).toHaveLength(1)
|
|
701
|
+
expect(listCalls[0].labelIds).toEqual(['INBOX'])
|
|
702
|
+
expect(listCalls[0].pageToken).toBeUndefined()
|
|
703
|
+
expect(page.messages).toHaveLength(2)
|
|
704
|
+
// ...and does NOT re-bootstrap (no profile fetch, snapshot preserved as the cursor).
|
|
705
|
+
expect(getProfileCalls).toBe(0)
|
|
706
|
+
const decoded = JSON.parse(Buffer.from(page.nextCursor!, 'base64').toString('utf-8'))
|
|
707
|
+
expect(decoded.historyId).toBe('555')
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
it('L3 first-page retry: a hard-failed first fallback page is retried on the next tick, not skipped via bootstrap', async () => {
|
|
711
|
+
let listCalls = 0
|
|
712
|
+
let getProfileCalls = 0
|
|
713
|
+
const api: GmailApiClient = {
|
|
714
|
+
...emptyApi(),
|
|
715
|
+
listHistory: async () => {
|
|
716
|
+
throw new GmailApiError('history expired', 404, 'history expired')
|
|
717
|
+
},
|
|
718
|
+
getProfile: async () => {
|
|
719
|
+
getProfileCalls += 1
|
|
720
|
+
return { emailAddress: 'alice@gmail.com', historyId: '999' }
|
|
721
|
+
},
|
|
722
|
+
listMessages: async () => {
|
|
723
|
+
listCalls += 1
|
|
724
|
+
return {
|
|
725
|
+
messages: [
|
|
726
|
+
{ id: 'gm-1', threadId: 'gm-t-1' },
|
|
727
|
+
{ id: 'gm-2', threadId: 'gm-t-2' },
|
|
728
|
+
],
|
|
729
|
+
}
|
|
730
|
+
},
|
|
731
|
+
getMessageRaw: async (_auth, id) => {
|
|
732
|
+
if (id === 'gm-2') throw new GmailApiError('server boom', 500, 'server')
|
|
733
|
+
return buildRawResponse(id, 'gm-t-1')
|
|
734
|
+
},
|
|
735
|
+
}
|
|
736
|
+
setGmailApiClient(api)
|
|
737
|
+
const adapter = getGmailChannelAdapter()
|
|
738
|
+
// Tick 1: history.list 404 → first fallback page hard-fails on gm-2. Only
|
|
739
|
+
// the snapshot is pinned; the cursor must NOT advance past the unprocessed
|
|
740
|
+
// messages.
|
|
741
|
+
const first = await adapter.fetchHistory!({
|
|
742
|
+
conversationId: 'inbox',
|
|
743
|
+
credentials: userCredentials,
|
|
744
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
745
|
+
...({ channelState: { historyId: '100' } } as unknown as Record<string, unknown>),
|
|
746
|
+
} as Parameters<NonNullable<ReturnType<typeof getGmailChannelAdapter>['fetchHistory']>>[0])
|
|
747
|
+
expect(first.messages).toHaveLength(1)
|
|
748
|
+
expect(first.hasMore).toBe(true)
|
|
749
|
+
const firstDecoded = JSON.parse(Buffer.from(first.nextCursor!, 'base64').toString('utf-8'))
|
|
750
|
+
expect(firstDecoded.historyId).toBeUndefined()
|
|
751
|
+
expect(firstDecoded.pendingMessagesPageToken).toBeUndefined()
|
|
752
|
+
expect(firstDecoded.pendingMessagesHistoryIdSnapshot).toBe('999')
|
|
753
|
+
const listAfterFirst = listCalls
|
|
754
|
+
const profileAfterFirst = getProfileCalls
|
|
755
|
+
// Tick 2: feed the orphaned cursor back in. It must re-enter the fallback
|
|
756
|
+
// scan (listMessages called again) rather than bootstrap (getProfile must
|
|
757
|
+
// NOT be called again).
|
|
758
|
+
const second = await adapter.fetchHistory!({
|
|
759
|
+
conversationId: 'inbox',
|
|
760
|
+
credentials: userCredentials,
|
|
761
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
762
|
+
...({ channelState: firstDecoded } as unknown as Record<string, unknown>),
|
|
763
|
+
} as Parameters<NonNullable<ReturnType<typeof getGmailChannelAdapter>['fetchHistory']>>[0])
|
|
764
|
+
expect(listCalls).toBe(listAfterFirst + 1)
|
|
765
|
+
expect(getProfileCalls).toBe(profileAfterFirst)
|
|
766
|
+
expect(second.messages).toHaveLength(1)
|
|
767
|
+
})
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
describe('GmailChannelAdapter.normalizeInbound + verifyWebhook + resolveContact', () => {
|
|
771
|
+
it('normalizeInbound accepts rawBase64Url payloads', async () => {
|
|
772
|
+
const mime = buildRawMime('msg-1', 'hello')
|
|
773
|
+
const result = await getGmailChannelAdapter().normalizeInbound({
|
|
774
|
+
raw: {
|
|
775
|
+
rawBase64Url: encodeBase64Url(mime),
|
|
776
|
+
gmailMessageId: 'gm-1',
|
|
777
|
+
gmailThreadId: 'gm-t-1',
|
|
778
|
+
accountIdentifier: 'alice@gmail.com',
|
|
779
|
+
},
|
|
780
|
+
eventType: 'message',
|
|
781
|
+
})
|
|
782
|
+
expect(result.externalMessageId).toBe('msg-1')
|
|
783
|
+
expect(result.externalConversationId).toBe('gmail-thread:gm-t-1')
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
it('verifyWebhook returns a non-message event', async () => {
|
|
787
|
+
const event = await getGmailChannelAdapter().verifyWebhook({
|
|
788
|
+
rawBody: '',
|
|
789
|
+
headers: {},
|
|
790
|
+
credentials: userCredentials,
|
|
791
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
792
|
+
})
|
|
793
|
+
expect(event.eventType).toBe('other')
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
it('resolveContact returns email hint for email-shaped identifiers', async () => {
|
|
797
|
+
const hint = await getGmailChannelAdapter().resolveContact!({
|
|
798
|
+
senderIdentifier: 'eve@example.com',
|
|
799
|
+
credentials: userCredentials,
|
|
800
|
+
scope: { tenantId: 't', organizationId: 'o' },
|
|
801
|
+
})
|
|
802
|
+
expect(hint).toEqual({ email: 'eve@example.com', displayName: undefined })
|
|
803
|
+
})
|
|
804
|
+
})
|
|
805
|
+
|
|
806
|
+
function emptyApi(): GmailApiClient {
|
|
807
|
+
return {
|
|
808
|
+
listHistory: async () => ({ historyId: '0' }),
|
|
809
|
+
listMessages: async () => ({}),
|
|
810
|
+
getMessageRaw: async () => ({ id: '', threadId: '', raw: '' }),
|
|
811
|
+
sendRawMessage: async () => ({ id: '', threadId: '' }),
|
|
812
|
+
getProfile: async () => ({ emailAddress: '', historyId: '0' }),
|
|
813
|
+
trashMessage: async () => undefined,
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function buildRawResponse(id: string, threadId: string): GmailGetMessageRawResponse {
|
|
818
|
+
return {
|
|
819
|
+
id,
|
|
820
|
+
threadId,
|
|
821
|
+
raw: encodeBase64Url(buildRawMime(`${id}@example.com`, `body ${id}`)),
|
|
822
|
+
labelIds: ['INBOX'],
|
|
823
|
+
internalDate: String(Date.now()),
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function stubOAuth(overrides: Partial<GoogleOAuthClient>): GoogleOAuthClient {
|
|
828
|
+
return {
|
|
829
|
+
buildAuthorizeUrl: overrides.buildAuthorizeUrl ?? (() => 'https://accounts.google.com/o/oauth2/v2/auth'),
|
|
830
|
+
exchangeCode:
|
|
831
|
+
overrides.exchangeCode ??
|
|
832
|
+
(async () => ({ access_token: 'x', token_type: 'Bearer' })),
|
|
833
|
+
refreshToken:
|
|
834
|
+
overrides.refreshToken ??
|
|
835
|
+
(async () => ({ access_token: 'x', token_type: 'Bearer' })),
|
|
836
|
+
fetchUserInfo: overrides.fetchUserInfo ?? (async () => ({})),
|
|
837
|
+
}
|
|
838
|
+
}
|