Skip to content

Commit 6c1175f

Browse files
committed
fix lifecycle for agentmail infra
1 parent 8205b38 commit 6c1175f

7 files changed

Lines changed: 159 additions & 28 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/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/lib/billing/core/subscription.ts

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from '@/lib/billing/core/plan'
1111
import {
1212
getPlanTierCredits,
13+
isEnterprise as isPlanEnterprise,
1314
isPro as isPlanPro,
1415
isTeam as isPlanTeam,
1516
} from '@/lib/billing/plan-helpers'
@@ -521,12 +522,13 @@ export async function isWorkspaceOnEnterprisePlan(workspaceId: string): Promise<
521522
const MAX_PLAN_CREDITS = 25000
522523

523524
/**
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.
525+
* Whether a plan tier entitles the inbox (Sim Mailer) feature: a Max tier
526+
* (credits >= 25000, covering `pro_25000` and `team_25000`) or any enterprise
527+
* plan. Subscription status (usable vs entitled) is gated by callers before this
528+
* runs — the predicate is tier-only.
527529
*/
528-
function isInboxEntitledPlan(sub: { plan: string }): boolean {
529-
return getPlanTierCredits(sub.plan) >= MAX_PLAN_CREDITS || checkEnterprisePlan(sub)
530+
function isInboxEntitledPlan(plan: string): boolean {
531+
return getPlanTierCredits(plan) >= MAX_PLAN_CREDITS || isPlanEnterprise(plan)
530532
}
531533

532534
/**
@@ -554,7 +556,7 @@ export async function hasWorkspaceInboxAccess(workspaceId: string): Promise<bool
554556
if (ws.organizationId) {
555557
if (await isOrganizationBillingBlocked(ws.organizationId)) return false
556558
const orgSub = await getOrganizationSubscriptionUsable(ws.organizationId)
557-
return !!orgSub && isInboxEntitledPlan(orgSub)
559+
return !!orgSub && isInboxEntitledPlan(orgSub.plan)
558560
}
559561

560562
const [billedSub, billingStatus] = await Promise.all([
@@ -563,13 +565,48 @@ export async function hasWorkspaceInboxAccess(workspaceId: string): Promise<bool
563565
])
564566
if (!billedSub) return false
565567
if (!hasUsableSubscriptionAccess(billedSub.status, billingStatus.billingBlocked)) return false
566-
return isInboxEntitledPlan(billedSub)
568+
return isInboxEntitledPlan(billedSub.plan)
567569
} catch (error) {
568570
logger.error('Error checking workspace inbox access', { error, workspaceId })
569571
return false
570572
}
571573
}
572574

575+
/**
576+
* Whether a workspace should RETAIN its provisioned inbox (Sim Mailer)
577+
* infrastructure. Unlike {@link hasWorkspaceInboxAccess}, which gates active use
578+
* on a *usable* (active) subscription, this uses the broader *entitled* status
579+
* set (active OR `past_due`) so a transient payment failure never triggers the
580+
* destructive teardown of a paying customer's inbox.
581+
*
582+
* Reconciliation should delete AgentMail resources only when this returns
583+
* `false` — i.e. the plan is genuinely terminal (canceled, downgraded off
584+
* Max/Enterprise, or gone). Fails open (returns `true`) on any error or
585+
* ambiguity: never tear down on uncertainty.
586+
*/
587+
export async function hasWorkspaceInboxGraceAccess(workspaceId: string): Promise<boolean> {
588+
try {
589+
if (isInboxEnabled) return true
590+
if (!isBillingEnabled) return true
591+
592+
const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils')
593+
const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true })
594+
if (!ws) return true
595+
596+
if (ws.organizationId) {
597+
const { getOrganizationSubscription } = await import('@/lib/billing/core/billing')
598+
const orgSub = await getOrganizationSubscription(ws.organizationId)
599+
return !!orgSub && isInboxEntitledPlan(orgSub.plan)
600+
}
601+
602+
const billedSub = await getHighestPrioritySubscription(ws.billedAccountUserId)
603+
return !!billedSub && isInboxEntitledPlan(billedSub.plan)
604+
} catch (error) {
605+
logger.error('Error checking workspace inbox grace access', { error, workspaceId })
606+
return true
607+
}
608+
}
609+
573610
/**
574611
* Check if user has access to live sync (every 5 minutes) for KB connectors
575612
* Returns true if:

apps/sim/lib/mothership/inbox/types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import type { mothershipInboxTask } from '@sim/db'
22

33
export type InboxTask = typeof mothershipInboxTask.$inferSelect
44
export type InboxTaskStatus = 'received' | 'processing' | 'completed' | 'failed' | 'rejected'
5-
export type RejectionReason = 'sender_not_allowed' | 'automated_sender' | 'rate_limit_exceeded'
5+
export type RejectionReason =
6+
| 'sender_not_allowed'
7+
| 'automated_sender'
8+
| 'rate_limit_exceeded'
9+
| 'not_entitled'
610

711
export interface InboxConfig {
812
enabled: boolean

helm/sim/values.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1276,6 +1276,18 @@ cronjobs:
12761276
successfulJobsHistoryLimit: 3
12771277
failedJobsHistoryLimit: 1
12781278

1279+
# Releases AgentMail inbox + webhook for workspaces whose Sim Mailer plan
1280+
# lapsed (canceled/downgraded off Max/Enterprise). Tolerates past_due, so a
1281+
# transient payment failure never tears down a paying customer's inbox.
1282+
reconcileInboxEntitlement:
1283+
enabled: true
1284+
name: reconcile-inbox-entitlement
1285+
schedule: "0 3 * * *"
1286+
path: "/api/cron/reconcile-inbox-entitlement"
1287+
concurrencyPolicy: Forbid
1288+
successfulJobsHistoryLimit: 3
1289+
failedJobsHistoryLimit: 1
1290+
12791291
workspaceEventsPoll:
12801292
enabled: true
12811293
name: workspace-events-poll

scripts/check-api-validation-contracts.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
99
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
1010

1111
const BASELINE = {
12-
totalRoutes: 873,
13-
zodRoutes: 873,
12+
totalRoutes: 874,
13+
zodRoutes: 874,
1414
nonZodRoutes: 0,
1515
} as const
1616

@@ -65,6 +65,7 @@ const INDIRECT_ZOD_ROUTES = new Set([
6565
'apps/sim/app/api/cron/cleanup-stale-executions/route.ts',
6666
'apps/sim/app/api/cron/renew-subscriptions/route.ts',
6767
'apps/sim/app/api/cron/reconcile-billing-seats/route.ts',
68+
'apps/sim/app/api/cron/reconcile-inbox-entitlement/route.ts',
6869
'apps/sim/app/api/cron/run-data-drains/route.ts',
6970
'apps/sim/app/api/logs/cleanup/route.ts',
7071
'apps/sim/app/api/knowledge/connectors/sync/route.ts',

0 commit comments

Comments
 (0)