Skip to content

Commit 0200377

Browse files
feat(polling-groups): can invite multiple people to have their gmail/outlook inboxes connected to a workflow (#2695)
* progress on cred sets * fix credential set system * return data to render credential set in block preview * progress * invite flow * simplify code * fix ui * fix tests * fix types * fix * fix icon for outlook * fix cred set name not showing up for owner * fix rendering of credential set name * fix outlook well known folder id resolution * fix perms for creating cred set * add to docs and simplify ui * consolidate webhook code better * fix tests * fix credential collab logic issue * fix ui * fix lint
1 parent cb12ceb commit 0200377

File tree

59 files changed

+14773
-318
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+14773
-318
lines changed

apps/docs/content/docs/en/triggers/index.mdx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ Use the Start block for everything originating from the editor, deploy-to-API, o
3333
<Card title="RSS Feed" href="/triggers/rss">
3434
Monitor RSS and Atom feeds for new content
3535
</Card>
36+
<Card title="Email Polling Groups" href="#email-polling-groups">
37+
Monitor team Gmail and Outlook inboxes
38+
</Card>
3639
</Cards>
3740

3841
## Quick Comparison
@@ -43,6 +46,7 @@ Use the Start block for everything originating from the editor, deploy-to-API, o
4346
| **Schedule** | Timer managed in schedule block |
4447
| **Webhook** | On inbound HTTP request |
4548
| **RSS Feed** | New item published to feed |
49+
| **Email Polling Groups** | New email received in team Gmail or Outlook inboxes |
4650

4751
> The Start block always exposes `input`, `conversationId`, and `files` fields. Add custom fields to the input format for additional structured data.
4852
@@ -66,3 +70,24 @@ If your workflow has multiple triggers, the highest priority trigger will be exe
6670

6771
**External triggers with mock payloads**: When external triggers (webhooks and integrations) are executed manually, Sim automatically generates mock payloads based on the trigger's expected data structure. This ensures downstream blocks can resolve variables correctly during testing.
6872

73+
## Email Polling Groups
74+
75+
Polling Groups let you monitor multiple team members' Gmail or Outlook inboxes with a single trigger. Requires a Team or Enterprise plan.
76+
77+
**Creating a Polling Group** (Admin/Owner)
78+
79+
1. Go to **Settings → Email Polling**
80+
2. Click **Create** and choose Gmail or Outlook
81+
3. Enter a name for the group
82+
83+
**Inviting Members**
84+
85+
1. Click **Add Members** on your polling group
86+
2. Enter email addresses (comma or newline separated, or drag & drop a CSV)
87+
3. Click **Send Invites**
88+
89+
Invitees receive an email with a link to connect their account. Once connected, their inbox is automatically included in the polling group. Invitees don't need to be members of your Sim organization.
90+
91+
**Using in a Workflow**
92+
93+
When configuring an email trigger, select your polling group from the credentials dropdown instead of an individual account. The system creates webhooks for each member and routes all emails through your workflow.

apps/sim/app/(auth)/signup/signup-form.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,15 @@ function SignupFormContent({
109109
setEmail(emailParam)
110110
}
111111

112-
const redirectParam = searchParams.get('redirect')
112+
// Check both 'redirect' and 'callbackUrl' params (login page uses callbackUrl)
113+
const redirectParam = searchParams.get('redirect') || searchParams.get('callbackUrl')
113114
if (redirectParam) {
114115
setRedirectUrl(redirectParam)
115116

116-
if (redirectParam.startsWith('/invite/')) {
117+
if (
118+
redirectParam.startsWith('/invite/') ||
119+
redirectParam.startsWith('/credential-account/')
120+
) {
117121
setIsInviteFlow(true)
118122
}
119123
}

apps/sim/app/api/auth/oauth/disconnect/route.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,18 @@ import { createMockLogger, createMockRequest } from '@/app/api/__test-utils__/ut
88

99
describe('OAuth Disconnect API Route', () => {
1010
const mockGetSession = vi.fn()
11+
const mockSelectChain = {
12+
from: vi.fn().mockReturnThis(),
13+
innerJoin: vi.fn().mockReturnThis(),
14+
where: vi.fn().mockResolvedValue([]),
15+
}
1116
const mockDb = {
1217
delete: vi.fn().mockReturnThis(),
1318
where: vi.fn(),
19+
select: vi.fn().mockReturnValue(mockSelectChain),
1420
}
1521
const mockLogger = createMockLogger()
22+
const mockSyncAllWebhooksForCredentialSet = vi.fn().mockResolvedValue({})
1623

1724
const mockUUID = 'mock-uuid-12345678-90ab-cdef-1234-567890abcdef'
1825

@@ -33,6 +40,13 @@ describe('OAuth Disconnect API Route', () => {
3340

3441
vi.doMock('@sim/db/schema', () => ({
3542
account: { userId: 'userId', providerId: 'providerId' },
43+
credentialSetMember: {
44+
id: 'id',
45+
credentialSetId: 'credentialSetId',
46+
userId: 'userId',
47+
status: 'status',
48+
},
49+
credentialSet: { id: 'id', providerId: 'providerId' },
3650
}))
3751

3852
vi.doMock('drizzle-orm', () => ({
@@ -45,6 +59,14 @@ describe('OAuth Disconnect API Route', () => {
4559
vi.doMock('@sim/logger', () => ({
4660
createLogger: vi.fn().mockReturnValue(mockLogger),
4761
}))
62+
63+
vi.doMock('@/lib/core/utils/request', () => ({
64+
generateRequestId: vi.fn().mockReturnValue('test-request-id'),
65+
}))
66+
67+
vi.doMock('@/lib/webhooks/utils.server', () => ({
68+
syncAllWebhooksForCredentialSet: mockSyncAllWebhooksForCredentialSet,
69+
}))
4870
})
4971

5072
afterEach(() => {

apps/sim/app/api/auth/oauth/disconnect/route.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { db } from '@sim/db'
2-
import { account } from '@sim/db/schema'
2+
import { account, credentialSet, credentialSetMember } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { and, eq, like, or } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { z } from 'zod'
77
import { getSession } from '@/lib/auth'
88
import { generateRequestId } from '@/lib/core/utils/request'
9+
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
910

1011
export const dynamic = 'force-dynamic'
1112

@@ -74,6 +75,49 @@ export async function POST(request: NextRequest) {
7475
)
7576
}
7677

78+
// Sync webhooks for all credential sets the user is a member of
79+
// This removes webhooks that were using the disconnected credential
80+
const userMemberships = await db
81+
.select({
82+
id: credentialSetMember.id,
83+
credentialSetId: credentialSetMember.credentialSetId,
84+
providerId: credentialSet.providerId,
85+
})
86+
.from(credentialSetMember)
87+
.innerJoin(credentialSet, eq(credentialSetMember.credentialSetId, credentialSet.id))
88+
.where(
89+
and(
90+
eq(credentialSetMember.userId, session.user.id),
91+
eq(credentialSetMember.status, 'active')
92+
)
93+
)
94+
95+
for (const membership of userMemberships) {
96+
// Only sync if the credential set matches this provider
97+
// Credential sets store OAuth provider IDs like 'google-email' or 'outlook'
98+
const matchesProvider =
99+
membership.providerId === provider ||
100+
membership.providerId === providerId ||
101+
membership.providerId?.startsWith(`${provider}-`)
102+
103+
if (matchesProvider) {
104+
try {
105+
await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId)
106+
logger.info(`[${requestId}] Synced webhooks after credential disconnect`, {
107+
credentialSetId: membership.credentialSetId,
108+
provider,
109+
})
110+
} catch (error) {
111+
// Log but don't fail the disconnect - credential is already removed
112+
logger.error(`[${requestId}] Failed to sync webhooks after credential disconnect`, {
113+
credentialSetId: membership.credentialSetId,
114+
provider,
115+
error,
116+
})
117+
}
118+
}
119+
}
120+
77121
return NextResponse.json({ success: true }, { status: 200 })
78122
} catch (error) {
79123
logger.error(`[${requestId}] Error disconnecting OAuth provider`, error)

apps/sim/app/api/auth/oauth/token/route.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,10 @@ describe('OAuth Token API Routes', () => {
138138
const data = await response.json()
139139

140140
expect(response.status).toBe(400)
141-
expect(data).toHaveProperty('error', 'Credential ID is required')
141+
expect(data).toHaveProperty(
142+
'error',
143+
'Either credentialId or (credentialAccountUserId + providerId) is required'
144+
)
142145
expect(mockLogger.warn).toHaveBeenCalled()
143146
})
144147

apps/sim/app/api/auth/oauth/token/route.ts

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,25 @@ import { z } from 'zod'
44
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
55
import { checkHybridAuth } from '@/lib/auth/hybrid'
66
import { generateRequestId } from '@/lib/core/utils/request'
7-
import { getCredential, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
7+
import { getCredential, getOAuthToken, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
88

99
export const dynamic = 'force-dynamic'
1010

1111
const logger = createLogger('OAuthTokenAPI')
1212

1313
const SALESFORCE_INSTANCE_URL_REGEX = /__sf_instance__:([^\s]+)/
1414

15-
const tokenRequestSchema = z.object({
16-
credentialId: z
17-
.string({ required_error: 'Credential ID is required' })
18-
.min(1, 'Credential ID is required'),
19-
workflowId: z.string().min(1, 'Workflow ID is required').nullish(),
20-
})
15+
const tokenRequestSchema = z
16+
.object({
17+
credentialId: z.string().min(1).optional(),
18+
credentialAccountUserId: z.string().min(1).optional(),
19+
providerId: z.string().min(1).optional(),
20+
workflowId: z.string().min(1).nullish(),
21+
})
22+
.refine(
23+
(data) => data.credentialId || (data.credentialAccountUserId && data.providerId),
24+
'Either credentialId or (credentialAccountUserId + providerId) is required'
25+
)
2126

2227
const tokenQuerySchema = z.object({
2328
credentialId: z
@@ -58,9 +63,37 @@ export async function POST(request: NextRequest) {
5863
)
5964
}
6065

61-
const { credentialId, workflowId } = parseResult.data
66+
const { credentialId, credentialAccountUserId, providerId, workflowId } = parseResult.data
67+
68+
if (credentialAccountUserId && providerId) {
69+
logger.info(`[${requestId}] Fetching token by credentialAccountUserId + providerId`, {
70+
credentialAccountUserId,
71+
providerId,
72+
})
73+
74+
try {
75+
const accessToken = await getOAuthToken(credentialAccountUserId, providerId)
76+
if (!accessToken) {
77+
return NextResponse.json(
78+
{
79+
error: `No credential found for user ${credentialAccountUserId} and provider ${providerId}`,
80+
},
81+
{ status: 404 }
82+
)
83+
}
84+
85+
return NextResponse.json({ accessToken }, { status: 200 })
86+
} catch (error) {
87+
const message = error instanceof Error ? error.message : 'Failed to get OAuth token'
88+
logger.warn(`[${requestId}] OAuth token error: ${message}`)
89+
return NextResponse.json({ error: message }, { status: 403 })
90+
}
91+
}
92+
93+
if (!credentialId) {
94+
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
95+
}
6296

63-
// We already have workflowId from the parsed body; avoid forcing hybrid auth to re-read it
6497
const authz = await authorizeCredentialUse(request, {
6598
credentialId,
6699
workflowId: workflowId ?? undefined,
@@ -70,15 +103,13 @@ export async function POST(request: NextRequest) {
70103
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
71104
}
72105

73-
// Fetch the credential as the owner to enforce ownership scoping
74106
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
75107

76108
if (!credential) {
77109
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
78110
}
79111

80112
try {
81-
// Refresh the token if needed
82113
const { accessToken } = await refreshTokenIfNeeded(requestId, credential, credentialId)
83114

84115
let instanceUrl: string | undefined
@@ -145,7 +176,6 @@ export async function GET(request: NextRequest) {
145176
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
146177
}
147178

148-
// Get the credential from the database
149179
const credential = await getCredential(requestId, credentialId, auth.userId)
150180

151181
if (!credential) {

0 commit comments

Comments
 (0)