Skip to content

Commit 9e9565c

Browse files
committed
fix(org): key the Gmail mailbox cache on the provider subject
Managed credentials always store a null account id, so the key never told accounts apart. The subject is what a reconnect actually changes on a managed credential.
1 parent a073ee5 commit 9e9565c

4 files changed

Lines changed: 34 additions & 23 deletions

File tree

‎apps/sim/lib/credential-groups/viewer-accounts.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export async function listViewerOrganizationAccounts(input: {
1414
const accounts = await db
1515
.select({
1616
credentialId: credential.id,
17-
accountId: credential.accountId,
17+
providerSubjectId: credential.providerSubjectId,
1818
displayName: credential.displayName,
1919
status: credential.managedOauthStatus,
2020
groupId: credentialGroup.id,

‎apps/sim/lib/organizations/application/home-widgets.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ interface WidgetReadResult<T> {
107107

108108
/** One account's read: which grant it is, and how many accounts this widget read is sharing. */
109109
interface ViewerAccountRead extends ProviderTokenInput {
110-
accountId: string | null
110+
providerSubjectId: string | null
111111
readableAccounts: number
112112
withToken: AccountTokenRead
113113
}
@@ -173,7 +173,8 @@ async function readViewerAccounts<T>(
173173

174174
const results = await Promise.all(
175175
candidates.map(async (candidate) => {
176-
const { credentialId, accountId, displayName, provider, providerId, status, read } = candidate
176+
const { credentialId, providerSubjectId, displayName, provider, providerId, status, read } =
177+
candidate
177178
const settled = (state: HomeWidgetAccount['state'], rows: T[] = []) => ({
178179
account: { credentialId, displayName, state },
179180
rows: rows.map((row) => ({ ...row, credentialId })),
@@ -186,7 +187,7 @@ async function readViewerAccounts<T>(
186187
const tokenInput = { context, credentialId, providerId }
187188
const account = {
188189
...tokenInput,
189-
accountId,
190+
providerSubjectId,
190191
readableAccounts: toRead.size,
191192
withToken: createAccountTokenRead(tokenInput),
192193
}
@@ -261,7 +262,7 @@ export const readOrganizationHomeEmail = defineOrganizationAccountsUseCase({
261262
accessToken,
262263
signal,
263264
credentialId: account.credentialId,
264-
accountId: account.accountId,
265+
providerSubjectId: account.providerSubjectId,
265266
count: Math.ceil(HOME_EMAIL_THREAD_LIMIT / account.readableAccounts),
266267
})
267268
cursors.set(account.credentialId, { ...cursor, account })

‎apps/sim/lib/organizations/home/provider-reads.test.ts‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ async function readGmailThreads(input: { accessToken: string; signal: AbortSigna
8383
const { threads } = await readGmailThreadsPage({
8484
...input,
8585
credentialId: `credential-${credentialCount}`,
86-
accountId: 'account',
86+
providerSubjectId: 'google-sub',
8787
count: HOME_EMAIL_THREAD_LIMIT,
8888
})
8989
return threads
@@ -296,7 +296,13 @@ describe('sharing a Gmail read across accounts', () => {
296296
: respond(gmailThread('t', [metadataMessage('Ada', 'S', 'P')]))
297297
)
298298
const read = (accessToken: string, credentialId: string) =>
299-
readGmailThreadsPage({ accessToken, signal, credentialId, accountId: 'account', count: 1 })
299+
readGmailThreadsPage({
300+
accessToken,
301+
signal,
302+
credentialId,
303+
providerSubjectId: 'google-sub',
304+
count: 1,
305+
})
300306
await read('token-a', 'mailbox-cached')
301307
const rotated = await read('token-b', 'mailbox-cached')
302308
expect(mailboxEmail).toHaveBeenCalledTimes(1)
@@ -305,23 +311,23 @@ describe('sharing a Gmail read across accounts', () => {
305311
expect(mailboxEmail).toHaveBeenCalledTimes(2)
306312
})
307313

308-
it('reads the mailbox again when a reconnect moves the credential to another account', async () => {
314+
it('reads the mailbox again when a reconnect binds a credential that had no subject', async () => {
309315
providerFetch.mockImplementation(async (url: string) =>
310316
url.includes('/threads?')
311317
? respond({ threads: [{ id: 't' }] })
312318
: respond(gmailThread('t', [metadataMessage('Ada', 'S', 'P')]))
313319
)
314-
const read = (accountId: string) =>
320+
const read = (providerSubjectId: string | null) =>
315321
readGmailThreadsPage({
316322
accessToken: 'token',
317323
signal,
318324
credentialId: 'mailbox-reconnected',
319-
accountId,
325+
providerSubjectId,
320326
count: 1,
321327
})
322-
await read('account-old')
328+
await read(null)
323329
mailboxEmail.mockResolvedValue('new@example.com')
324-
const reconnected = await read('account-new')
330+
const reconnected = await read('google-sub-new')
325331
expect(reconnected.threads[0].url).toBe(gmailThreadUrl('t', 'new@example.com'))
326332
})
327333

@@ -338,7 +344,7 @@ describe('sharing a Gmail read across accounts', () => {
338344
accessToken: 'token',
339345
signal,
340346
credentialId: 'mailbox-parallel',
341-
accountId: 'account',
347+
providerSubjectId: 'google-sub',
342348
count: 1,
343349
})
344350
await vi.waitFor(() => expect(mailboxEmail).toHaveBeenCalledOnce())
@@ -358,7 +364,7 @@ describe('sharing a Gmail read across accounts', () => {
358364
accessToken: 't',
359365
signal,
360366
credentialId: 'mailbox-failed',
361-
accountId: 'account',
367+
providerSubjectId: 'google-sub',
362368
count: 1,
363369
})
364370
await expect(read()).rejects.toMatchObject({ failure: 'unavailable' })

‎apps/sim/lib/organizations/home/provider-reads.ts‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,9 @@ function mailboxReadFailure(error: unknown): HomeProviderReadError {
175175
}
176176

177177
/**
178-
* The address thread links open as. A reconnect can point a credential at another Google account, on
179-
* any instance, so the key names the account too. It holds only the address: the token itself is
180-
* resolved fresh for every read.
178+
* The address thread links open as. A reconnect can bind a credential to a Google account it had no
179+
* identity for, on any instance, so the key names the account's subject too. It holds only the
180+
* address: the token itself is resolved fresh for every read.
181181
*/
182182
const gmailMailboxes = new LRUCache<string, string>({
183183
max: 10_000,
@@ -188,9 +188,9 @@ async function readGmailMailbox({
188188
accessToken,
189189
signal,
190190
credentialId,
191-
accountId,
192-
}: ProviderReadInput & { credentialId: string; accountId: string | null }) {
193-
const key = `${credentialId}:${accountId}`
191+
providerSubjectId,
192+
}: ProviderReadInput & { credentialId: string; providerSubjectId: string | null }) {
193+
const key = `${credentialId}:${providerSubjectId}`
194194
const cached = gmailMailboxes.get(key)
195195
if (cached !== undefined) return cached
196196
const mailbox = await getGmailMailboxEmail(accessToken, { signal }).catch((error: unknown) => {
@@ -211,9 +211,13 @@ export async function readGmailThreads({
211211
accessToken,
212212
signal,
213213
credentialId,
214-
accountId,
214+
providerSubjectId,
215215
count,
216-
}: ProviderReadInput & { credentialId: string; accountId: string | null; count: number }): Promise<{
216+
}: ProviderReadInput & {
217+
credentialId: string
218+
providerSubjectId: string | null
219+
count: number
220+
}): Promise<{
217221
threads: HomeEmailThread[]
218222
cursor: GmailThreadCursor
219223
}> {
@@ -223,7 +227,7 @@ export async function readGmailThreads({
223227
{ accessToken, labelIds: 'INBOX', maxResults: HOME_EMAIL_THREAD_LIMIT },
224228
signal
225229
),
226-
readGmailMailbox({ accessToken, signal, credentialId, accountId }),
230+
readGmailMailbox({ accessToken, signal, credentialId, providerSubjectId }),
227231
])
228232
if (!list.success) throw new HomeProviderReadError('unavailable')
229233
const cursor: GmailThreadCursor = {

0 commit comments

Comments
 (0)