From d6ec115348d0581fc2e6729298db7f31c776d1d6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 7 Apr 2026 16:11:31 -0700 Subject: [PATCH 1/3] v0.6.29: login improvements, posthog telemetry (#4026) * feat(posthog): Add tracking on mothership abort (#4023) Co-authored-by: Theodore Li * 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 --- apps/sim/app/(auth)/signup/signup-form.tsx | 11 +++-------- .../app/workspace/[workspaceId]/home/home.tsx | 12 ++++++++++-- .../w/[workflowId]/components/panel/panel.tsx | 19 ++++++++++++++++++- apps/sim/lib/posthog/events.ts | 5 +++++ 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 55a0508ec1b..afb27cd729a 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -270,10 +270,8 @@ function SignupFormContent({ name: sanitizedName, }, { - fetchOptions: { - headers: { - ...(token ? { 'x-captcha-response': token } : {}), - }, + headers: { + ...(token ? { 'x-captcha-response': token } : {}), }, onError: (ctx) => { logger.error('Signup error:', ctx.error) @@ -282,10 +280,7 @@ function SignupFormContent({ let errorCode = 'unknown' if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) { errorCode = 'user_already_exists' - errorMessage.push( - 'An account with this email already exists. Please sign in instead.' - ) - setEmailError(errorMessage[0]) + setEmailError('An account with this email already exists. Please sign in instead.') } else if ( ctx.error.code?.includes('BAD_REQUEST') || ctx.error.message?.includes('Email and password sign up is not enabled') diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index d76f17ff454..38367339197 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -223,6 +223,14 @@ export function Home({ chatId }: HomeProps = {}) { posthogRef.current = posthog }, [posthog]) + const handleStopGeneration = useCallback(() => { + captureEvent(posthogRef.current, 'task_generation_aborted', { + workspace_id: workspaceId, + view: 'mothership', + }) + stopGeneration() + }, [stopGeneration, workspaceId]) + const handleSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() @@ -334,7 +342,7 @@ export function Home({ chatId }: HomeProps = {}) { defaultValue={initialPrompt} onSubmit={handleSubmit} isSending={isSending} - onStopGeneration={stopGeneration} + onStopGeneration={handleStopGeneration} userId={session?.user?.id} onContextAdd={handleContextAdd} /> @@ -359,7 +367,7 @@ export function Home({ chatId }: HomeProps = {}) { isSending={isSending} isReconnecting={isReconnecting} onSubmit={handleSubmit} - onStopGeneration={stopGeneration} + onStopGeneration={handleStopGeneration} messageQueue={messageQueue} onRemoveQueuedMessage={removeFromQueue} onSendQueuedMessage={sendNow} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 4d485c763ce..da51910789b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { History, Plus, Square } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' +import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' import { BubbleChatClose, @@ -33,6 +34,7 @@ import { import { Lock, Unlock, Upload } from '@/components/emcn/icons' import { VariableIcon } from '@/components/icons' import { useSession } from '@/lib/auth/auth-client' +import { captureEvent } from '@/lib/posthog/client' import { generateWorkflowJson } from '@/lib/workflows/operations/import-export' import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components' @@ -101,6 +103,9 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const params = useParams() const workspaceId = propWorkspaceId ?? (params.workspaceId as string) + const posthog = usePostHog() + const posthogRef = useRef(posthog) + const panelRef = useRef(null) const fileInputRef = useRef(null) const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore( @@ -264,6 +269,10 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel loadCopilotChats() }, [loadCopilotChats]) + useEffect(() => { + posthogRef.current = posthog + }, [posthog]) + const handleCopilotSelectChat = useCallback((chat: { id: string; title: string | null }) => { setCopilotChatId(chat.id) setCopilotChatTitle(chat.title) @@ -394,6 +403,14 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel [copilotEditQueuedMessage] ) + const handleCopilotStopGeneration = useCallback(() => { + captureEvent(posthogRef.current, 'task_generation_aborted', { + workspace_id: workspaceId, + view: 'copilot', + }) + copilotStopGeneration() + }, [copilotStopGeneration, workspaceId]) + const handleCopilotSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() @@ -833,7 +850,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel isSending={copilotIsSending} isReconnecting={copilotIsReconnecting} onSubmit={handleCopilotSubmit} - onStopGeneration={copilotStopGeneration} + onStopGeneration={handleCopilotStopGeneration} messageQueue={copilotMessageQueue} onRemoveQueuedMessage={copilotRemoveFromQueue} onSendQueuedMessage={copilotSendNow} diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 537a9864282..faf9895bf62 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -378,6 +378,11 @@ export interface PostHogEventMap { workspace_id: string } + task_generation_aborted: { + workspace_id: string + view: 'mothership' | 'copilot' + } + task_message_sent: { workspace_id: string has_attachments: boolean From 8205b380bddda63c1407888e879bcc1ba7148335 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 30 Jun 2026 19:53:16 -0700 Subject: [PATCH 2/3] fix(mailer): permissions entitlements for enabling/disabling --- .../app/api/workspaces/[id]/inbox/route.ts | 42 ++++++-------- .../workspaces/[id]/inbox/senders/route.ts | 8 +-- .../api/workspaces/[id]/inbox/tasks/route.ts | 4 +- .../settings/components/inbox/inbox.tsx | 20 ++++--- .../settings-sidebar/settings-sidebar.tsx | 9 ++- apps/sim/lib/api/contracts/inbox.ts | 1 + apps/sim/lib/billing/core/subscription.ts | 58 +++++++++++++------ 7 files changed, 85 insertions(+), 57 deletions(-) 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/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 ( + + + + ) + } return (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index c2d9284665c..c9ca98d100c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -23,6 +23,7 @@ import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sideb import { useSSOProviders } from '@/ee/sso/hooks/sso' import { prefetchWorkspaceCredentials } from '@/hooks/queries/credentials' import { prefetchGeneralSettings, useGeneralSettings } from '@/hooks/queries/general-settings' +import { useInboxConfig } from '@/hooks/queries/inbox' import { useOrganizations } from '@/hooks/queries/organization' import { prefetchSubscriptionData, useSubscriptionData } from '@/hooks/queries/subscription' import { usePermissionConfig } from '@/hooks/use-permission-config' @@ -63,6 +64,7 @@ export function SettingsSidebar({ enabled: isBillingEnabled, staleTime: 5 * 60 * 1000, }) + const { data: inboxConfig } = useInboxConfig(workspaceId) const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders({ enabled: !isHosted, }) @@ -78,6 +80,7 @@ export function SettingsSidebar({ const isAdmin = userRole === 'admin' const isOrgAdminOrOwner = isOwner || isAdmin const subscriptionAccess = getSubscriptionAccessState(subscriptionData?.data) + const inboxEntitled = inboxConfig?.entitled ?? false const hasTeamPlan = subscriptionAccess.hasUsableTeamAccess const hasEnterprisePlan = subscriptionAccess.hasUsableEnterpriseAccess const isEnterprisePlan = isEnterprise(subscriptionData?.data?.plan) @@ -296,7 +299,11 @@ export function SettingsSidebar({ {sectionItems.map((item) => { const Icon = item.icon const active = activeSection === item.id - const isLocked = item.requiresMax && !subscriptionAccess.hasUsableMaxAccess + const isLocked = + item.requiresMax && + (item.id === 'inbox' + ? !inboxEntitled + : !subscriptionAccess.hasUsableMaxAccess) const itemClassName = chipVariants({ active, fullWidth: true }) const content = ( <> diff --git a/apps/sim/lib/api/contracts/inbox.ts b/apps/sim/lib/api/contracts/inbox.ts index c24304a5eae..34152f3346b 100644 --- a/apps/sim/lib/api/contracts/inbox.ts +++ b/apps/sim/lib/api/contracts/inbox.ts @@ -17,6 +17,7 @@ export const inboxTaskStatusSchema = z.enum([ export const inboxConfigSchema = z.object({ enabled: z.boolean(), address: z.string().nullable(), + entitled: z.boolean(), taskStats: z.object({ total: z.number(), completed: z.number(), diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 6829a773e0a..45e3a221245 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -518,30 +518,54 @@ export async function isWorkspaceOnEnterprisePlan(workspaceId: string): Promise< } } +const MAX_PLAN_CREDITS = 25000 + /** - * Check if user has access to inbox (Sim Mailer) feature + * Whether a resolved subscription entitles the inbox (Sim Mailer) feature: a Max + * tier (credits >= 25000, covering `pro_25000` and `team_25000`) or any + * enterprise plan. + */ +function isInboxEntitledPlan(sub: { plan: string }): boolean { + return getPlanTierCredits(sub.plan) >= MAX_PLAN_CREDITS || checkEnterprisePlan(sub) +} + +/** + * Check whether a workspace is entitled to the inbox (Sim Mailer) feature. + * Entitlement follows the workspace's billing entity — not the acting user — so + * any workspace admin (including an external member) can manage the inbox when + * the workspace's organization, or its billed account for personal workspaces, + * is on a Max or enterprise plan. + * * Returns true if: - * - INBOX_ENABLED env var is set, OR - * - Non-production environment, OR - * - User has a Max plan (credits >= 25000) or enterprise plan + * - INBOX_ENABLED env var is set (self-hosted override), OR + * - billing is disabled, OR + * - the workspace belongs to an organization on a Max/enterprise plan (org-mode), OR + * - the billed user has an individual Max/enterprise subscription (personal workspace). */ -export async function hasInboxAccess(userId: string): Promise { +export async function hasWorkspaceInboxAccess(workspaceId: string): Promise { try { - if (isInboxEnabled) { - return true - } - if (!isBillingEnabled) { - return true + if (isInboxEnabled) return true + if (!isBillingEnabled) return true + + const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils') + const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) + if (!ws) return false + + if (ws.organizationId) { + if (await isOrganizationBillingBlocked(ws.organizationId)) return false + const orgSub = await getOrganizationSubscriptionUsable(ws.organizationId) + return !!orgSub && isInboxEntitledPlan(orgSub) } - const [sub, billingStatus] = await Promise.all([ - getHighestPrioritySubscription(userId), - getEffectiveBillingStatus(userId), + + const [billedSub, billingStatus] = await Promise.all([ + getHighestPrioritySubscription(ws.billedAccountUserId), + getEffectiveBillingStatus(ws.billedAccountUserId), ]) - if (!sub) return false - if (!hasUsableSubscriptionAccess(sub.status, billingStatus.billingBlocked)) return false - return getPlanTierCredits(sub.plan) >= 25000 || checkEnterprisePlan(sub) + if (!billedSub) return false + if (!hasUsableSubscriptionAccess(billedSub.status, billingStatus.billingBlocked)) return false + return isInboxEntitledPlan(billedSub) } catch (error) { - logger.error('Error checking inbox access', { error, userId }) + logger.error('Error checking workspace inbox access', { error, workspaceId }) return false } } From 6c1175fe4d48fdfcd71223cd0487dcdbba0c6588 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 30 Jun 2026 20:41:19 -0700 Subject: [PATCH 3/3] fix lifecycle for agentmail infra --- .../cron/reconcile-inbox-entitlement/route.ts | 66 +++++++++++++++++++ apps/sim/app/api/webhooks/agentmail/route.ts | 45 ++++++++----- .../inbox-task-list/inbox-task-list.tsx | 2 + apps/sim/lib/billing/core/subscription.ts | 51 ++++++++++++-- apps/sim/lib/mothership/inbox/types.ts | 6 +- helm/sim/values.yaml | 12 ++++ scripts/check-api-validation-contracts.ts | 5 +- 7 files changed, 159 insertions(+), 28 deletions(-) create mode 100644 apps/sim/app/api/cron/reconcile-inbox-entitlement/route.ts 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/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/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 45e3a221245..5084ce91081 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -10,6 +10,7 @@ import { } from '@/lib/billing/core/plan' import { getPlanTierCredits, + isEnterprise as isPlanEnterprise, isPro as isPlanPro, isTeam as isPlanTeam, } from '@/lib/billing/plan-helpers' @@ -521,12 +522,13 @@ export async function isWorkspaceOnEnterprisePlan(workspaceId: string): Promise< const MAX_PLAN_CREDITS = 25000 /** - * Whether a resolved subscription entitles the inbox (Sim Mailer) feature: a Max - * tier (credits >= 25000, covering `pro_25000` and `team_25000`) or any - * enterprise plan. + * Whether a plan tier entitles the inbox (Sim Mailer) feature: a Max tier + * (credits >= 25000, covering `pro_25000` and `team_25000`) or any enterprise + * plan. Subscription status (usable vs entitled) is gated by callers before this + * runs — the predicate is tier-only. */ -function isInboxEntitledPlan(sub: { plan: string }): boolean { - return getPlanTierCredits(sub.plan) >= MAX_PLAN_CREDITS || checkEnterprisePlan(sub) +function isInboxEntitledPlan(plan: string): boolean { + return getPlanTierCredits(plan) >= MAX_PLAN_CREDITS || isPlanEnterprise(plan) } /** @@ -554,7 +556,7 @@ export async function hasWorkspaceInboxAccess(workspaceId: string): Promise { + try { + if (isInboxEnabled) return true + if (!isBillingEnabled) return true + + const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils') + const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true }) + if (!ws) return true + + if (ws.organizationId) { + const { getOrganizationSubscription } = await import('@/lib/billing/core/billing') + const orgSub = await getOrganizationSubscription(ws.organizationId) + return !!orgSub && isInboxEntitledPlan(orgSub.plan) + } + + const billedSub = await getHighestPrioritySubscription(ws.billedAccountUserId) + return !!billedSub && isInboxEntitledPlan(billedSub.plan) + } catch (error) { + logger.error('Error checking workspace inbox grace access', { error, workspaceId }) + return true + } +} + /** * Check if user has access to live sync (every 5 minutes) for KB connectors * Returns true if: diff --git a/apps/sim/lib/mothership/inbox/types.ts b/apps/sim/lib/mothership/inbox/types.ts index 764fa01a56e..02fab8a1e9a 100644 --- a/apps/sim/lib/mothership/inbox/types.ts +++ b/apps/sim/lib/mothership/inbox/types.ts @@ -2,7 +2,11 @@ import type { mothershipInboxTask } from '@sim/db' export type InboxTask = typeof mothershipInboxTask.$inferSelect export type InboxTaskStatus = 'received' | 'processing' | 'completed' | 'failed' | 'rejected' -export type RejectionReason = 'sender_not_allowed' | 'automated_sender' | 'rate_limit_exceeded' +export type RejectionReason = + | 'sender_not_allowed' + | 'automated_sender' + | 'rate_limit_exceeded' + | 'not_entitled' export interface InboxConfig { enabled: boolean diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 4ddbd5bc28b..30096678aac 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -1276,6 +1276,18 @@ cronjobs: successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 1 + # Releases AgentMail inbox + webhook for workspaces whose Sim Mailer plan + # lapsed (canceled/downgraded off Max/Enterprise). Tolerates past_due, so a + # transient payment failure never tears down a paying customer's inbox. + reconcileInboxEntitlement: + enabled: true + name: reconcile-inbox-entitlement + schedule: "0 3 * * *" + path: "/api/cron/reconcile-inbox-entitlement" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 1 + workspaceEventsPoll: enabled: true name: workspace-events-poll diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 5387f3cf638..89a31551871 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 873, - zodRoutes: 873, + totalRoutes: 874, + zodRoutes: 874, nonZodRoutes: 0, } as const @@ -65,6 +65,7 @@ const INDIRECT_ZOD_ROUTES = new Set([ 'apps/sim/app/api/cron/cleanup-stale-executions/route.ts', 'apps/sim/app/api/cron/renew-subscriptions/route.ts', 'apps/sim/app/api/cron/reconcile-billing-seats/route.ts', + 'apps/sim/app/api/cron/reconcile-inbox-entitlement/route.ts', 'apps/sim/app/api/cron/run-data-drains/route.ts', 'apps/sim/app/api/logs/cleanup/route.ts', 'apps/sim/app/api/knowledge/connectors/sync/route.ts',