Skip to content

Commit bfb7ff3

Browse files
fix(mailer): enforce Max access and preserve inbox resources on failure (#7950)
* fix(mailer): enforce Max access and preserve inbox resources on failure * fix(mailer): bound deletion polling separately from failure retries * fix(mailer): expedite rollback after activation failure
1 parent af73d0c commit bfb7ff3

14 files changed

Lines changed: 1346 additions & 176 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, type ReactNode } from 'react'
5+
import { QueryClient, QueryClientProvider, useMutation } from '@tanstack/react-query'
6+
import { createRoot, type Root } from 'react-dom/client'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
const mocks = vi.hoisted(() => ({ enabled: false, toggle: vi.fn() }))
10+
vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }) }))
11+
vi.mock('@/hooks/queries/inbox', () => ({
12+
useInboxConfig: () => ({ data: { enabled: mocks.enabled, address: 'inbox@example.com' } }),
13+
useToggleInbox: () => useMutation({ mutationFn: mocks.toggle }),
14+
}))
15+
vi.mock('@sim/emcn', () => ({
16+
Label: ({ children }: { children: ReactNode }) => <span>{children}</span>,
17+
ChipSwitch: ({ onChange }: { onChange: (value: string) => void }) => (
18+
<>
19+
<button type='button' onClick={() => onChange('enabled')}>
20+
On
21+
</button>
22+
<button type='button' onClick={() => onChange('disabled')}>
23+
Off
24+
</button>
25+
</>
26+
),
27+
ChipModal: ({ open, children }: { open: boolean; children: ReactNode }) =>
28+
open ? <section role='dialog'>{children}</section> : null,
29+
ChipModalHeader: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
30+
ChipModalBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
31+
ChipModalField: () => null,
32+
ChipModalError: ({ children }: { children: ReactNode }) =>
33+
children ? <p role='alert'>{children}</p> : null,
34+
ChipModalFooter: ({
35+
onCancel,
36+
primaryAction,
37+
}: {
38+
onCancel: () => void
39+
primaryAction: { label: string; onClick: () => void }
40+
}) => (
41+
<>
42+
<button type='button' onClick={onCancel}>
43+
Cancel
44+
</button>
45+
<button type='button' onClick={primaryAction.onClick}>
46+
{primaryAction.label}
47+
</button>
48+
</>
49+
),
50+
ChipConfirmModal: ({
51+
open,
52+
children,
53+
onOpenChange,
54+
confirm,
55+
}: {
56+
open: boolean
57+
children: ReactNode
58+
onOpenChange: (open: boolean) => void
59+
confirm: { label: string; onClick: () => void }
60+
}) =>
61+
open ? (
62+
<section role='dialog'>
63+
{children}
64+
<button type='button' onClick={() => onOpenChange(false)}>
65+
Cancel
66+
</button>
67+
<button type='button' onClick={confirm.onClick}>
68+
{confirm.label}
69+
</button>
70+
</section>
71+
) : null,
72+
}))
73+
74+
import { InboxEnableToggle } from '@/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle'
75+
76+
let container: HTMLDivElement
77+
let root: Root
78+
let client: QueryClient
79+
beforeEach(() => {
80+
vi.useFakeTimers()
81+
vi.clearAllMocks()
82+
mocks.enabled = false
83+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
84+
container = document.createElement('div')
85+
document.body.appendChild(container)
86+
root = createRoot(container)
87+
client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
88+
})
89+
afterEach(() => {
90+
act(() => root.unmount())
91+
client.clear()
92+
container.remove()
93+
vi.useRealTimers()
94+
})
95+
96+
function render() {
97+
act(() =>
98+
root.render(
99+
<QueryClientProvider client={client}>
100+
<InboxEnableToggle />
101+
</QueryClientProvider>
102+
)
103+
)
104+
}
105+
async function click(label: string) {
106+
const button = Array.from(container.querySelectorAll('button')).find(
107+
(button) => button.textContent === label
108+
)
109+
expect(button).toBeDefined()
110+
await act(async () => {
111+
button?.click()
112+
await vi.runAllTimersAsync()
113+
})
114+
}
115+
116+
describe('inbox setup error visibility', () => {
117+
it.each([
118+
{ enabled: false, toggle: 'On', submit: 'Enable' },
119+
{ enabled: true, toggle: 'Off', submit: 'Disable inbox' },
120+
])(
121+
'keeps the dialog open and displays a failed $submit request',
122+
async ({ enabled, toggle, submit }) => {
123+
mocks.enabled = enabled
124+
mocks.toggle.mockRejectedValueOnce(new Error('Email service unavailable'))
125+
render()
126+
await click(toggle)
127+
await click(submit)
128+
expect(container.querySelector('[role="alert"]')?.textContent).toBe(
129+
'Email service unavailable'
130+
)
131+
expect(container.querySelector('[role="dialog"]')).not.toBeNull()
132+
await click('Cancel')
133+
await click(toggle)
134+
expect(container.querySelector('[role="alert"]')).toBeNull()
135+
}
136+
)
137+
138+
it('clears the failure and closes after a successful retry', async () => {
139+
mocks.toggle
140+
.mockRejectedValueOnce(new Error('Email service unavailable'))
141+
.mockResolvedValueOnce({ enabled: true })
142+
render()
143+
await click('On')
144+
await click('Enable')
145+
expect(container.querySelector('[role="alert"]')).not.toBeNull()
146+
await click('Enable')
147+
expect(container.querySelector('[role="dialog"]')).toBeNull()
148+
expect(mocks.toggle).toHaveBeenCalledTimes(2)
149+
})
150+
})

apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx

Lines changed: 57 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
'use client'
22

3-
import { useCallback, useState } from 'react'
3+
import { useState } from 'react'
44
import {
55
ChipConfirmModal,
66
ChipModal,
77
ChipModalBody,
8+
ChipModalError,
89
ChipModalField,
910
ChipModalFooter,
1011
ChipModalHeader,
12+
ChipSwitch,
1113
Label,
12-
Switch,
1314
} from '@sim/emcn'
14-
import { createLogger } from '@sim/logger'
15+
import { getErrorMessage } from '@sim/utils/errors'
1516
import { useParams } from 'next/navigation'
1617
import { useInboxConfig, useToggleInbox } from '@/hooks/queries/inbox'
1718

18-
const logger = createLogger('InboxEnableToggle')
19+
const INBOX_OPTIONS = [
20+
{ value: 'enabled', label: 'On' },
21+
{ value: 'disabled', label: 'Off' },
22+
] as const
1923

2024
export function InboxEnableToggle() {
2125
const params = useParams()
@@ -28,56 +32,68 @@ export function InboxEnableToggle() {
2832
const [isDisableOpen, setIsDisableOpen] = useState(false)
2933
const [enableUsername, setEnableUsername] = useState('')
3034

31-
const handleToggle = useCallback(async (checked: boolean) => {
35+
function handleToggle(checked: boolean) {
36+
toggleInbox.reset()
3237
if (checked) {
3338
setIsEnableOpen(true)
34-
return
39+
} else {
40+
setIsDisableOpen(true)
3541
}
36-
setIsDisableOpen(true)
37-
}, [])
42+
}
3843

39-
const handleDisable = useCallback(async () => {
40-
try {
41-
await toggleInbox.mutateAsync({ workspaceId, enabled: false })
42-
setIsDisableOpen(false)
43-
} catch (error) {
44-
logger.error('Failed to disable inbox', { error })
45-
}
46-
}, [workspaceId, toggleInbox.mutateAsync])
44+
function handleEnableOpenChange(open: boolean) {
45+
if (!toggleInbox.isPending) setIsEnableOpen(open)
46+
}
4747

48-
const handleEnable = useCallback(async () => {
49-
try {
50-
await toggleInbox.mutateAsync({
51-
workspaceId,
52-
enabled: true,
53-
username: enableUsername.trim() || undefined,
54-
})
55-
setIsEnableOpen(false)
56-
setEnableUsername('')
57-
} catch (error) {
58-
logger.error('Failed to enable inbox', { error })
59-
}
60-
}, [workspaceId, enableUsername, toggleInbox.mutateAsync])
48+
function handleDisable() {
49+
toggleInbox.mutate(
50+
{ workspaceId, enabled: false },
51+
{ onSuccess: () => setIsDisableOpen(false) }
52+
)
53+
}
54+
55+
function handleEnable() {
56+
toggleInbox.mutate(
57+
{ workspaceId, enabled: true, username: enableUsername.trim() || undefined },
58+
{
59+
onSuccess: () => {
60+
setIsEnableOpen(false)
61+
setEnableUsername('')
62+
},
63+
}
64+
)
65+
}
66+
67+
const error = toggleInbox.error
68+
? getErrorMessage(toggleInbox.error, 'Failed to update inbox')
69+
: null
6170

6271
return (
6372
<>
6473
<div className='flex items-center justify-between'>
6574
<div className='flex flex-col gap-1'>
66-
<Label htmlFor='inbox-enabled'>Enable email inbox</Label>
75+
<Label>Enable email inbox</Label>
6776
<p className='text-[var(--text-muted)] text-caption'>
6877
Allow this workspace to receive tasks via email
6978
</p>
7079
</div>
71-
<Switch
72-
id='inbox-enabled'
73-
checked={config?.enabled ?? false}
74-
onCheckedChange={handleToggle}
80+
<ChipSwitch
81+
aria-label='Enable email inbox'
82+
options={INBOX_OPTIONS}
83+
value={config?.enabled ? 'enabled' : 'disabled'}
84+
onChange={(value) => handleToggle(value === 'enabled')}
7585
disabled={toggleInbox.isPending}
7686
/>
7787
</div>
7888

79-
<ChipModal open={isEnableOpen} onOpenChange={setIsEnableOpen} srTitle='Enable email inbox'>
80-
<ChipModalHeader onClose={() => setIsEnableOpen(false)}>Enable email inbox</ChipModalHeader>
89+
<ChipModal
90+
open={isEnableOpen}
91+
onOpenChange={handleEnableOpenChange}
92+
srTitle='Enable email inbox'
93+
>
94+
<ChipModalHeader onClose={() => handleEnableOpenChange(false)}>
95+
Enable email inbox
96+
</ChipModalHeader>
8197
<ChipModalBody>
8298
<p className='px-2 text-[var(--text-secondary)] text-sm'>
8399
An email address will be created for this workspace. Anyone in the allowed senders list
@@ -93,11 +109,13 @@ export function InboxEnableToggle() {
93109
<p className='px-2 text-[var(--text-muted)] text-sm'>
94110
Leave blank for an auto-generated address.
95111
</p>
112+
<ChipModalError>{error}</ChipModalError>
96113
</ChipModalBody>
97114
<ChipModalFooter
98-
onCancel={() => setIsEnableOpen(false)}
115+
onCancel={() => handleEnableOpenChange(false)}
116+
cancelDisabled={toggleInbox.isPending}
99117
primaryAction={{
100-
label: 'Enable',
118+
label: toggleInbox.isPending ? 'Enabling...' : 'Enable',
101119
onClick: handleEnable,
102120
disabled: toggleInbox.isPending,
103121
}}
@@ -125,6 +143,7 @@ export function InboxEnableToggle() {
125143
<p className='px-2 text-[var(--text-secondary)] text-sm'>
126144
Your existing conversations and task history will be preserved.
127145
</p>
146+
<ChipModalError>{error}</ChipModalError>
128147
</ChipConfirmModal>
129148
</>
130149
)

apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,11 @@ export function InboxSettingsTab() {
296296
</div>
297297
</div>
298298
)}
299+
{updateSecretPolicy.error && (
300+
<p role='alert' className='text-[var(--text-error)] text-caption'>
301+
{getErrorMessage(updateSecretPolicy.error, 'Failed to update secret access')}
302+
</p>
303+
)}
299304
</div>
300305
</SettingsSection>
301306
</div>

0 commit comments

Comments
 (0)