Skip to content

Commit ca34301

Browse files
icecrasher321waleedlatif1TheodoreSpeaksSg312claude
authored
fix(mailer): permissions entitlements for enabling/disabling (#5312)
* v0.6.29: login improvements, posthog telemetry (#4026) * feat(posthog): Add tracking on mothership abort (#4023) Co-authored-by: Theodore Li <theo@sim.ai> * fix(login): fix captcha headers for manual login (#4025) * fix(signup): fix turnstile key loading * fix(login): fix captcha header passing * Catch user already exists, remove login form captcha * fix(mailer): permissions entitlements for enabling/disabling * fix lifecycle for agentmail infra --------- Co-authored-by: Waleed <walif6@gmail.com> Co-authored-by: Theodore Li <theodoreqili@gmail.com> Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Co-authored-by: Theodore Li <theo@sim.ai> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ff16b1b commit ca34301

13 files changed

Lines changed: 235 additions & 76 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { db, workspace } from '@sim/db'
2+
import { createLogger } from '@sim/logger'
3+
import { getErrorMessage } from '@sim/utils/errors'
4+
import { eq } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import { verifyCronAuth } from '@/lib/auth/internal'
7+
import { hasWorkspaceInboxGraceAccess } from '@/lib/billing/core/subscription'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { disableInbox } from '@/lib/mothership/inbox/lifecycle'
10+
11+
const logger = createLogger('InboxEntitlementReconcileCron')
12+
13+
export const dynamic = 'force-dynamic'
14+
15+
/**
16+
* Periodic inbox (Sim Mailer) entitlement reconciliation. Releases the AgentMail
17+
* inbox + webhook for any workspace whose provisioned inbox has outlived its
18+
* plan — i.e. `inboxEnabled` is still true but the billing entity no longer
19+
* holds an entitled (active or `past_due`) Max/Enterprise subscription.
20+
*
21+
* Teardown keys off {@link hasWorkspaceInboxGraceAccess}, which tolerates
22+
* `past_due`, so a transient payment failure never destroys a paying customer's
23+
* inbox — only a genuinely terminal plan (canceled/downgraded) is reclaimed.
24+
* `disableInbox` swallows AgentMail delete failures, so a rerun or a race with a
25+
* manual disable is a no-op. On self-hosted / billing-disabled deployments the
26+
* grace check returns true for every workspace, making this sweep inert.
27+
*
28+
* Scheduled in helm/sim/values.yaml under cronjobs.jobs.reconcileInboxEntitlement.
29+
*/
30+
export const GET = withRouteHandler(async (request: NextRequest) => {
31+
const authError = verifyCronAuth(request, 'Inbox entitlement reconciliation')
32+
if (authError) {
33+
return authError
34+
}
35+
36+
const enabledWorkspaces = await db
37+
.select({ id: workspace.id })
38+
.from(workspace)
39+
.where(eq(workspace.inboxEnabled, true))
40+
41+
let disabled = 0
42+
for (const ws of enabledWorkspaces) {
43+
try {
44+
if (await hasWorkspaceInboxGraceAccess(ws.id)) {
45+
continue
46+
}
47+
await disableInbox(ws.id)
48+
disabled++
49+
logger.info('Reclaimed inbox for workspace with terminated Sim Mailer entitlement', {
50+
workspaceId: ws.id,
51+
})
52+
} catch (error) {
53+
logger.error('Failed to reconcile inbox entitlement for workspace', {
54+
workspaceId: ws.id,
55+
error: getErrorMessage(error, 'Unknown error'),
56+
})
57+
}
58+
}
59+
60+
logger.info('Inbox entitlement reconciliation complete', {
61+
checked: enabledWorkspaces.length,
62+
disabled,
63+
})
64+
65+
return NextResponse.json({ checked: enabledWorkspaces.length, disabled })
66+
})

apps/sim/app/api/webhooks/agentmail/route.ts

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
agentMailMessageSchema,
2020
webhookSvixHeadersSchema,
2121
} from '@/lib/api/contracts/webhooks'
22+
import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription'
2223
import { resolveTriggerRegion } from '@/lib/core/async-jobs/region'
2324
import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
2425
import {
@@ -167,30 +168,38 @@ export const POST = withRouteHandler(async (req: Request) => {
167168
const emailMessageId = message.message_id
168169
const inReplyTo = message.in_reply_to || null
169170

170-
const [existingResult, isAllowed, recentCount, parentTaskResult] = await Promise.all([
171-
emailMessageId
172-
? db
173-
.select({ id: mothershipInboxTask.id })
174-
.from(mothershipInboxTask)
175-
.where(eq(mothershipInboxTask.emailMessageId, emailMessageId))
176-
.limit(1)
177-
: Promise.resolve([]),
178-
isSenderAllowed(fromEmail, result.id),
179-
getRecentTaskCount(result.id),
180-
inReplyTo
181-
? db
182-
.select({ chatId: mothershipInboxTask.chatId })
183-
.from(mothershipInboxTask)
184-
.where(eq(mothershipInboxTask.responseMessageId, inReplyTo))
185-
.limit(1)
186-
: Promise.resolve([]),
187-
])
171+
const [existingResult, isAllowed, recentCount, parentTaskResult, isEntitled] =
172+
await Promise.all([
173+
emailMessageId
174+
? db
175+
.select({ id: mothershipInboxTask.id })
176+
.from(mothershipInboxTask)
177+
.where(eq(mothershipInboxTask.emailMessageId, emailMessageId))
178+
.limit(1)
179+
: Promise.resolve([]),
180+
isSenderAllowed(fromEmail, result.id),
181+
getRecentTaskCount(result.id),
182+
inReplyTo
183+
? db
184+
.select({ chatId: mothershipInboxTask.chatId })
185+
.from(mothershipInboxTask)
186+
.where(eq(mothershipInboxTask.responseMessageId, inReplyTo))
187+
.limit(1)
188+
: Promise.resolve([]),
189+
hasWorkspaceInboxAccess(result.id),
190+
])
188191

189192
if (existingResult[0]) {
190193
logger.info('Duplicate webhook, skipping', { emailMessageId })
191194
return NextResponse.json({ ok: true })
192195
}
193196

197+
if (!isEntitled) {
198+
logger.info('Inbox no longer entitled, rejecting', { workspaceId: result.id })
199+
await createRejectedTask(result.id, message, 'not_entitled')
200+
return NextResponse.json({ ok: true })
201+
}
202+
194203
if (!isAllowed) {
195204
await createRejectedTask(result.id, message, 'sender_not_allowed')
196205
return NextResponse.json({ ok: true })

apps/sim/app/api/workspaces/[id]/inbox/route.ts

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
66
import { updateInboxConfigContract } from '@/lib/api/contracts/inbox'
77
import { parseRequest } from '@/lib/api/server'
88
import { getSession } from '@/lib/auth'
9-
import { hasInboxAccess } from '@/lib/billing/core/subscription'
9+
import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1111
import { disableInbox, enableInbox, updateInboxAddress } from '@/lib/mothership/inbox/lifecycle'
1212
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -21,18 +21,12 @@ export const GET = withRouteHandler(
2121
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
2222
}
2323

24-
const [hasAccess, permission] = await Promise.all([
25-
hasInboxAccess(session.user.id),
26-
getUserEntityPermissions(session.user.id, 'workspace', workspaceId),
27-
])
28-
if (!hasAccess) {
29-
return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 })
30-
}
24+
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
3125
if (!permission) {
3226
return NextResponse.json({ error: 'Not found' }, { status: 404 })
3327
}
3428

35-
const [wsResult, statsResult] = await Promise.all([
29+
const [wsResult, statsResult, entitled] = await Promise.all([
3630
db
3731
.select({
3832
inboxEnabled: workspace.inboxEnabled,
@@ -49,6 +43,7 @@ export const GET = withRouteHandler(
4943
.from(mothershipInboxTask)
5044
.where(eq(mothershipInboxTask.workspaceId, workspaceId))
5145
.groupBy(mothershipInboxTask.status),
46+
hasWorkspaceInboxAccess(workspaceId),
5247
])
5348

5449
const [ws] = wsResult
@@ -73,6 +68,7 @@ export const GET = withRouteHandler(
7368
return NextResponse.json({
7469
enabled: ws.inboxEnabled,
7570
address: ws.inboxAddress,
71+
entitled,
7672
taskStats: stats,
7773
})
7874
}
@@ -86,21 +82,24 @@ export const PATCH = withRouteHandler(
8682
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
8783
}
8884

89-
const [hasAccess, permission] = await Promise.all([
90-
hasInboxAccess(session.user.id),
91-
getUserEntityPermissions(session.user.id, 'workspace', workspaceId),
92-
])
93-
if (!hasAccess) {
94-
return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 })
95-
}
85+
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
9686
if (permission !== 'admin') {
9787
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
9888
}
9989

90+
const parsed = await parseRequest(updateInboxConfigContract, req, context)
91+
if (!parsed.success) return parsed.response
92+
const body = parsed.data.body
93+
10094
try {
101-
const parsed = await parseRequest(updateInboxConfigContract, req, context)
102-
if (!parsed.success) return parsed.response
103-
const body = parsed.data.body
95+
if (body.enabled === false) {
96+
await disableInbox(workspaceId)
97+
return NextResponse.json({ enabled: false, address: null })
98+
}
99+
100+
if (!(await hasWorkspaceInboxAccess(workspaceId))) {
101+
return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 })
102+
}
104103

105104
if (body.enabled === true) {
106105
const [current] = await db
@@ -115,11 +114,6 @@ export const PATCH = withRouteHandler(
115114
return NextResponse.json(config)
116115
}
117116

118-
if (body.enabled === false) {
119-
await disableInbox(workspaceId)
120-
return NextResponse.json({ enabled: false, address: null })
121-
}
122-
123117
if (body.username) {
124118
const config = await updateInboxAddress(workspaceId, body.username)
125119
return NextResponse.json(config)

apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
66
import { addInboxSenderContract, removeInboxSenderContract } from '@/lib/api/contracts/inbox'
77
import { parseRequest } from '@/lib/api/server'
88
import { getSession } from '@/lib/auth'
9-
import { hasInboxAccess } from '@/lib/billing/core/subscription'
9+
import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription'
1010
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1111
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1212

@@ -21,7 +21,7 @@ export const GET = withRouteHandler(
2121
}
2222

2323
const [hasAccess, permission] = await Promise.all([
24-
hasInboxAccess(session.user.id),
24+
hasWorkspaceInboxAccess(workspaceId),
2525
getUserEntityPermissions(session.user.id, 'workspace', workspaceId),
2626
])
2727
if (!hasAccess) {
@@ -77,7 +77,7 @@ export const POST = withRouteHandler(
7777
}
7878

7979
const [hasAccess, permission] = await Promise.all([
80-
hasInboxAccess(session.user.id),
80+
hasWorkspaceInboxAccess(workspaceId),
8181
getUserEntityPermissions(session.user.id, 'workspace', workspaceId),
8282
])
8383
if (!hasAccess) {
@@ -136,7 +136,7 @@ export const DELETE = withRouteHandler(
136136
}
137137

138138
const [hasAccess, permission] = await Promise.all([
139-
hasInboxAccess(session.user.id),
139+
hasWorkspaceInboxAccess(workspaceId),
140140
getUserEntityPermissions(session.user.id, 'workspace', workspaceId),
141141
])
142142
if (!hasAccess) {

apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server'
44
import { inboxTasksQuerySchema, inboxWorkspaceParamsSchema } from '@/lib/api/contracts/inbox'
55
import { getValidationErrorMessage } from '@/lib/api/server'
66
import { getSession } from '@/lib/auth'
7-
import { hasInboxAccess } from '@/lib/billing/core/subscription'
7+
import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1010

@@ -24,7 +24,7 @@ export const GET = withRouteHandler(
2424
}
2525

2626
const [hasAccess, permission] = await Promise.all([
27-
hasInboxAccess(session.user.id),
27+
hasWorkspaceInboxAccess(workspaceId),
2828
getUserEntityPermissions(session.user.id, 'workspace', workspaceId),
2929
])
3030
if (!hasAccess) {

apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ function formatRejectionReason(reason: string): string {
193193
return 'Automated sender'
194194
case 'rate_limit_exceeded':
195195
return 'Rate limit exceeded'
196+
case 'not_entitled':
197+
return 'Plan no longer includes Sim Mailer'
196198
default:
197199
return reason
198200
}

apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,36 @@
33
import { Chip } from '@sim/emcn'
44
import { ArrowRight } from 'lucide-react'
55
import { useParams, useRouter } from 'next/navigation'
6-
import { getSubscriptionAccessState } from '@/lib/billing/client'
6+
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
77
import {
88
InboxEnableToggle,
99
InboxSettingsTab,
1010
InboxTaskList,
1111
} from '@/app/workspace/[workspaceId]/settings/components/inbox/components'
1212
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
1313
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
14-
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
1514
import { useInboxConfig } from '@/hooks/queries/inbox'
16-
import { useSubscriptionData } from '@/hooks/queries/subscription'
1715

1816
export function Inbox() {
1917
const params = useParams()
2018
const router = useRouter()
2119
const workspaceId = params.workspaceId as string
2220

2321
const { data: config, isLoading } = useInboxConfig(workspaceId)
24-
const { data: subscriptionResponse, isLoading: isSubLoading } = useSubscriptionData({
25-
enabled: isBillingEnabled,
26-
})
27-
const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data)
22+
const { canAdmin } = useUserPermissionsContext()
2823

29-
if (isLoading || (isBillingEnabled && isSubLoading)) {
24+
if (isLoading) {
3025
return null
3126
}
3227

33-
if (isBillingEnabled && !subscriptionAccess.hasUsableMaxAccess) {
28+
if (!config?.entitled) {
29+
if (config?.enabled && canAdmin) {
30+
return (
31+
<SettingsPanel>
32+
<InboxEnableToggle />
33+
</SettingsPanel>
34+
)
35+
}
3436
return (
3537
<div className='flex h-full flex-col bg-[var(--bg)]'>
3638
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sideb
2323
import { useSSOProviders } from '@/ee/sso/hooks/sso'
2424
import { prefetchWorkspaceCredentials } from '@/hooks/queries/credentials'
2525
import { prefetchGeneralSettings, useGeneralSettings } from '@/hooks/queries/general-settings'
26+
import { useInboxConfig } from '@/hooks/queries/inbox'
2627
import { useOrganizations } from '@/hooks/queries/organization'
2728
import { prefetchSubscriptionData, useSubscriptionData } from '@/hooks/queries/subscription'
2829
import { usePermissionConfig } from '@/hooks/use-permission-config'
@@ -63,6 +64,7 @@ export function SettingsSidebar({
6364
enabled: isBillingEnabled,
6465
staleTime: 5 * 60 * 1000,
6566
})
67+
const { data: inboxConfig } = useInboxConfig(workspaceId)
6668
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders({
6769
enabled: !isHosted,
6870
})
@@ -78,6 +80,7 @@ export function SettingsSidebar({
7880
const isAdmin = userRole === 'admin'
7981
const isOrgAdminOrOwner = isOwner || isAdmin
8082
const subscriptionAccess = getSubscriptionAccessState(subscriptionData?.data)
83+
const inboxEntitled = inboxConfig?.entitled ?? false
8184
const hasTeamPlan = subscriptionAccess.hasUsableTeamAccess
8285
const hasEnterprisePlan = subscriptionAccess.hasUsableEnterpriseAccess
8386
const isEnterprisePlan = isEnterprise(subscriptionData?.data?.plan)
@@ -296,7 +299,11 @@ export function SettingsSidebar({
296299
{sectionItems.map((item) => {
297300
const Icon = item.icon
298301
const active = activeSection === item.id
299-
const isLocked = item.requiresMax && !subscriptionAccess.hasUsableMaxAccess
302+
const isLocked =
303+
item.requiresMax &&
304+
(item.id === 'inbox'
305+
? !inboxEntitled
306+
: !subscriptionAccess.hasUsableMaxAccess)
300307
const itemClassName = chipVariants({ active, fullWidth: true })
301308
const content = (
302309
<>

apps/sim/lib/api/contracts/inbox.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const inboxTaskStatusSchema = z.enum([
1717
export const inboxConfigSchema = z.object({
1818
enabled: z.boolean(),
1919
address: z.string().nullable(),
20+
entitled: z.boolean(),
2021
taskStats: z.object({
2122
total: z.number(),
2223
completed: z.number(),

0 commit comments

Comments
 (0)