diff --git a/apps/sim/app/api/cron/reconcile-inbox-entitlement/route.ts b/apps/sim/app/api/cron/reconcile-inbox-entitlement/route.ts
new file mode 100644
index 00000000000..08e47c48c1c
--- /dev/null
+++ b/apps/sim/app/api/cron/reconcile-inbox-entitlement/route.ts
@@ -0,0 +1,66 @@
+import { db, workspace } from '@sim/db'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { eq } from 'drizzle-orm'
+import { type NextRequest, NextResponse } from 'next/server'
+import { verifyCronAuth } from '@/lib/auth/internal'
+import { hasWorkspaceInboxGraceAccess } from '@/lib/billing/core/subscription'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { disableInbox } from '@/lib/mothership/inbox/lifecycle'
+
+const logger = createLogger('InboxEntitlementReconcileCron')
+
+export const dynamic = 'force-dynamic'
+
+/**
+ * Periodic inbox (Sim Mailer) entitlement reconciliation. Releases the AgentMail
+ * inbox + webhook for any workspace whose provisioned inbox has outlived its
+ * plan — i.e. `inboxEnabled` is still true but the billing entity no longer
+ * holds an entitled (active or `past_due`) Max/Enterprise subscription.
+ *
+ * Teardown keys off {@link hasWorkspaceInboxGraceAccess}, which tolerates
+ * `past_due`, so a transient payment failure never destroys a paying customer's
+ * inbox — only a genuinely terminal plan (canceled/downgraded) is reclaimed.
+ * `disableInbox` swallows AgentMail delete failures, so a rerun or a race with a
+ * manual disable is a no-op. On self-hosted / billing-disabled deployments the
+ * grace check returns true for every workspace, making this sweep inert.
+ *
+ * Scheduled in helm/sim/values.yaml under cronjobs.jobs.reconcileInboxEntitlement.
+ */
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const authError = verifyCronAuth(request, 'Inbox entitlement reconciliation')
+ if (authError) {
+ return authError
+ }
+
+ const enabledWorkspaces = await db
+ .select({ id: workspace.id })
+ .from(workspace)
+ .where(eq(workspace.inboxEnabled, true))
+
+ let disabled = 0
+ for (const ws of enabledWorkspaces) {
+ try {
+ if (await hasWorkspaceInboxGraceAccess(ws.id)) {
+ continue
+ }
+ await disableInbox(ws.id)
+ disabled++
+ logger.info('Reclaimed inbox for workspace with terminated Sim Mailer entitlement', {
+ workspaceId: ws.id,
+ })
+ } catch (error) {
+ logger.error('Failed to reconcile inbox entitlement for workspace', {
+ workspaceId: ws.id,
+ error: getErrorMessage(error, 'Unknown error'),
+ })
+ }
+ }
+
+ logger.info('Inbox entitlement reconciliation complete', {
+ checked: enabledWorkspaces.length,
+ disabled,
+ })
+
+ return NextResponse.json({ checked: enabledWorkspaces.length, disabled })
+})
diff --git a/apps/sim/app/api/webhooks/agentmail/route.ts b/apps/sim/app/api/webhooks/agentmail/route.ts
index 4d62dd4940c..b31ae2563a9 100644
--- a/apps/sim/app/api/webhooks/agentmail/route.ts
+++ b/apps/sim/app/api/webhooks/agentmail/route.ts
@@ -19,6 +19,7 @@ import {
agentMailMessageSchema,
webhookSvixHeadersSchema,
} from '@/lib/api/contracts/webhooks'
+import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription'
import { resolveTriggerRegion } from '@/lib/core/async-jobs/region'
import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
import {
@@ -167,30 +168,38 @@ export const POST = withRouteHandler(async (req: Request) => {
const emailMessageId = message.message_id
const inReplyTo = message.in_reply_to || null
- const [existingResult, isAllowed, recentCount, parentTaskResult] = await Promise.all([
- emailMessageId
- ? db
- .select({ id: mothershipInboxTask.id })
- .from(mothershipInboxTask)
- .where(eq(mothershipInboxTask.emailMessageId, emailMessageId))
- .limit(1)
- : Promise.resolve([]),
- isSenderAllowed(fromEmail, result.id),
- getRecentTaskCount(result.id),
- inReplyTo
- ? db
- .select({ chatId: mothershipInboxTask.chatId })
- .from(mothershipInboxTask)
- .where(eq(mothershipInboxTask.responseMessageId, inReplyTo))
- .limit(1)
- : Promise.resolve([]),
- ])
+ const [existingResult, isAllowed, recentCount, parentTaskResult, isEntitled] =
+ await Promise.all([
+ emailMessageId
+ ? db
+ .select({ id: mothershipInboxTask.id })
+ .from(mothershipInboxTask)
+ .where(eq(mothershipInboxTask.emailMessageId, emailMessageId))
+ .limit(1)
+ : Promise.resolve([]),
+ isSenderAllowed(fromEmail, result.id),
+ getRecentTaskCount(result.id),
+ inReplyTo
+ ? db
+ .select({ chatId: mothershipInboxTask.chatId })
+ .from(mothershipInboxTask)
+ .where(eq(mothershipInboxTask.responseMessageId, inReplyTo))
+ .limit(1)
+ : Promise.resolve([]),
+ hasWorkspaceInboxAccess(result.id),
+ ])
if (existingResult[0]) {
logger.info('Duplicate webhook, skipping', { emailMessageId })
return NextResponse.json({ ok: true })
}
+ if (!isEntitled) {
+ logger.info('Inbox no longer entitled, rejecting', { workspaceId: result.id })
+ await createRejectedTask(result.id, message, 'not_entitled')
+ return NextResponse.json({ ok: true })
+ }
+
if (!isAllowed) {
await createRejectedTask(result.id, message, 'sender_not_allowed')
return NextResponse.json({ ok: true })
diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.ts
index aa3a3ccc357..bbaa2594986 100644
--- a/apps/sim/app/api/workspaces/[id]/inbox/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/inbox/route.ts
@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { updateInboxConfigContract } from '@/lib/api/contracts/inbox'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
-import { hasInboxAccess } from '@/lib/billing/core/subscription'
+import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { disableInbox, enableInbox, updateInboxAddress } from '@/lib/mothership/inbox/lifecycle'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -21,18 +21,12 @@ export const GET = withRouteHandler(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
- const [hasAccess, permission] = await Promise.all([
- hasInboxAccess(session.user.id),
- getUserEntityPermissions(session.user.id, 'workspace', workspaceId),
- ])
- if (!hasAccess) {
- return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 })
- }
+ const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (!permission) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
- const [wsResult, statsResult] = await Promise.all([
+ const [wsResult, statsResult, entitled] = await Promise.all([
db
.select({
inboxEnabled: workspace.inboxEnabled,
@@ -49,6 +43,7 @@ export const GET = withRouteHandler(
.from(mothershipInboxTask)
.where(eq(mothershipInboxTask.workspaceId, workspaceId))
.groupBy(mothershipInboxTask.status),
+ hasWorkspaceInboxAccess(workspaceId),
])
const [ws] = wsResult
@@ -73,6 +68,7 @@ export const GET = withRouteHandler(
return NextResponse.json({
enabled: ws.inboxEnabled,
address: ws.inboxAddress,
+ entitled,
taskStats: stats,
})
}
@@ -86,21 +82,24 @@ export const PATCH = withRouteHandler(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
- const [hasAccess, permission] = await Promise.all([
- hasInboxAccess(session.user.id),
- getUserEntityPermissions(session.user.id, 'workspace', workspaceId),
- ])
- if (!hasAccess) {
- return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 })
- }
+ const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission !== 'admin') {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
}
+ const parsed = await parseRequest(updateInboxConfigContract, req, context)
+ if (!parsed.success) return parsed.response
+ const body = parsed.data.body
+
try {
- const parsed = await parseRequest(updateInboxConfigContract, req, context)
- if (!parsed.success) return parsed.response
- const body = parsed.data.body
+ if (body.enabled === false) {
+ await disableInbox(workspaceId)
+ return NextResponse.json({ enabled: false, address: null })
+ }
+
+ if (!(await hasWorkspaceInboxAccess(workspaceId))) {
+ return NextResponse.json({ error: 'Sim Mailer requires a Max plan' }, { status: 403 })
+ }
if (body.enabled === true) {
const [current] = await db
@@ -115,11 +114,6 @@ export const PATCH = withRouteHandler(
return NextResponse.json(config)
}
- if (body.enabled === false) {
- await disableInbox(workspaceId)
- return NextResponse.json({ enabled: false, address: null })
- }
-
if (body.username) {
const config = await updateInboxAddress(workspaceId, body.username)
return NextResponse.json(config)
diff --git a/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts
index a4e2728aec3..eba711ed188 100644
--- a/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/inbox/senders/route.ts
@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { addInboxSenderContract, removeInboxSenderContract } from '@/lib/api/contracts/inbox'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
-import { hasInboxAccess } from '@/lib/billing/core/subscription'
+import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -21,7 +21,7 @@ export const GET = withRouteHandler(
}
const [hasAccess, permission] = await Promise.all([
- hasInboxAccess(session.user.id),
+ hasWorkspaceInboxAccess(workspaceId),
getUserEntityPermissions(session.user.id, 'workspace', workspaceId),
])
if (!hasAccess) {
@@ -77,7 +77,7 @@ export const POST = withRouteHandler(
}
const [hasAccess, permission] = await Promise.all([
- hasInboxAccess(session.user.id),
+ hasWorkspaceInboxAccess(workspaceId),
getUserEntityPermissions(session.user.id, 'workspace', workspaceId),
])
if (!hasAccess) {
@@ -136,7 +136,7 @@ export const DELETE = withRouteHandler(
}
const [hasAccess, permission] = await Promise.all([
- hasInboxAccess(session.user.id),
+ hasWorkspaceInboxAccess(workspaceId),
getUserEntityPermissions(session.user.id, 'workspace', workspaceId),
])
if (!hasAccess) {
diff --git a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts
index 63e4b398a28..82a9e12e383 100644
--- a/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/inbox/tasks/route.ts
@@ -4,7 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { inboxTasksQuerySchema, inboxWorkspaceParamsSchema } from '@/lib/api/contracts/inbox'
import { getValidationErrorMessage } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
-import { hasInboxAccess } from '@/lib/billing/core/subscription'
+import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -24,7 +24,7 @@ export const GET = withRouteHandler(
}
const [hasAccess, permission] = await Promise.all([
- hasInboxAccess(session.user.id),
+ hasWorkspaceInboxAccess(workspaceId),
getUserEntityPermissions(session.user.id, 'workspace', workspaceId),
])
if (!hasAccess) {
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx
index c96f587ccea..f7d9280d2a4 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx
@@ -193,6 +193,8 @@ function formatRejectionReason(reason: string): string {
return 'Automated sender'
case 'rate_limit_exceeded':
return 'Rate limit exceeded'
+ case 'not_entitled':
+ return 'Plan no longer includes Sim Mailer'
default:
return reason
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx
index fd8b348cac7..a88bff9c5ca 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/inbox.tsx
@@ -3,7 +3,7 @@
import { Chip } from '@sim/emcn'
import { ArrowRight } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
-import { getSubscriptionAccessState } from '@/lib/billing/client'
+import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
InboxEnableToggle,
InboxSettingsTab,
@@ -11,9 +11,7 @@ import {
} from '@/app/workspace/[workspaceId]/settings/components/inbox/components'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
-import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
import { useInboxConfig } from '@/hooks/queries/inbox'
-import { useSubscriptionData } from '@/hooks/queries/subscription'
export function Inbox() {
const params = useParams()
@@ -21,16 +19,20 @@ export function Inbox() {
const workspaceId = params.workspaceId as string
const { data: config, isLoading } = useInboxConfig(workspaceId)
- const { data: subscriptionResponse, isLoading: isSubLoading } = useSubscriptionData({
- enabled: isBillingEnabled,
- })
- const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data)
+ const { canAdmin } = useUserPermissionsContext()
- if (isLoading || (isBillingEnabled && isSubLoading)) {
+ if (isLoading) {
return null
}
- if (isBillingEnabled && !subscriptionAccess.hasUsableMaxAccess) {
+ if (!config?.entitled) {
+ if (config?.enabled && canAdmin) {
+ return (
+