Skip to content

Commit 8205b38

Browse files
committed
fix(mailer): permissions entitlements for enabling/disabling
1 parent 0371856 commit 8205b38

7 files changed

Lines changed: 85 additions & 57 deletions

File tree

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/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(),

apps/sim/lib/billing/core/subscription.ts

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -518,30 +518,54 @@ export async function isWorkspaceOnEnterprisePlan(workspaceId: string): Promise<
518518
}
519519
}
520520

521+
const MAX_PLAN_CREDITS = 25000
522+
521523
/**
522-
* Check if user has access to inbox (Sim Mailer) feature
524+
* Whether a resolved subscription entitles the inbox (Sim Mailer) feature: a Max
525+
* tier (credits >= 25000, covering `pro_25000` and `team_25000`) or any
526+
* enterprise plan.
527+
*/
528+
function isInboxEntitledPlan(sub: { plan: string }): boolean {
529+
return getPlanTierCredits(sub.plan) >= MAX_PLAN_CREDITS || checkEnterprisePlan(sub)
530+
}
531+
532+
/**
533+
* Check whether a workspace is entitled to the inbox (Sim Mailer) feature.
534+
* Entitlement follows the workspace's billing entity — not the acting user — so
535+
* any workspace admin (including an external member) can manage the inbox when
536+
* the workspace's organization, or its billed account for personal workspaces,
537+
* is on a Max or enterprise plan.
538+
*
523539
* Returns true if:
524-
* - INBOX_ENABLED env var is set, OR
525-
* - Non-production environment, OR
526-
* - User has a Max plan (credits >= 25000) or enterprise plan
540+
* - INBOX_ENABLED env var is set (self-hosted override), OR
541+
* - billing is disabled, OR
542+
* - the workspace belongs to an organization on a Max/enterprise plan (org-mode), OR
543+
* - the billed user has an individual Max/enterprise subscription (personal workspace).
527544
*/
528-
export async function hasInboxAccess(userId: string): Promise<boolean> {
545+
export async function hasWorkspaceInboxAccess(workspaceId: string): Promise<boolean> {
529546
try {
530-
if (isInboxEnabled) {
531-
return true
532-
}
533-
if (!isBillingEnabled) {
534-
return true
547+
if (isInboxEnabled) return true
548+
if (!isBillingEnabled) return true
549+
550+
const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils')
551+
const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true })
552+
if (!ws) return false
553+
554+
if (ws.organizationId) {
555+
if (await isOrganizationBillingBlocked(ws.organizationId)) return false
556+
const orgSub = await getOrganizationSubscriptionUsable(ws.organizationId)
557+
return !!orgSub && isInboxEntitledPlan(orgSub)
535558
}
536-
const [sub, billingStatus] = await Promise.all([
537-
getHighestPrioritySubscription(userId),
538-
getEffectiveBillingStatus(userId),
559+
560+
const [billedSub, billingStatus] = await Promise.all([
561+
getHighestPrioritySubscription(ws.billedAccountUserId),
562+
getEffectiveBillingStatus(ws.billedAccountUserId),
539563
])
540-
if (!sub) return false
541-
if (!hasUsableSubscriptionAccess(sub.status, billingStatus.billingBlocked)) return false
542-
return getPlanTierCredits(sub.plan) >= 25000 || checkEnterprisePlan(sub)
564+
if (!billedSub) return false
565+
if (!hasUsableSubscriptionAccess(billedSub.status, billingStatus.billingBlocked)) return false
566+
return isInboxEntitledPlan(billedSub)
543567
} catch (error) {
544-
logger.error('Error checking inbox access', { error, userId })
568+
logger.error('Error checking workspace inbox access', { error, workspaceId })
545569
return false
546570
}
547571
}

0 commit comments

Comments
 (0)