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 ( + + + + ) + } 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..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' @@ -518,34 +519,94 @@ export async function isWorkspaceOnEnterprisePlan(workspaceId: string): Promise< } } +const MAX_PLAN_CREDITS = 25000 + +/** + * 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(plan: string): boolean { + return getPlanTierCredits(plan) >= MAX_PLAN_CREDITS || isPlanEnterprise(plan) +} + /** - * Check if user has access to inbox (Sim Mailer) feature + * 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.plan) } - 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.plan) } catch (error) { - logger.error('Error checking inbox access', { error, userId }) + logger.error('Error checking workspace inbox access', { error, workspaceId }) return false } } +/** + * Whether a workspace should RETAIN its provisioned inbox (Sim Mailer) + * infrastructure. Unlike {@link hasWorkspaceInboxAccess}, which gates active use + * on a *usable* (active) subscription, this uses the broader *entitled* status + * set (active OR `past_due`) so a transient payment failure never triggers the + * destructive teardown of a paying customer's inbox. + * + * Reconciliation should delete AgentMail resources only when this returns + * `false` — i.e. the plan is genuinely terminal (canceled, downgraded off + * Max/Enterprise, or gone). Fails open (returns `true`) on any error or + * ambiguity: never tear down on uncertainty. + */ +export async function hasWorkspaceInboxGraceAccess(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 66f820ff3c6..987f7f578de 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -64,6 +64,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',