diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.test.tsx
new file mode 100644
index 00000000000..7effe3be58c
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.test.tsx
@@ -0,0 +1,150 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, type ReactNode } from 'react'
+import { QueryClient, QueryClientProvider, useMutation } from '@tanstack/react-query'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({ enabled: false, toggle: vi.fn() }))
+vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }) }))
+vi.mock('@/hooks/queries/inbox', () => ({
+ useInboxConfig: () => ({ data: { enabled: mocks.enabled, address: 'inbox@example.com' } }),
+ useToggleInbox: () => useMutation({ mutationFn: mocks.toggle }),
+}))
+vi.mock('@sim/emcn', () => ({
+ Label: ({ children }: { children: ReactNode }) => {children},
+ ChipSwitch: ({ onChange }: { onChange: (value: string) => void }) => (
+ <>
+
+
+ >
+ ),
+ ChipModal: ({ open, children }: { open: boolean; children: ReactNode }) =>
+ open ? : null,
+ ChipModalHeader: ({ children }: { children: ReactNode }) =>
{children}
,
+ ChipModalBody: ({ children }: { children: ReactNode }) => {children}
,
+ ChipModalField: () => null,
+ ChipModalError: ({ children }: { children: ReactNode }) =>
+ children ? {children}
: null,
+ ChipModalFooter: ({
+ onCancel,
+ primaryAction,
+ }: {
+ onCancel: () => void
+ primaryAction: { label: string; onClick: () => void }
+ }) => (
+ <>
+
+
+ >
+ ),
+ ChipConfirmModal: ({
+ open,
+ children,
+ onOpenChange,
+ confirm,
+ }: {
+ open: boolean
+ children: ReactNode
+ onOpenChange: (open: boolean) => void
+ confirm: { label: string; onClick: () => void }
+ }) =>
+ open ? (
+
+ {children}
+
+
+
+ ) : null,
+}))
+
+import { InboxEnableToggle } from '@/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle'
+
+let container: HTMLDivElement
+let root: Root
+let client: QueryClient
+beforeEach(() => {
+ vi.useFakeTimers()
+ vi.clearAllMocks()
+ mocks.enabled = false
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
+})
+afterEach(() => {
+ act(() => root.unmount())
+ client.clear()
+ container.remove()
+ vi.useRealTimers()
+})
+
+function render() {
+ act(() =>
+ root.render(
+
+
+
+ )
+ )
+}
+async function click(label: string) {
+ const button = Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent === label
+ )
+ expect(button).toBeDefined()
+ await act(async () => {
+ button?.click()
+ await vi.runAllTimersAsync()
+ })
+}
+
+describe('inbox setup error visibility', () => {
+ it.each([
+ { enabled: false, toggle: 'On', submit: 'Enable' },
+ { enabled: true, toggle: 'Off', submit: 'Disable inbox' },
+ ])(
+ 'keeps the dialog open and displays a failed $submit request',
+ async ({ enabled, toggle, submit }) => {
+ mocks.enabled = enabled
+ mocks.toggle.mockRejectedValueOnce(new Error('Email service unavailable'))
+ render()
+ await click(toggle)
+ await click(submit)
+ expect(container.querySelector('[role="alert"]')?.textContent).toBe(
+ 'Email service unavailable'
+ )
+ expect(container.querySelector('[role="dialog"]')).not.toBeNull()
+ await click('Cancel')
+ await click(toggle)
+ expect(container.querySelector('[role="alert"]')).toBeNull()
+ }
+ )
+
+ it('clears the failure and closes after a successful retry', async () => {
+ mocks.toggle
+ .mockRejectedValueOnce(new Error('Email service unavailable'))
+ .mockResolvedValueOnce({ enabled: true })
+ render()
+ await click('On')
+ await click('Enable')
+ expect(container.querySelector('[role="alert"]')).not.toBeNull()
+ await click('Enable')
+ expect(container.querySelector('[role="dialog"]')).toBeNull()
+ expect(mocks.toggle).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx
index 0159c6ac807..41c87b24cf0 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx
@@ -1,21 +1,25 @@
'use client'
-import { useCallback, useState } from 'react'
+import { useState } from 'react'
import {
ChipConfirmModal,
ChipModal,
ChipModalBody,
+ ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
+ ChipSwitch,
Label,
- Switch,
} from '@sim/emcn'
-import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
import { useParams } from 'next/navigation'
import { useInboxConfig, useToggleInbox } from '@/hooks/queries/inbox'
-const logger = createLogger('InboxEnableToggle')
+const INBOX_OPTIONS = [
+ { value: 'enabled', label: 'On' },
+ { value: 'disabled', label: 'Off' },
+] as const
export function InboxEnableToggle() {
const params = useParams()
@@ -28,56 +32,68 @@ export function InboxEnableToggle() {
const [isDisableOpen, setIsDisableOpen] = useState(false)
const [enableUsername, setEnableUsername] = useState('')
- const handleToggle = useCallback(async (checked: boolean) => {
+ function handleToggle(checked: boolean) {
+ toggleInbox.reset()
if (checked) {
setIsEnableOpen(true)
- return
+ } else {
+ setIsDisableOpen(true)
}
- setIsDisableOpen(true)
- }, [])
+ }
- const handleDisable = useCallback(async () => {
- try {
- await toggleInbox.mutateAsync({ workspaceId, enabled: false })
- setIsDisableOpen(false)
- } catch (error) {
- logger.error('Failed to disable inbox', { error })
- }
- }, [workspaceId, toggleInbox.mutateAsync])
+ function handleEnableOpenChange(open: boolean) {
+ if (!toggleInbox.isPending) setIsEnableOpen(open)
+ }
- const handleEnable = useCallback(async () => {
- try {
- await toggleInbox.mutateAsync({
- workspaceId,
- enabled: true,
- username: enableUsername.trim() || undefined,
- })
- setIsEnableOpen(false)
- setEnableUsername('')
- } catch (error) {
- logger.error('Failed to enable inbox', { error })
- }
- }, [workspaceId, enableUsername, toggleInbox.mutateAsync])
+ function handleDisable() {
+ toggleInbox.mutate(
+ { workspaceId, enabled: false },
+ { onSuccess: () => setIsDisableOpen(false) }
+ )
+ }
+
+ function handleEnable() {
+ toggleInbox.mutate(
+ { workspaceId, enabled: true, username: enableUsername.trim() || undefined },
+ {
+ onSuccess: () => {
+ setIsEnableOpen(false)
+ setEnableUsername('')
+ },
+ }
+ )
+ }
+
+ const error = toggleInbox.error
+ ? getErrorMessage(toggleInbox.error, 'Failed to update inbox')
+ : null
return (
<>
-
+
Allow this workspace to receive tasks via email
-
handleToggle(value === 'enabled')}
disabled={toggleInbox.isPending}
/>
-
- setIsEnableOpen(false)}>Enable email inbox
+
+ handleEnableOpenChange(false)}>
+ Enable email inbox
+
An email address will be created for this workspace. Anyone in the allowed senders list
@@ -93,11 +109,13 @@ export function InboxEnableToggle() {
Leave blank for an auto-generated address.
+ {error}
setIsEnableOpen(false)}
+ onCancel={() => handleEnableOpenChange(false)}
+ cancelDisabled={toggleInbox.isPending}
primaryAction={{
- label: 'Enable',
+ label: toggleInbox.isPending ? 'Enabling...' : 'Enable',
onClick: handleEnable,
disabled: toggleInbox.isPending,
}}
@@ -125,6 +143,7 @@ export function InboxEnableToggle() {
Your existing conversations and task history will be preserved.
+ {error}
>
)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx
index 894a55ef643..b6274cde49d 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx
@@ -296,6 +296,11 @@ export function InboxSettingsTab() {
)}
+ {updateSecretPolicy.error && (
+
+ {getErrorMessage(updateSecretPolicy.error, 'Failed to update secret access')}
+
+ )}
diff --git a/apps/sim/lib/billing/core/inbox-entitlement.test.ts b/apps/sim/lib/billing/core/inbox-entitlement.test.ts
new file mode 100644
index 00000000000..38073f0b69e
--- /dev/null
+++ b/apps/sim/lib/billing/core/inbox-entitlement.test.ts
@@ -0,0 +1,203 @@
+/**
+ * @vitest-environment node
+ */
+import {
+ dbChainMockFns,
+ resetDbChainMock,
+ resetEnvFlagsMock,
+ resetEnvMock,
+ setEnv,
+ setEnvFlags,
+} from '@sim/testing'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockGetPersonalSubscription,
+ mockGetOrganizationSubscription,
+ mockGetWorkspaceWithOwner,
+ mockGetEffectiveBillingStatus,
+ mockIsOrganizationBillingBlocked,
+} = vi.hoisted(() => ({
+ mockGetPersonalSubscription: vi.fn(),
+ mockGetOrganizationSubscription: vi.fn(),
+ mockGetWorkspaceWithOwner: vi.fn(),
+ mockGetEffectiveBillingStatus: vi.fn(),
+ mockIsOrganizationBillingBlocked: vi.fn(),
+}))
+
+vi.mock('@/lib/billing/core/plan', () => ({
+ getHighestPriorityPersonalSubscription: mockGetPersonalSubscription,
+ getHighestPrioritySubscription: vi.fn(),
+}))
+
+vi.mock('@/lib/billing/core/billing', () => ({
+ getOrganizationSubscription: mockGetOrganizationSubscription,
+}))
+
+vi.mock('@/lib/billing/core/access', () => ({
+ getEffectiveBillingStatus: mockGetEffectiveBillingStatus,
+ isOrganizationBillingBlocked: mockIsOrganizationBillingBlocked,
+}))
+
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ getWorkspaceWithOwner: mockGetWorkspaceWithOwner,
+}))
+
+import {
+ hasWorkspaceInboxAccess,
+ hasWorkspaceInboxGraceAccess,
+} from '@/lib/billing/core/subscription'
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ setEnv({ COPILOT_API_KEY: 'test-copilot-key' })
+ setEnvFlags({ isHosted: true, isBillingEnabled: true, isInboxEnabled: false })
+ mockGetWorkspaceWithOwner.mockResolvedValue({
+ id: 'workspace-1',
+ billedAccountUserId: 'payer-1',
+ organizationId: null,
+ })
+ mockGetPersonalSubscription.mockResolvedValue(null)
+ mockGetOrganizationSubscription.mockResolvedValue(null)
+ mockGetEffectiveBillingStatus.mockResolvedValue({
+ billingBlocked: false,
+ billingBlockedReason: null,
+ blockedByOrgOwner: false,
+ })
+ mockIsOrganizationBillingBlocked.mockResolvedValue(false)
+})
+
+afterEach(() => {
+ resetEnvFlagsMock()
+ resetEnvMock()
+})
+
+describe('Sim Mailer hosted entitlement', () => {
+ it.each([
+ { isInboxEnabled: true, isBillingEnabled: true },
+ { isInboxEnabled: false, isBillingEnabled: false },
+ { isInboxEnabled: true, isBillingEnabled: false },
+ ])('requires a qualifying payer despite deployment flags %o', async (flags) => {
+ setEnvFlags(flags)
+
+ await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(false)
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(false)
+ expect(mockGetPersonalSubscription).toHaveBeenCalledWith('payer-1')
+ })
+
+ it.each([
+ ['pro_25000', true],
+ ['enterprise', true],
+ ['pro_6000', false],
+ ['pro', false],
+ ['free', false],
+ ])('checks the personal workspace payer plan %s', async (plan, expected) => {
+ mockGetPersonalSubscription.mockResolvedValue({ plan, status: 'active' })
+
+ await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(expected)
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(expected)
+ })
+
+ it.each([
+ ['team_25000', true],
+ ['enterprise', true],
+ ['team_6000', false],
+ ])(
+ 'checks the organization payer plan %s without requiring a personal plan',
+ async (plan, expected) => {
+ mockGetWorkspaceWithOwner.mockResolvedValue({
+ id: 'workspace-1',
+ billedAccountUserId: 'payer-1',
+ organizationId: 'org-1',
+ })
+ dbChainMockFns.limit.mockResolvedValueOnce([{ plan, status: 'active' }])
+ mockGetOrganizationSubscription.mockResolvedValue({ plan, status: 'active' })
+
+ await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(expected)
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(expected)
+ expect(mockGetPersonalSubscription).not.toHaveBeenCalled()
+ expect(mockGetOrganizationSubscription).toHaveBeenCalledWith('org-1', { onError: 'throw' })
+ }
+ )
+
+ it('blocks a past-due Max payer from use while preserving provisioned resources', async () => {
+ mockGetPersonalSubscription.mockResolvedValue({ plan: 'pro_25000', status: 'past_due' })
+
+ await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(false)
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true)
+ })
+
+ it('blocks a billing-blocked Max payer from use while preserving provisioned resources', async () => {
+ mockGetPersonalSubscription.mockResolvedValue({ plan: 'pro_25000', status: 'active' })
+ mockGetEffectiveBillingStatus.mockResolvedValue({ billingBlocked: true })
+
+ await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(false)
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true)
+ })
+
+ it('requires the execution key for use without destroying resources when it is missing', async () => {
+ setEnv({ COPILOT_API_KEY: undefined })
+ mockGetPersonalSubscription.mockResolvedValue({ plan: 'pro_25000', status: 'active' })
+
+ await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(false)
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true)
+ })
+})
+
+describe('Sim Mailer cleanup uncertainty', () => {
+ it('preserves the inbox if the workspace cannot be found', async () => {
+ mockGetWorkspaceWithOwner.mockResolvedValue(null)
+
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true)
+ })
+
+ it('preserves the inbox on a workspace lookup failure', async () => {
+ mockGetWorkspaceWithOwner.mockRejectedValue(new Error('Database unavailable'))
+
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true)
+ })
+
+ it('requires the personal subscription reader to surface errors and preserves the inbox', async () => {
+ mockGetPersonalSubscription.mockRejectedValue(new Error('Database unavailable'))
+
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true)
+ expect(mockGetPersonalSubscription).toHaveBeenCalledWith('payer-1', { onError: 'throw' })
+ })
+
+ it('requires the organization subscription reader to surface errors and preserves the inbox', async () => {
+ mockGetWorkspaceWithOwner.mockResolvedValue({
+ id: 'workspace-1',
+ billedAccountUserId: 'payer-1',
+ organizationId: 'org-1',
+ })
+ mockGetOrganizationSubscription.mockRejectedValue(new Error('Database unavailable'))
+
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true)
+ expect(mockGetOrganizationSubscription).toHaveBeenCalledWith('org-1', { onError: 'throw' })
+ })
+
+ it('retains past-due Max for Teams resources', async () => {
+ mockGetWorkspaceWithOwner.mockResolvedValue({
+ id: 'workspace-1',
+ billedAccountUserId: 'payer-1',
+ organizationId: 'org-1',
+ })
+ mockGetOrganizationSubscription.mockResolvedValue({ plan: 'team_25000', status: 'past_due' })
+
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true)
+ })
+})
+
+describe('Sim Mailer self-hosted overrides', () => {
+ it.each([
+ { isInboxEnabled: true, isBillingEnabled: true },
+ { isInboxEnabled: false, isBillingEnabled: false },
+ ])('preserves self-hosted configuration %o', async (flags) => {
+ setEnvFlags({ isHosted: false, ...flags })
+
+ await expect(hasWorkspaceInboxAccess('workspace-1')).resolves.toBe(true)
+ await expect(hasWorkspaceInboxGraceAccess('workspace-1')).resolves.toBe(true)
+ expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts
index 10b709b54bd..64c6b0c8f6d 100644
--- a/apps/sim/lib/billing/core/subscription.ts
+++ b/apps/sim/lib/billing/core/subscription.ts
@@ -803,16 +803,14 @@ const hasMaxTierWorkspaceAccess = cache(
* Inbox.
*
* Otherwise returns true if:
- * - INBOX_ENABLED env var is set (self-hosted override), OR
- * - billing is disabled, OR
+ * - on self-hosted deployments, INBOX_ENABLED is set 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 hasWorkspaceInboxAccess(workspaceId: string): Promise {
try {
if (!env.COPILOT_API_KEY) return false
- if (isInboxEnabled) return true
- if (!isBillingEnabled) return true
+ if (!isHosted && (isInboxEnabled || !isBillingEnabled)) return true
return await hasMaxTierWorkspaceAccess(workspaceId)
} catch (error) {
logger.error('Error checking workspace inbox access', { error, workspaceId })
@@ -834,12 +832,12 @@ export async function hasWorkspaceInboxAccess(workspaceId: string): Promise {
try {
- if (isInboxEnabled) return true
- if (!isBillingEnabled) return true
+ if (!isHosted && (isInboxEnabled || !isBillingEnabled)) return true
return await hasWorkspaceTierAccess(workspaceId, isMaxTier, {
intent: 'retention',
onMissingWorkspace: true,
+ onError: 'throw',
})
} catch (error) {
logger.error('Error checking workspace inbox grace access', { error, workspaceId })
diff --git a/apps/sim/lib/core/outbox/processor.test.ts b/apps/sim/lib/core/outbox/processor.test.ts
index e63b025eca5..d20af17bc58 100644
--- a/apps/sim/lib/core/outbox/processor.test.ts
+++ b/apps/sim/lib/core/outbox/processor.test.ts
@@ -33,6 +33,7 @@ vi.mock('@/lib/knowledge/application/slack-search/outbox', () => ({
vi.mock('@/lib/knowledge/documents/processing-outbox-handler', () => ({
knowledgeDocumentProcessingOutboxHandlers: {},
}))
+vi.mock('@/lib/mothership/inbox/cleanup-outbox', () => ({ inboxCleanupOutboxHandlers: {} }))
vi.mock('@/lib/organizations/resource-cleanup', () => ({
organizationResourceCleanupOutboxHandlers: {},
}))
diff --git a/apps/sim/lib/core/outbox/processor.ts b/apps/sim/lib/core/outbox/processor.ts
index 7c9fbe9fea7..464460579bc 100644
--- a/apps/sim/lib/core/outbox/processor.ts
+++ b/apps/sim/lib/core/outbox/processor.ts
@@ -18,6 +18,7 @@ import { slackSearchOutboxHandlers } from '@/lib/knowledge/application/slack-sea
import { getConnectorFailureDiagnostic } from '@/lib/knowledge/connectors/connector-error'
import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler'
import { recoverKnowledgeDocumentProcessing } from '@/lib/knowledge/documents/processing-recovery'
+import { inboxCleanupOutboxHandlers } from '@/lib/mothership/inbox/cleanup-outbox'
import { organizationResourceCleanupOutboxHandlers } from '@/lib/organizations/resource-cleanup'
import { permissionAccessRequestOutboxHandlers } from '@/lib/permission-access-requests/notifications'
import { workspaceFileLiveDocOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox'
@@ -42,6 +43,7 @@ const handlers = {
...directGrantOutboxHandlers,
...knowledgeDocumentProcessingOutboxHandlers,
...organizationResourceCleanupOutboxHandlers,
+ ...inboxCleanupOutboxHandlers,
...permissionAccessRequestOutboxHandlers,
...workspaceFileLiveDocOutboxHandlers,
...workspaceFileStorageCleanupOutboxHandlers,
diff --git a/apps/sim/lib/core/outbox/service.ts b/apps/sim/lib/core/outbox/service.ts
index 967e2a6e487..f3b857699e4 100644
--- a/apps/sim/lib/core/outbox/service.ts
+++ b/apps/sim/lib/core/outbox/service.ts
@@ -83,7 +83,8 @@ export interface DeferredOutboxHandlerResult {
* Defaults to true for an external acknowledgement with a finite retry
* budget. False is reserved for waits on an internal dependency whose own
* outbox row independently reaches completed or dead-letter, and for
- * bounded continuation after durable progress (`continueOutboxHandler`).
+ * bounded continuation after durable progress (`continueOutboxHandler`),
+ * or external polling with a separately persisted, finite poll allowance.
*/
consumeAttempt?: boolean
}
diff --git a/apps/sim/lib/mothership/inbox/agentmail-client.test.ts b/apps/sim/lib/mothership/inbox/agentmail-client.test.ts
new file mode 100644
index 00000000000..17a5bc3790c
--- /dev/null
+++ b/apps/sim/lib/mothership/inbox/agentmail-client.test.ts
@@ -0,0 +1,68 @@
+/**
+ * @vitest-environment node
+ */
+import { resetEnvMock, setEnv } from '@sim/testing'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ createInbox,
+ createWebhook,
+ deleteInbox,
+ deleteWebhook,
+ getInbox,
+} from '@/lib/mothership/inbox/agentmail-client'
+
+const fetchMock = vi.fn()
+beforeEach(() => {
+ fetchMock.mockReset()
+ setEnv({ AGENTMAIL_API_KEY: 'test-key' })
+ vi.stubGlobal('fetch', fetchMock)
+})
+afterEach(() => {
+ vi.unstubAllGlobals()
+ resetEnvMock()
+})
+
+describe('AgentMail resource deletion', () => {
+ it.each([deleteInbox, deleteWebhook])(
+ 'accepts an empty 202 without attempting JSON parsing',
+ async (remove) => {
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 202 }))
+ await expect(remove('resource-id')).resolves.toBe(false)
+ }
+ )
+ it.each([204, 404])('treats status %s as completed deletion', async (status) => {
+ fetchMock.mockResolvedValueOnce(new Response(null, { status }))
+ await expect(deleteInbox('resource-id')).resolves.toBe(true)
+ })
+ it('does not treat a conflict as completed deletion', async () => {
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 409 }))
+ await expect(deleteInbox('resource-id')).rejects.toThrow()
+ })
+ it('returns null only for a missing inbox', async () => {
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 404 }))
+ await expect(getInbox('resource-id')).resolves.toBeNull()
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 503 }))
+ await expect(getInbox('resource-id')).rejects.toThrow()
+ })
+ it('never exposes provider response bodies in user-facing errors', async () => {
+ fetchMock.mockResolvedValueOnce(new Response('private provider diagnostic', { status: 400 }))
+ await expect(createInbox({ username: 'test' })).rejects.toThrow('Check the email prefix')
+ })
+
+ it('reports an address conflict only when creating an inbox', async () => {
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 409 }))
+ await expect(createInbox({ username: 'test' })).rejects.toThrow(
+ 'This email address is unavailable'
+ )
+ })
+ it('reports webhook conflicts as a service failure instead of an address conflict', async () => {
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 409 }))
+ await expect(
+ createWebhook({
+ url: 'https://example.com/webhook',
+ eventTypes: ['message.received'],
+ inboxIds: ['inbox@example.com'],
+ })
+ ).rejects.toThrow('The email service is unavailable')
+ })
+})
diff --git a/apps/sim/lib/mothership/inbox/agentmail-client.ts b/apps/sim/lib/mothership/inbox/agentmail-client.ts
index 3f7a0dfe5e4..9373eb7ce01 100644
--- a/apps/sim/lib/mothership/inbox/agentmail-client.ts
+++ b/apps/sim/lib/mothership/inbox/agentmail-client.ts
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { env } from '@/lib/core/config/env'
+import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
import type {
AgentMailAttachment,
AgentMailInbox,
@@ -20,7 +21,23 @@ function getApiKey(): string {
return key
}
-async function request(path: string, options: RequestInit = {}): Promise {
+class AgentMailError extends Error {
+ constructor(
+ readonly status: number,
+ path: string
+ ) {
+ super(
+ status === 409 && path === '/inboxes'
+ ? 'This email address is unavailable. Choose another prefix or try again later.'
+ : status === 400 && path === '/inboxes'
+ ? 'Unable to create this inbox. Check the email prefix and try again, or contact support.'
+ : 'The email service is unavailable. Please try again later or contact support.'
+ )
+ this.name = 'AgentMailError'
+ }
+}
+
+async function requestResponse(path: string, options: RequestInit = {}): Promise {
const url = `${BASE_URL}${path}`
const response = await fetch(url, {
...options,
@@ -32,20 +49,59 @@ async function request(path: string, options: RequestInit = {}): Promise {
})
if (!response.ok) {
- const body = await response.text().catch(() => '')
- logger.error('AgentMail API error', {
- status: response.status,
- path,
- body,
- })
- throw new Error(`AgentMail API error: ${response.status} ${body}`)
+ logger.error('AgentMail API error', { status: response.status, path })
+ await response.body?.cancel()
+ throw new AgentMailError(response.status, path)
+ }
+ return response
+}
+
+async function request(path: string, options: RequestInit = {}): Promise {
+ const response = await requestResponse(path, options)
+ return response.json() as Promise
+}
+
+function withResourceTimeout(options: RequestInit): RequestInit {
+ return {
+ ...options,
+ signal: options.signal
+ ? AbortSignal.any([options.signal, AbortSignal.timeout(15_000)])
+ : AbortSignal.timeout(15_000),
}
+}
+
+async function requestResource(path: string, options: RequestInit = {}): Promise {
+ const response = await requestResponse(path, withResourceTimeout(options))
+ return readResponseJsonWithLimit(response, {
+ maxBytes: 64 * 1024,
+ label: 'AgentMail resource',
+ })
+}
- if (response.status === 204) {
- return undefined as T
+/** Treat already-absent resources as deleted, and distinguish asynchronous acceptance. */
+async function deleteResource(path: string, signal?: AbortSignal): Promise {
+ try {
+ const response = await requestResponse(path, withResourceTimeout({ method: 'DELETE', signal }))
+ await response.body?.cancel()
+ return response.status !== 202
+ } catch (error) {
+ if (error instanceof AgentMailError && error.status === 404) return true
+ throw error
}
+}
- return response.json() as Promise
+export async function getInbox(
+ inboxId: string,
+ signal?: AbortSignal
+): Promise {
+ try {
+ return await requestResource(`/inboxes/${encodeURIComponent(inboxId)}`, {
+ signal,
+ })
+ } catch (error) {
+ if (error instanceof AgentMailError && error.status === 404) return null
+ throw error
+ }
}
export async function createInbox(opts: {
@@ -53,7 +109,7 @@ export async function createInbox(opts: {
displayName?: string
}): Promise {
const domain = env.AGENTMAIL_DOMAIN
- return request('/inboxes', {
+ return requestResource('/inboxes', {
method: 'POST',
body: JSON.stringify({
username: opts.username,
@@ -63,10 +119,8 @@ export async function createInbox(opts: {
})
}
-export async function deleteInbox(inboxId: string): Promise {
- return request(`/inboxes/${encodeURIComponent(inboxId)}`, {
- method: 'DELETE',
- })
+export function deleteInbox(inboxId: string, signal?: AbortSignal): Promise {
+ return deleteResource(`/inboxes/${encodeURIComponent(inboxId)}`, signal)
}
export async function createWebhook(opts: {
@@ -74,7 +128,7 @@ export async function createWebhook(opts: {
eventTypes: string[]
inboxIds: string[]
}): Promise {
- return request('/webhooks', {
+ return requestResource('/webhooks', {
method: 'POST',
body: JSON.stringify({
url: opts.url,
@@ -84,10 +138,8 @@ export async function createWebhook(opts: {
})
}
-export async function deleteWebhook(webhookId: string): Promise {
- return request(`/webhooks/${encodeURIComponent(webhookId)}`, {
- method: 'DELETE',
- })
+export function deleteWebhook(webhookId: string, signal?: AbortSignal): Promise {
+ return deleteResource(`/webhooks/${encodeURIComponent(webhookId)}`, signal)
}
export async function replyToMessage(
diff --git a/apps/sim/lib/mothership/inbox/cleanup-outbox.test.ts b/apps/sim/lib/mothership/inbox/cleanup-outbox.test.ts
new file mode 100644
index 00000000000..0d2939ef6f1
--- /dev/null
+++ b/apps/sim/lib/mothership/inbox/cleanup-outbox.test.ts
@@ -0,0 +1,231 @@
+/**
+ * @vitest-environment node
+ */
+
+import { db } from '@sim/db'
+import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { OutboxEventContext } from '@/lib/core/outbox/service'
+import * as outboxService from '@/lib/core/outbox/service'
+
+const mocks = vi.hoisted(() => ({
+ getInbox: vi.fn(),
+ deleteInbox: vi.fn(),
+ deleteWebhook: vi.fn(),
+}))
+vi.mock('@/lib/mothership/inbox/agentmail-client', () => mocks)
+
+import {
+ cancelInboxCleanup,
+ type InboxCleanupPayload,
+ inboxCleanupOutboxHandlers,
+ processInboxCleanupNow,
+} from '@/lib/mothership/inbox/cleanup-outbox'
+
+const cleanup = inboxCleanupOutboxHandlers['inbox.resources.cleanup']
+const payload = {
+ inboxId: 'old@example.com',
+ inboxCreatedAt: '2025-01-01T00:00:00Z',
+ webhookId: 'old-hook',
+}
+let context: OutboxEventContext
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ mocks.getInbox.mockResolvedValue({
+ inbox_id: payload.inboxId,
+ created_at: payload.inboxCreatedAt,
+ })
+ mocks.deleteInbox.mockResolvedValue(true)
+ mocks.deleteWebhook.mockResolvedValue(true)
+ context = {
+ eventId: 'event-1',
+ eventType: 'inbox.resources.cleanup',
+ attempts: 0,
+ maxAttempts: 10,
+ signal: new AbortController().signal,
+ checkpointPayload: vi.fn().mockResolvedValue(undefined),
+ }
+})
+
+describe('durable inbox cleanup', () => {
+ it('refuses resources still referenced by an active configuration', async () => {
+ queueTableRows(schemaMock.mothershipInboxWebhook, [{ id: 'active-hook' }])
+ await expect(cleanup(payload, context)).rejects.toThrow('still in use')
+ expect(mocks.deleteWebhook).not.toHaveBeenCalled()
+ expect(mocks.deleteInbox).not.toHaveBeenCalled()
+ })
+
+ it('refuses an inbox still referenced by a workspace', async () => {
+ queueTableRows(schemaMock.workspace, [{ id: 'workspace-1' }])
+ await expect(cleanup({ ...payload, webhookId: null }, context)).rejects.toThrow('still in use')
+ expect(mocks.deleteInbox).not.toHaveBeenCalled()
+ })
+
+ it('surfaces provider failures for the outbox retry policy', async () => {
+ mocks.deleteWebhook.mockRejectedValueOnce(new Error('Service unavailable'))
+ await expect(cleanup(payload, context)).rejects.toThrow('Service unavailable')
+ expect(mocks.deleteInbox).not.toHaveBeenCalled()
+ })
+
+ it('checkpoints accepted deletion and polls on retry instead of deleting again', async () => {
+ mocks.deleteInbox.mockResolvedValueOnce(false)
+ await expect(cleanup(payload, context)).resolves.toMatchObject({ outcome: 'deferred' })
+ expect(context.checkpointPayload).toHaveBeenCalledWith({ inboxDeleteAccepted: true })
+ await expect(
+ cleanup({ ...payload, inboxDeleteAccepted: true }, context)
+ ).resolves.toMatchObject({ outcome: 'deferred' })
+ expect(mocks.deleteInbox).toHaveBeenCalledTimes(1)
+ mocks.getInbox.mockResolvedValueOnce(null)
+ await expect(
+ cleanup({ ...payload, inboxDeleteAccepted: true }, context)
+ ).resolves.toBeUndefined()
+ })
+
+ it('does not delete a new inbox that reused the retired address', async () => {
+ mocks.getInbox.mockResolvedValueOnce({
+ inbox_id: payload.inboxId,
+ created_at: '2025-02-01T00:00:00Z',
+ })
+ await expect(cleanup(payload, context)).resolves.toBeUndefined()
+ expect(mocks.deleteInbox).not.toHaveBeenCalled()
+ })
+
+ it('completes already-absent resources without issuing another inbox delete', async () => {
+ mocks.getInbox.mockResolvedValueOnce(null)
+ await expect(cleanup(payload, context)).resolves.toBeUndefined()
+ expect(mocks.deleteInbox).not.toHaveBeenCalled()
+ })
+
+ it('does not lose asynchronous webhook deletion', async () => {
+ mocks.deleteWebhook.mockResolvedValueOnce(false)
+ await expect(cleanup(payload, context)).resolves.toMatchObject({ outcome: 'deferred' })
+ expect(mocks.deleteInbox).not.toHaveBeenCalled()
+ })
+
+ it('honors cancellation before provider side effects', async () => {
+ context.signal = AbortSignal.abort()
+ await expect(cleanup(payload, context)).rejects.toThrow()
+ expect(mocks.deleteWebhook).not.toHaveBeenCalled()
+ })
+
+ it('requires a creation timestamp before deleting an address', async () => {
+ await expect(cleanup({ ...payload, inboxCreatedAt: null }, context)).rejects.toThrow()
+ expect(mocks.deleteWebhook).not.toHaveBeenCalled()
+ expect(mocks.deleteInbox).not.toHaveBeenCalled()
+ })
+
+ it('records that cleanup started before deleting resources so a retry cannot be activated', async () => {
+ await cleanup(payload, context)
+ expect(context.checkpointPayload).toHaveBeenCalledWith({ cleanupStarted: true })
+ expect(vi.mocked(context.checkpointPayload).mock.invocationCallOrder[0]).toBeLessThan(
+ mocks.deleteWebhook.mock.invocationCallOrder[0]
+ )
+ })
+
+ it('does not delete anything if the cleanup lease cannot checkpoint its start', async () => {
+ vi.mocked(context.checkpointPayload).mockRejectedValueOnce(new Error('Lease lost'))
+ await expect(cleanup(payload, context)).rejects.toThrow('Lease lost')
+ expect(mocks.deleteWebhook).not.toHaveBeenCalled()
+ expect(mocks.deleteInbox).not.toHaveBeenCalled()
+ })
+
+ it('refuses activation when rollback is no longer cancelable', async () => {
+ dbChainMockFns.returning.mockResolvedValueOnce([])
+ await expect(cancelInboxCleanup(db, 'event-1')).rejects.toThrow('Inbox setup expired')
+ })
+
+ it.each(['webhook', 'inbox'])(
+ 'allows more than ten expected %s polls without spending failure attempts',
+ async (resource) => {
+ const pending: InboxCleanupPayload = { ...payload }
+ vi.mocked(context.checkpointPayload).mockImplementation(async (patch) => {
+ Object.assign(pending, patch)
+ })
+ if (resource === 'webhook') mocks.deleteWebhook.mockResolvedValue(false)
+ else mocks.deleteInbox.mockResolvedValue(false)
+
+ for (let poll = 0; poll < 11; poll++) {
+ await expect(cleanup(pending, context)).resolves.toMatchObject({
+ outcome: 'deferred',
+ consumeAttempt: false,
+ minimumBackoffMs: 30_000,
+ })
+ }
+ expect(pending.deletionPollsRemaining).toBe(109)
+ mocks.deleteWebhook.mockResolvedValue(true)
+ mocks.getInbox.mockResolvedValueOnce(null)
+ await expect(cleanup(pending, context)).resolves.toBeUndefined()
+ }
+ )
+
+ it.each(['webhook', 'inbox'])(
+ 'bounds perpetual %s polling and returns to the failure budget',
+ async (resource) => {
+ const pending: InboxCleanupPayload = { ...payload, deletionPollsRemaining: 1 }
+ vi.mocked(context.checkpointPayload).mockImplementation(async (patch) => {
+ Object.assign(pending, patch)
+ })
+ if (resource === 'webhook') mocks.deleteWebhook.mockResolvedValue(false)
+ else mocks.deleteInbox.mockResolvedValue(false)
+
+ await expect(cleanup(pending, context)).resolves.toMatchObject({ consumeAttempt: false })
+ expect(pending.deletionPollsRemaining).toBe(0)
+ const exhausted = await cleanup(pending, context)
+ expect(exhausted).toMatchObject({
+ outcome: 'deferred',
+ reason: expect.stringContaining('exhausted'),
+ })
+ expect(exhausted).not.toHaveProperty('consumeAttempt', false)
+ expect(pending.deletionPollsRemaining).toBe(0)
+ }
+ )
+
+ it('continues charging actual provider failures to the outbox error budget', async () => {
+ mocks.deleteWebhook.mockRejectedValueOnce(new Error('Connection failed'))
+ await expect(cleanup({ ...payload, deletionPollsRemaining: 100 }, context)).rejects.toThrow(
+ 'Connection failed'
+ )
+ expect(context.checkpointPayload).not.toHaveBeenCalledWith(
+ expect.objectContaining({ deletionPollsRemaining: expect.any(Number) })
+ )
+ })
+})
+
+describe('immediate rollback processing', () => {
+ afterEach(() => vi.restoreAllMocks())
+
+ it('makes failed activation rollback due before asking the outbox to claim it', async () => {
+ const process = vi.spyOn(outboxService, 'processOutboxEventById').mockResolvedValue('completed')
+ const before = Date.now()
+ await processInboxCleanupNow('rollback-event', { expedite: true })
+ expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.outboxEvent)
+ const scheduled = dbChainMockFns.set.mock.calls[0][0].availableAt
+ expect(scheduled.getTime()).toBeGreaterThanOrEqual(before)
+ expect(scheduled.getTime()).toBeLessThanOrEqual(Date.now())
+ expect(process).toHaveBeenCalledWith('rollback-event', inboxCleanupOutboxHandlers)
+ expect(dbChainMockFns.set.mock.invocationCallOrder[0]).toBeLessThan(
+ process.mock.invocationCallOrder[0]
+ )
+ })
+
+ it('leaves the durable fallback intact if the database cannot expedite rollback', async () => {
+ const process = vi.spyOn(outboxService, 'processOutboxEventById').mockResolvedValue('completed')
+ dbChainMockFns.update.mockImplementationOnce(() => {
+ throw new Error('Database unavailable')
+ })
+ await expect(
+ processInboxCleanupNow('rollback-event', { expedite: true })
+ ).resolves.toBeUndefined()
+ expect(process).not.toHaveBeenCalled()
+ expect(dbChainMockFns.delete).not.toHaveBeenCalled()
+ })
+
+ it('does not change availability for ordinary cleanup or retries', async () => {
+ const process = vi.spyOn(outboxService, 'processOutboxEventById').mockResolvedValue('pending')
+ await processInboxCleanupNow('cleanup-event')
+ expect(dbChainMockFns.update).not.toHaveBeenCalled()
+ expect(process).toHaveBeenCalledWith('cleanup-event', inboxCleanupOutboxHandlers)
+ })
+})
diff --git a/apps/sim/lib/mothership/inbox/cleanup-outbox.ts b/apps/sim/lib/mothership/inbox/cleanup-outbox.ts
new file mode 100644
index 00000000000..0c37df5315c
--- /dev/null
+++ b/apps/sim/lib/mothership/inbox/cleanup-outbox.ts
@@ -0,0 +1,146 @@
+import { db } from '@sim/db'
+import { mothershipInboxWebhook, outboxEvent, workspace } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { and, eq, gt, sql } from 'drizzle-orm'
+import { z } from 'zod'
+import {
+ deferOutboxHandler,
+ enqueueOutboxEvent,
+ type OutboxEventContext,
+ type OutboxHandler,
+ type OutboxHandlerRegistry,
+ processOutboxEventById,
+} from '@/lib/core/outbox/service'
+import * as agentmail from '@/lib/mothership/inbox/agentmail-client'
+
+const logger = createLogger('InboxCleanup')
+const INBOX_CLEANUP_EVENT = 'inbox.resources.cleanup'
+const MAX_DELETION_POLLS = 120
+const DELETION_POLL_INTERVAL_MS = 30_000
+
+const cleanupPayloadSchema = z
+ .object({
+ inboxId: z.string().min(1).max(320).nullable(),
+ inboxCreatedAt: z.string().datetime({ offset: true }).nullable(),
+ webhookId: z.string().min(1).max(256).nullable(),
+ inboxDeleteAccepted: z.boolean().optional(),
+ cleanupStarted: z.boolean().optional(),
+ deletionPollsRemaining: z.number().int().min(0).max(MAX_DELETION_POLLS).optional(),
+ })
+ .refine((payload) => !payload.inboxId || payload.inboxCreatedAt !== null)
+
+export type InboxCleanupPayload = z.infer
+
+/** Expected provider waits have a separate finite allowance from failures such as network errors. */
+async function waitForDeletion(
+ payload: InboxCleanupPayload,
+ context: OutboxEventContext,
+ reason: string
+) {
+ const remaining = payload.deletionPollsRemaining ?? MAX_DELETION_POLLS
+ if (remaining === 0) {
+ return deferOutboxHandler(`${reason}: polling allowance exhausted`, DELETION_POLL_INTERVAL_MS)
+ }
+ await context.checkpointPayload({ deletionPollsRemaining: remaining - 1 })
+ return deferOutboxHandler(reason, DELETION_POLL_INTERVAL_MS, false)
+}
+
+const cleanupInboxResources: OutboxHandler = async (rawPayload, context) => {
+ const payload = cleanupPayloadSchema.parse(rawPayload)
+ context.signal.throwIfAborted()
+ /** Retries and recovered leases become pending again, but must never be activated afterward. */
+ if (!payload.cleanupStarted) await context.checkpointPayload({ cleanupStarted: true })
+
+ if (payload.webhookId) {
+ const [active] = await db
+ .select({ id: mothershipInboxWebhook.id })
+ .from(mothershipInboxWebhook)
+ .where(eq(mothershipInboxWebhook.webhookId, payload.webhookId))
+ .limit(1)
+ if (active) throw new Error('Inbox webhook is still in use')
+ if (!(await agentmail.deleteWebhook(payload.webhookId, context.signal))) {
+ return waitForDeletion(payload, context, 'Waiting for webhook deletion')
+ }
+ }
+
+ if (!payload.inboxId) return
+ context.signal.throwIfAborted()
+ const [active] = await db
+ .select({ id: workspace.id })
+ .from(workspace)
+ .where(eq(workspace.inboxProviderId, payload.inboxId))
+ .limit(1)
+ if (active) throw new Error('Inbox is still in use')
+
+ const inbox = await agentmail.getInbox(payload.inboxId, context.signal)
+ /** Email addresses can be reused; a retry must never delete a replacement inbox. */
+ if (!inbox || inbox.created_at !== payload.inboxCreatedAt) return
+ if (!payload.inboxDeleteAccepted) {
+ context.signal.throwIfAborted()
+ if (await agentmail.deleteInbox(payload.inboxId, context.signal)) return
+ await context.checkpointPayload({ inboxDeleteAccepted: true })
+ }
+ return waitForDeletion(payload, context, 'Waiting for inbox deletion')
+}
+
+export const inboxCleanupOutboxHandlers = {
+ [INBOX_CLEANUP_EVENT]: cleanupInboxResources,
+} satisfies OutboxHandlerRegistry
+
+/** Retains resource identities in the same transaction that removes their active configuration. */
+export function enqueueInboxCleanup(
+ executor: Pick,
+ payload: InboxCleanupPayload,
+ availableAt?: Date
+): Promise {
+ return enqueueOutboxEvent(executor, INBOX_CLEANUP_EVENT, cleanupPayloadSchema.parse(payload), {
+ availableAt,
+ })
+}
+
+/** Attempts committed cleanup immediately, leaving failures to the durable retry worker. */
+export async function processInboxCleanupNow(
+ eventId: string,
+ options: { expedite?: boolean } = {}
+): Promise {
+ try {
+ if (options.expedite) {
+ const now = new Date()
+ /** Only release the activation grace delay; preserve claimed work and retry backoff. */
+ await db
+ .update(outboxEvent)
+ .set({ availableAt: now })
+ .where(
+ and(
+ eq(outboxEvent.id, eventId),
+ eq(outboxEvent.eventType, INBOX_CLEANUP_EVENT),
+ eq(outboxEvent.status, 'pending'),
+ gt(outboxEvent.availableAt, now),
+ sql`${outboxEvent.payload}->>'cleanupStarted' IS DISTINCT FROM 'true'`
+ )
+ )
+ }
+ await processOutboxEventById(eventId, inboxCleanupOutboxHandlers)
+ } catch (error) {
+ logger.warn('Inbox cleanup remains queued', { eventId, error })
+ }
+}
+
+/** Cancels unclaimed rollback only inside the transaction that activates its resources. */
+export async function cancelInboxCleanup(
+ executor: Pick,
+ eventId: string
+): Promise {
+ const [canceled] = await executor
+ .delete(outboxEvent)
+ .where(
+ and(
+ eq(outboxEvent.id, eventId),
+ eq(outboxEvent.eventType, INBOX_CLEANUP_EVENT),
+ eq(outboxEvent.status, 'pending'),
+ sql`${outboxEvent.payload}->>'cleanupStarted' IS DISTINCT FROM 'true'`
+ )
+ )
+ .returning({ id: outboxEvent.id })
+ if (!canceled) throw new Error('Inbox setup expired. Please try again.')
+}
diff --git a/apps/sim/lib/mothership/inbox/lifecycle.test.ts b/apps/sim/lib/mothership/inbox/lifecycle.test.ts
new file mode 100644
index 00000000000..8b055fb33c3
--- /dev/null
+++ b/apps/sim/lib/mothership/inbox/lifecycle.test.ts
@@ -0,0 +1,230 @@
+/**
+ * @vitest-environment node
+ */
+import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ createInbox: vi.fn(),
+ createWebhook: vi.fn(),
+ getInbox: vi.fn(),
+ enqueue: vi.fn(),
+ process: vi.fn(),
+ cancel: vi.fn(),
+ deleteInbox: vi.fn(),
+ deleteWebhook: vi.fn(),
+}))
+vi.mock('@/lib/mothership/inbox/agentmail-client', () => mocks)
+vi.mock('@/lib/mothership/inbox/cleanup-outbox', () => ({
+ cancelInboxCleanup: mocks.cancel,
+ enqueueInboxCleanup: mocks.enqueue,
+ processInboxCleanupNow: mocks.process,
+}))
+vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://example.com' }))
+
+import { disableInbox, enableInbox, updateInboxAddress } from '@/lib/mothership/inbox/lifecycle'
+
+const oldState = {
+ enabled: true,
+ address: 'old@example.com',
+ providerId: 'old@example.com',
+ webhookId: 'old-hook',
+}
+const emptyState = { enabled: false, address: null, providerId: null, webhookId: null }
+const createdAt = '2025-01-01T00:00:00Z'
+const newInbox = { inbox_id: 'new@example.com', created_at: createdAt }
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ mocks.createInbox.mockResolvedValue(newInbox)
+ mocks.createWebhook.mockResolvedValue({ webhook_id: 'new-hook', secret: 'test-secret' })
+ mocks.getInbox.mockResolvedValue({ inbox_id: oldState.providerId, created_at: createdAt })
+ mocks.enqueue.mockResolvedValue('cleanup-event')
+ mocks.process.mockResolvedValue(undefined)
+ mocks.cancel.mockResolvedValue(undefined)
+ mocks.deleteInbox.mockResolvedValue(true)
+ mocks.deleteWebhook.mockResolvedValue(true)
+})
+
+function queueState(state = oldState) {
+ queueTableRows(schemaMock.workspace, [state])
+}
+function queueLockedState(state = oldState) {
+ queueTableRows(schemaMock.workspace, [{ id: 'workspace-1' }])
+ queueState(state)
+}
+
+describe('inbox lifecycle failure safety', () => {
+ it('keeps the existing inbox when a replacement address is unavailable', async () => {
+ queueState()
+ mocks.createInbox.mockRejectedValueOnce(new Error('Address unavailable'))
+ await expect(updateInboxAddress('workspace-1', 'new')).rejects.toThrow('Address unavailable')
+ expect(dbChainMockFns.update).not.toHaveBeenCalled()
+ expect(dbChainMockFns.delete).not.toHaveBeenCalled()
+ expect(mocks.enqueue).not.toHaveBeenCalled()
+ })
+
+ it('retains the old configuration and cleans up only the new inbox when webhook creation fails', async () => {
+ queueState()
+ mocks.createWebhook.mockRejectedValueOnce(new Error('Webhook limit'))
+ await expect(updateInboxAddress('workspace-1', 'new')).rejects.toThrow('Webhook limit')
+ expect(dbChainMockFns.delete).not.toHaveBeenCalled()
+ expect(dbChainMockFns.update).not.toHaveBeenCalled()
+ expect(mocks.enqueue).toHaveBeenCalledWith(expect.anything(), {
+ inboxId: newInbox.inbox_id,
+ inboxCreatedAt: createdAt,
+ webhookId: null,
+ })
+ })
+
+ it('commits the replacement and cleanup identities together before deleting old resources', async () => {
+ queueState()
+ queueLockedState()
+ await expect(updateInboxAddress('workspace-1', 'new')).resolves.toMatchObject({
+ address: newInbox.inbox_id,
+ })
+ expect(mocks.enqueue).toHaveBeenCalledWith(expect.anything(), {
+ inboxId: oldState.providerId,
+ inboxCreatedAt: createdAt,
+ webhookId: 'old-hook',
+ })
+ expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
+ expect(dbChainMockFns.for).toHaveBeenCalledWith('update')
+ expect(dbChainMockFns.set).toHaveBeenCalledWith(
+ expect.objectContaining({ inboxEnabled: true, inboxProviderId: newInbox.inbox_id })
+ )
+ expect(mocks.process.mock.invocationCallOrder[0]).toBeGreaterThan(
+ dbChainMockFns.set.mock.invocationCallOrder[0]
+ )
+ })
+
+ it('does not overwrite a concurrent change and rolls back only its own resources', async () => {
+ queueState()
+ queueLockedState({ ...oldState, webhookId: 'concurrent-hook' })
+ await expect(updateInboxAddress('workspace-1', 'new')).rejects.toThrow('Inbox settings changed')
+ expect(dbChainMockFns.delete).not.toHaveBeenCalled()
+ expect(dbChainMockFns.update).not.toHaveBeenCalled()
+ expect(mocks.enqueue).toHaveBeenCalledWith(
+ expect.anything(),
+ {
+ inboxId: newInbox.inbox_id,
+ inboxCreatedAt: createdAt,
+ webhookId: 'new-hook',
+ },
+ expect.any(Date)
+ )
+ })
+
+ it('does not clear configuration when it cannot durably enqueue cleanup', async () => {
+ queueState()
+ queueLockedState()
+ mocks.enqueue.mockRejectedValueOnce(new Error('Database unavailable'))
+ await expect(disableInbox('workspace-1')).rejects.toThrow('Database unavailable')
+ expect(dbChainMockFns.delete).not.toHaveBeenCalled()
+ expect(dbChainMockFns.update).not.toHaveBeenCalled()
+ expect(mocks.process).not.toHaveBeenCalled()
+ })
+
+ it('retains configuration when provider identity cannot be read', async () => {
+ queueState()
+ mocks.getInbox.mockRejectedValueOnce(new Error('Service unavailable'))
+ await expect(disableInbox('workspace-1')).rejects.toThrow('Service unavailable')
+ expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
+ })
+
+ it('disables processing while retaining the exact resource identities for retry', async () => {
+ queueState()
+ queueLockedState()
+ await disableInbox('workspace-1')
+ expect(mocks.enqueue).toHaveBeenCalledWith(expect.anything(), {
+ inboxId: oldState.providerId,
+ inboxCreatedAt: createdAt,
+ webhookId: 'old-hook',
+ })
+ expect(dbChainMockFns.set).toHaveBeenCalledWith(
+ expect.objectContaining({ inboxEnabled: false, inboxProviderId: null })
+ )
+ expect(mocks.process).toHaveBeenCalledWith('cleanup-event')
+ })
+
+ it('cleans up an already-absent inbox webhook', async () => {
+ queueState()
+ queueLockedState()
+ mocks.getInbox.mockResolvedValueOnce(null)
+ await disableInbox('workspace-1')
+ expect(mocks.enqueue).toHaveBeenCalledWith(expect.anything(), {
+ inboxId: null,
+ inboxCreatedAt: null,
+ webhookId: 'old-hook',
+ })
+ })
+
+ it('refuses duplicate enable before provisioning', async () => {
+ queueState()
+ await expect(enableInbox('workspace-1')).rejects.toThrow('already configured')
+ expect(mocks.createInbox).not.toHaveBeenCalled()
+ })
+
+ it('does not let a losing concurrent enable delete the winning configuration', async () => {
+ queueTableRows(schemaMock.workspace, [emptyState])
+ queueLockedState()
+ await expect(enableInbox('workspace-1')).rejects.toThrow('Inbox settings changed')
+ expect(dbChainMockFns.delete).not.toHaveBeenCalled()
+ expect(mocks.enqueue).toHaveBeenCalledWith(
+ expect.anything(),
+ {
+ inboxId: newInbox.inbox_id,
+ inboxCreatedAt: createdAt,
+ webhookId: 'new-hook',
+ },
+ expect.any(Date)
+ )
+ })
+
+ it('rolls back new resources after a database commit failure', async () => {
+ queueTableRows(schemaMock.workspace, [emptyState])
+ dbChainMockFns.transaction.mockRejectedValueOnce(new Error('Commit failed'))
+ await expect(enableInbox('workspace-1')).rejects.toThrow('Commit failed')
+ expect(mocks.enqueue).toHaveBeenCalledWith(
+ expect.anything(),
+ {
+ inboxId: newInbox.inbox_id,
+ inboxCreatedAt: createdAt,
+ webhookId: 'new-hook',
+ },
+ expect.any(Date)
+ )
+ expect(mocks.process).toHaveBeenCalledWith('cleanup-event', { expedite: true })
+ })
+
+ it('directly rolls back uninstalled resources if rollback cannot be recorded', async () => {
+ queueTableRows(schemaMock.workspace, [emptyState])
+ mocks.enqueue.mockRejectedValueOnce(new Error('Database unavailable'))
+ await expect(enableInbox('workspace-1')).rejects.toThrow('Database unavailable')
+ expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
+ expect(mocks.deleteWebhook).toHaveBeenCalledWith('new-hook')
+ expect(mocks.deleteInbox).toHaveBeenCalledWith(newInbox.inbox_id)
+ })
+
+ it('refuses activation once a cleanup worker owns its resources', async () => {
+ queueTableRows(schemaMock.workspace, [emptyState])
+ queueTableRows(schemaMock.workspace, [{ id: 'workspace-1' }])
+ queueTableRows(schemaMock.workspace, [emptyState])
+ mocks.cancel.mockRejectedValueOnce(new Error('Inbox setup expired'))
+ await expect(enableInbox('workspace-1')).rejects.toThrow('Inbox setup expired')
+ expect(dbChainMockFns.set).not.toHaveBeenCalled()
+ expect(mocks.deleteInbox).not.toHaveBeenCalled()
+ })
+
+ it('rolls back a created inbox when its response is missing the creation timestamp', async () => {
+ queueTableRows(schemaMock.workspace, [emptyState])
+ mocks.createInbox.mockResolvedValueOnce({ inbox_id: newInbox.inbox_id })
+ await expect(enableInbox('workspace-1')).rejects.toThrow(
+ 'Email service returned an invalid inbox'
+ )
+ expect(mocks.deleteInbox).toHaveBeenCalledWith(newInbox.inbox_id)
+ expect(mocks.createWebhook).not.toHaveBeenCalled()
+ expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/lib/mothership/inbox/lifecycle.ts b/apps/sim/lib/mothership/inbox/lifecycle.ts
index d92c916ec38..cb0f4a5867f 100644
--- a/apps/sim/lib/mothership/inbox/lifecycle.ts
+++ b/apps/sim/lib/mothership/inbox/lifecycle.ts
@@ -1,131 +1,198 @@
-import { db, mothershipInboxWebhook, workspace } from '@sim/db'
+import { db } from '@sim/db'
+import { mothershipInboxWebhook, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { getBaseUrl } from '@/lib/core/utils/urls'
import * as agentmail from '@/lib/mothership/inbox/agentmail-client'
-import type { InboxConfig } from '@/lib/mothership/inbox/types'
+import {
+ cancelInboxCleanup,
+ enqueueInboxCleanup,
+ type InboxCleanupPayload,
+ processInboxCleanupNow,
+} from '@/lib/mothership/inbox/cleanup-outbox'
+import type { AgentMailInbox, AgentMailWebhook, InboxConfig } from '@/lib/mothership/inbox/types'
const logger = createLogger('InboxLifecycle')
+type InboxExecutor = Pick
-/**
- * Enable inbox for a workspace:
- * 1. Create AgentMail inbox (with optional custom username)
- * 2. Create AgentMail webhook scoped to this inbox
- * 3. Store inbox details + webhook secret in DB
- * 4. Update workspace.inboxEnabled = true
- */
-export async function enableInbox(
- workspaceId: string,
- opts?: { username?: string }
-): Promise {
- const inbox = await agentmail.createInbox({
- username: opts?.username,
- displayName: 'Sim',
- })
+async function loadInbox(executor: InboxExecutor, workspaceId: string, lock = false) {
+ if (lock) {
+ await executor
+ .select({ id: workspace.id })
+ .from(workspace)
+ .where(eq(workspace.id, workspaceId))
+ .limit(1)
+ .for('update')
+ }
+ const query = executor
+ .select({
+ enabled: workspace.inboxEnabled,
+ address: workspace.inboxAddress,
+ providerId: workspace.inboxProviderId,
+ webhookId: mothershipInboxWebhook.webhookId,
+ })
+ .from(workspace)
+ .leftJoin(mothershipInboxWebhook, eq(mothershipInboxWebhook.workspaceId, workspace.id))
+ .where(eq(workspace.id, workspaceId))
+ .limit(1)
+ const [current] = await query
+ if (!current) throw new Error('Workspace not found')
+ return current
+}
- logger.info('AgentMail createInbox response', { inbox: JSON.stringify(inbox) })
+type InboxState = Awaited>
- if (!inbox?.inbox_id) {
- throw new Error('AgentMail createInbox response missing inbox_id')
+function assertUnchanged(current: InboxState, expected: InboxState): void {
+ if (
+ current.enabled !== expected.enabled ||
+ current.providerId !== expected.providerId ||
+ current.webhookId !== expected.webhookId
+ ) {
+ throw new Error('Inbox settings changed. Refresh and try again.')
+ }
+}
+
+async function captureCleanup(current: InboxState): Promise {
+ const inbox = current.providerId ? await agentmail.getInbox(current.providerId) : null
+ return {
+ inboxId: inbox?.inbox_id ?? null,
+ inboxCreatedAt: inbox?.created_at ?? null,
+ webhookId: current.webhookId,
+ }
+}
+
+/** Only used before activation can have committed, so direct rollback cannot delete a live inbox. */
+async function rollbackUninstalledResources(
+ inbox: AgentMailInbox,
+ webhook: AgentMailWebhook | null
+) {
+ const results = await Promise.allSettled([
+ ...(webhook ? [agentmail.deleteWebhook(webhook.webhook_id)] : []),
+ agentmail.deleteInbox(inbox.inbox_id),
+ ])
+ if (results.some((result) => result.status === 'rejected' || !result.value)) {
+ logger.error('Inbox provisioning rollback needs reconciliation', {
+ inboxId: inbox.inbox_id,
+ webhookId: webhook?.webhook_id,
+ })
}
+}
+
+async function rollbackProvisioning(inbox: AgentMailInbox, webhook: AgentMailWebhook | null) {
+ try {
+ const eventId = await enqueueInboxCleanup(db, {
+ inboxId: inbox.inbox_id,
+ inboxCreatedAt: inbox.created_at,
+ webhookId: webhook?.webhook_id ?? null,
+ })
+ await processInboxCleanupNow(eventId)
+ } catch (error) {
+ await rollbackUninstalledResources(inbox, webhook)
+ logger.error('Failed to queue inbox provisioning rollback', {
+ inboxId: inbox.inbox_id,
+ webhookId: webhook?.webhook_id,
+ error,
+ })
+ }
+}
- let webhook: Awaited> | null = null
+async function provisionInbox(username?: string) {
+ const inbox = await agentmail.createInbox({ username, displayName: 'Sim' })
+ if (!inbox?.inbox_id) {
+ throw new Error('Email service returned an invalid inbox')
+ }
+ if (!inbox.created_at) {
+ await rollbackUninstalledResources(inbox, null)
+ throw new Error('Email service returned an invalid inbox')
+ }
try {
- /**
- * The receiver routes a delivery to this workspace by the `message.inbox_id`
- * in the envelope, so it can only accept event types that carry a message.
- * Adding one that does not would make those deliveries unroutable.
- */
- webhook = await agentmail.createWebhook({
+ const webhook = await agentmail.createWebhook({
url: `${getBaseUrl()}/api/webhooks/agentmail`,
eventTypes: ['message.received'],
inboxIds: [inbox.inbox_id],
})
-
- await db.insert(mothershipInboxWebhook).values({
- id: generateId(),
- workspaceId,
- webhookId: webhook.webhook_id,
- secret: webhook.secret,
- })
-
- await db
- .update(workspace)
- .set({
- inboxEnabled: true,
- inboxAddress: inbox.inbox_id,
- inboxProviderId: inbox.inbox_id,
- updatedAt: new Date(),
- })
- .where(eq(workspace.id, workspaceId))
-
- logger.info('Inbox enabled', { workspaceId, address: inbox.inbox_id })
-
- return {
- enabled: true,
- address: inbox.inbox_id,
- providerId: inbox.inbox_id,
- }
+ return { inbox, webhook }
} catch (error) {
- try {
- if (webhook) await agentmail.deleteWebhook(webhook.webhook_id)
- await agentmail.deleteInbox(inbox.inbox_id)
- await db
- .delete(mothershipInboxWebhook)
- .where(eq(mothershipInboxWebhook.workspaceId, workspaceId))
- } catch (rollbackError) {
- logger.error('Failed to rollback AgentMail resources', { rollbackError })
- }
+ await rollbackProvisioning(inbox, null)
throw error
}
}
-/**
- * Disable inbox:
- * 1. Delete AgentMail webhook
- * 2. Delete AgentMail inbox
- * 3. Clear workspace inbox columns
- * 4. Delete mothershipInboxWebhook row
- */
-export async function disableInbox(workspaceId: string): Promise {
- const [[ws], [webhookRow]] = await Promise.all([
- db
- .select({ inboxProviderId: workspace.inboxProviderId })
- .from(workspace)
- .where(eq(workspace.id, workspaceId))
- .limit(1),
- db
- .select({ webhookId: mothershipInboxWebhook.webhookId })
- .from(mothershipInboxWebhook)
- .where(eq(mothershipInboxWebhook.workspaceId, workspaceId))
- .limit(1),
- ])
-
- const deletePromises: Promise[] = []
- if (webhookRow) {
- deletePromises.push(
- agentmail.deleteWebhook(webhookRow.webhookId).catch((error) => {
- logger.warn('Failed to delete AgentMail webhook', { error })
- })
+async function installInbox(
+ workspaceId: string,
+ expected: InboxState,
+ username?: string,
+ cleanup?: InboxCleanupPayload
+): Promise {
+ const { inbox, webhook } = await provisionInbox(username)
+ let rollbackEventId: string
+ try {
+ rollbackEventId = await enqueueInboxCleanup(
+ db,
+ {
+ inboxId: inbox.inbox_id,
+ inboxCreatedAt: inbox.created_at,
+ webhookId: webhook.webhook_id,
+ },
+ new Date(Date.now() + 5 * 60_000)
)
+ } catch (error) {
+ await rollbackUninstalledResources(inbox, webhook)
+ throw error
}
- if (ws?.inboxProviderId) {
- deletePromises.push(
- agentmail.deleteInbox(ws.inboxProviderId).catch((error) => {
- logger.warn('Failed to delete AgentMail inbox', { error })
+ let cleanupEventId: string | undefined
+ try {
+ await db.transaction(async (tx) => {
+ assertUnchanged(await loadInbox(tx, workspaceId, true), expected)
+ await cancelInboxCleanup(tx, rollbackEventId)
+ if (cleanup) cleanupEventId = await enqueueInboxCleanup(tx, cleanup)
+ await tx
+ .delete(mothershipInboxWebhook)
+ .where(eq(mothershipInboxWebhook.workspaceId, workspaceId))
+ await tx.insert(mothershipInboxWebhook).values({
+ id: generateId(),
+ workspaceId,
+ webhookId: webhook.webhook_id,
+ secret: webhook.secret,
})
- )
+ await tx
+ .update(workspace)
+ .set({
+ inboxEnabled: true,
+ inboxAddress: inbox.inbox_id,
+ inboxProviderId: inbox.inbox_id,
+ updatedAt: new Date(),
+ })
+ .where(eq(workspace.id, workspaceId))
+ })
+ } catch (error) {
+ await processInboxCleanupNow(rollbackEventId, { expedite: true })
+ throw error
}
- await Promise.all(deletePromises)
+ if (cleanupEventId) await processInboxCleanupNow(cleanupEventId)
+ return { enabled: true, address: inbox.inbox_id, providerId: inbox.inbox_id }
+}
- /**
- * Atomic so the two rows cannot disagree. `workspace.inboxProviderId` is
- * uniquely indexed, so a half-applied disable would strand the id of an
- * AgentMail inbox that no longer exists — and the next workspace to claim that
- * same address would then fail to enable at all.
- */
- await db.transaction(async (tx) => {
+/** Provisions resources before atomically activating their inbox and webhook configuration. */
+export async function enableInbox(
+ workspaceId: string,
+ opts?: { username?: string }
+): Promise {
+ const current = await loadInbox(db, workspaceId)
+ if (current.enabled || current.providerId || current.webhookId) {
+ throw new Error('Inbox is already configured. Refresh and try again.')
+ }
+ return installInbox(workspaceId, current, opts?.username)
+}
+
+/** Stops mail processing atomically and retains provider cleanup in the outbox until it succeeds. */
+export async function disableInbox(workspaceId: string): Promise {
+ const current = await loadInbox(db, workspaceId)
+ const cleanup = await captureCleanup(current)
+ const eventId = await db.transaction(async (tx) => {
+ assertUnchanged(await loadInbox(tx, workspaceId, true), current)
+ const eventId = await enqueueInboxCleanup(tx, cleanup)
await tx
.delete(mothershipInboxWebhook)
.where(eq(mothershipInboxWebhook.workspaceId, workspaceId))
@@ -138,20 +205,17 @@ export async function disableInbox(workspaceId: string): Promise {
updatedAt: new Date(),
})
.where(eq(workspace.id, workspaceId))
+ return eventId
})
-
- logger.info('Inbox disabled', { workspaceId })
+ await processInboxCleanupNow(eventId)
}
-/**
- * Update inbox address (regenerate):
- * 1. Disable old inbox
- * 2. Enable new inbox with new username
- */
+/** Keeps the existing inbox intact until its replacement is provisioned and committed. */
export async function updateInboxAddress(
workspaceId: string,
newUsername: string
): Promise {
- await disableInbox(workspaceId)
- return enableInbox(workspaceId, { username: newUsername })
+ const current = await loadInbox(db, workspaceId)
+ const cleanup = await captureCleanup(current)
+ return installInbox(workspaceId, current, newUsername, cleanup)
}