diff --git a/locales/rn/en.json b/locales/rn/en.json index 489ab02e..b7294771 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -370,7 +370,7 @@ "limit": "Maximum of {count} accounts reached." }, "shared_accounts": { - "description_mobile": "Group and shared accounts you can read through this account. Their filters and vacation responder are managed from the webmail." + "description_manage": "Group and shared accounts you can read through this account. Select one to manage its filters and vacation responder." }, "unknown": "Unknown" }, diff --git a/src/api/__tests__/sieve.test.ts b/src/api/__tests__/sieve.test.ts new file mode 100644 index 00000000..d59f45c4 --- /dev/null +++ b/src/api/__tests__/sieve.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'own', + authHeader: 'Basic x', + request: vi.fn(), + currentSession: null as unknown, + }, +})); + +vi.mock('../../lib/client-cert', () => ({ secureFetch: vi.fn() })); + +import { jmapClient } from '../jmap-client'; +import { secureFetch } from '../../lib/client-cert'; +import { CAPABILITIES } from '../types'; +import { + accountSupportsSieve, + getSieveCapabilities, + getSieveScriptContent, + getSieveScripts, + isSieveSupported, + updateSieveScript, + validateSieveScript, +} from '../sieve'; + +const mockRequest = jmapClient.request as ReturnType; +const mockFetch = secureFetch as unknown as ReturnType; + +const SIEVE_CAPS = { sieveExtensions: ['fileinto'] }; + +function setSession(session: unknown) { + (jmapClient as { currentSession: unknown }).currentSession = session; +} + +beforeEach(() => { + vi.clearAllMocks(); + setSession({ + downloadUrl: 'https://mail/download/{accountId}/{blobId}/{name}?type={type}', + uploadUrl: 'https://mail/upload/{accountId}/', + primaryAccounts: { [CAPABILITIES.SIEVE]: 'own' }, + capabilities: { [CAPABILITIES.SIEVE]: {} }, + accounts: { + own: { name: 'me', isPersonal: true, isReadOnly: false, accountCapabilities: { [CAPABILITIES.SIEVE]: SIEVE_CAPS } }, + team: { name: 'Team', isPersonal: false, isReadOnly: false, accountCapabilities: { [CAPABILITIES.MAIL]: {} } }, + other: { name: 'Other', isPersonal: true, isReadOnly: false, accountCapabilities: { [CAPABILITIES.MAIL]: {} } }, + }, + }); +}); + +describe('accountSupportsSieve', () => { + const caps = { [CAPABILITIES.SIEVE]: {} }; + + it('needs the Sieve capability in a personal account\'s own capabilities', () => { + expect(accountSupportsSieve( + { name: 'me', isPersonal: true, isReadOnly: false, accountCapabilities: { [CAPABILITIES.SIEVE]: {} } }, + caps, + )).toBe(true); + expect(accountSupportsSieve( + { name: 'me', isPersonal: true, isReadOnly: false, accountCapabilities: { [CAPABILITIES.MAIL]: {} } }, + caps, + )).toBe(false); + }); + + it('treats shared/group accounts as capable, but only when the server has Sieve', () => { + expect(accountSupportsSieve({ name: 'grp', isPersonal: false, isReadOnly: false }, caps)).toBe(true); + expect(accountSupportsSieve({ name: 'grp', isPersonal: false, isReadOnly: false }, {})).toBe(false); + expect(accountSupportsSieve(undefined, caps)).toBe(false); + }); + + it('keeps the session answer for servers without accountCapabilities', () => { + expect(accountSupportsSieve({ name: 'me', isPersonal: true, isReadOnly: false }, caps)).toBe(true); + }); +}); + +describe('isSieveSupported', () => { + it('checks the own Sieve account by default and a named account on request', () => { + expect(isSieveSupported()).toBe(true); + expect(isSieveSupported('team')).toBe(true); + expect(isSieveSupported('other')).toBe(false); + expect(isSieveSupported('missing')).toBe(false); + }); + + it('is false without a session', () => { + setSession(null); + expect(isSieveSupported()).toBe(false); + }); +}); + +describe('account scoping', () => { + it('reads the capabilities of the requested account', () => { + expect(getSieveCapabilities()).toEqual(SIEVE_CAPS); + expect(getSieveCapabilities('team')).toBeNull(); + }); + + it('lists scripts of the requested account', async () => { + mockRequest.mockResolvedValue({ methodResponses: [['SieveScript/get', { list: [] }, '0']] }); + await getSieveScripts('team'); + expect(mockRequest.mock.calls[0][0][0][1]).toEqual({ accountId: 'team' }); + await getSieveScripts(); + expect(mockRequest.mock.calls[1][0][0][1]).toEqual({ accountId: 'own' }); + }); + + it('downloads a shared account\'s script against that account', async () => { + mockFetch.mockResolvedValue({ ok: true, text: async () => 'keep;' }); + await getSieveScriptContent('blob-1', 'team'); + expect(mockFetch.mock.calls[0][0]).toContain('/download/team/blob-1/'); + }); + + it('uploads and saves into the requested account', async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ team: { blobId: 'b-new' } }) }); + mockRequest.mockResolvedValue({ methodResponses: [['SieveScript/set', { updated: { s1: null } }, '0']] }); + + await updateSieveScript('s1', 'keep;', true, 'team'); + + expect(mockFetch.mock.calls[0][0]).toBe('https://mail/upload/team/'); + const [method, args] = mockRequest.mock.calls[0][0][0]; + expect(method).toBe('SieveScript/set'); + expect(args).toEqual({ + accountId: 'team', + update: { s1: { blobId: 'b-new' } }, + onSuccessActivateScript: 's1', + }); + }); + + it('validates against the requested account', async () => { + mockFetch.mockResolvedValue({ ok: true, json: async () => ({ blobId: 'b-val' }) }); + mockRequest.mockResolvedValue({ methodResponses: [['SieveScript/validate', {}, '0']] }); + + await expect(validateSieveScript('keep;', 'team')).resolves.toEqual({ isValid: true }); + expect(mockFetch.mock.calls[0][0]).toBe('https://mail/upload/team/'); + expect(mockRequest.mock.calls[0][0][0][1]).toEqual({ accountId: 'team', blobId: 'b-val' }); + }); +}); diff --git a/src/api/__tests__/vacation.test.ts b/src/api/__tests__/vacation.test.ts new file mode 100644 index 00000000..24d36591 --- /dev/null +++ b/src/api/__tests__/vacation.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'own', + request: vi.fn(), + currentSession: null as unknown, + }, +})); + +import { jmapClient } from '../jmap-client'; +import { CAPABILITIES } from '../types'; +import { + accountSupportsVacation, + getVacationResponse, + isVacationSupported, + setVacationResponse, +} from '../vacation'; + +const mockRequest = jmapClient.request as ReturnType; + +function setSession(session: unknown) { + (jmapClient as { currentSession: unknown }).currentSession = session; +} + +beforeEach(() => { + vi.clearAllMocks(); + setSession({ + primaryAccounts: { [CAPABILITIES.MAIL]: 'own' }, + capabilities: { [CAPABILITIES.VACATION]: {} }, + accounts: { + own: { name: 'me', isPersonal: true, isReadOnly: false, accountCapabilities: { [CAPABILITIES.VACATION]: {} } }, + team: { name: 'Team', isPersonal: false, isReadOnly: false, accountCapabilities: { [CAPABILITIES.MAIL]: {} } }, + other: { name: 'Other', isPersonal: true, isReadOnly: false, accountCapabilities: { [CAPABILITIES.MAIL]: {} } }, + }, + }); +}); + +describe('accountSupportsVacation', () => { + const caps = { [CAPABILITIES.VACATION]: {} }; + + it('needs the capability in a personal account\'s own capabilities', () => { + expect(accountSupportsVacation( + { name: 'me', isPersonal: true, isReadOnly: false, accountCapabilities: { [CAPABILITIES.VACATION]: {} } }, + caps, + )).toBe(true); + expect(accountSupportsVacation( + { name: 'me', isPersonal: true, isReadOnly: false, accountCapabilities: { [CAPABILITIES.MAIL]: {} } }, + caps, + )).toBe(false); + }); + + it('treats shared/group accounts as capable, but only when the server has it', () => { + expect(accountSupportsVacation({ name: 'grp', isPersonal: false, isReadOnly: false }, caps)).toBe(true); + expect(accountSupportsVacation({ name: 'grp', isPersonal: false, isReadOnly: false }, {})).toBe(false); + expect(accountSupportsVacation(undefined, caps)).toBe(false); + }); +}); + +describe('isVacationSupported', () => { + it('checks the own mail account by default and a named account on request', () => { + expect(isVacationSupported()).toBe(true); + expect(isVacationSupported('team')).toBe(true); + expect(isVacationSupported('other')).toBe(false); + }); + + it('is false without a session', () => { + setSession(null); + expect(isVacationSupported()).toBe(false); + }); +}); + +describe('account scoping', () => { + it('reads and writes the responder of the requested account', async () => { + mockRequest.mockResolvedValueOnce({ methodResponses: [['VacationResponse/get', { list: [] }, '0']] }); + await getVacationResponse('team'); + expect(mockRequest.mock.calls[0][0][0][1]).toEqual({ accountId: 'team', ids: ['singleton'] }); + + mockRequest.mockResolvedValueOnce({ methodResponses: [['VacationResponse/set', { updated: {} }, '0']] }); + await setVacationResponse({ isEnabled: true }, 'team'); + expect(mockRequest.mock.calls[1][0][0][1]).toEqual({ + accountId: 'team', + update: { singleton: { isEnabled: true } }, + }); + }); + + it('defaults to the own mail account', async () => { + mockRequest.mockResolvedValueOnce({ methodResponses: [['VacationResponse/get', { list: [] }, '0']] }); + await getVacationResponse(); + expect(mockRequest.mock.calls[0][0][0][1].accountId).toBe('own'); + }); +}); diff --git a/src/api/sieve.ts b/src/api/sieve.ts index aef173cd..f3f932bf 100644 --- a/src/api/sieve.ts +++ b/src/api/sieve.ts @@ -1,5 +1,5 @@ import { jmapClient } from './jmap-client'; -import { CAPABILITIES } from './types'; +import { CAPABILITIES, type JMAPAccountInfo } from './types'; import { secureFetch } from '../lib/client-cert'; import type { SieveScript, SieveCapabilities } from '../lib/sieve/types'; @@ -17,28 +17,49 @@ function requireSession() { // Sieve lives on its own JMAP account (RFC 9661). Fall back to the mail account // when the server does not advertise a dedicated one (Stalwart uses the same id). +// Every call below takes an optional accountId so the filters of a shared/group +// account can be managed too (webmail: "Shared with me" in Account settings). export function getSieveAccountId(): string { const session = jmapClient.currentSession; return session?.primaryAccounts?.[CAPABILITIES.SIEVE] ?? jmapClient.accountId; } -export function isSieveSupported(): boolean { - const session = jmapClient.currentSession; - if (!session) return false; - return CAPABILITIES.SIEVE in (session.capabilities ?? {}); +// Gate on the ACCOUNT capability, not only the server-wide session capability: +// RFC 9661 advertises Sieve per account, and an account without Sieve rights +// fails every SieveScript call. Stalwart doesn't always advertise capabilities +// on shared/group accounts, so treat non-personal accounts as capable - the +// rule the webmail's getSharedAccounts() applies. A server that populates no +// accountCapabilities at all keeps the session-wide answer. +export function accountSupportsSieve( + account: JMAPAccountInfo | undefined, + sessionCapabilities: Record | undefined, +): boolean { + if (!sessionCapabilities || !(CAPABILITIES.SIEVE in sessionCapabilities)) return false; + if (!account) return false; + if (!account.isPersonal || !account.accountCapabilities) return true; + return CAPABILITIES.SIEVE in account.accountCapabilities; } -export function getSieveCapabilities(): SieveCapabilities | null { +export function isSieveSupported(accountId?: string): boolean { + const session = jmapClient.currentSession; + if (!session) return false; + return accountSupportsSieve( + session.accounts?.[accountId ?? getSieveAccountId()], + session.capabilities, + ); +} + +export function getSieveCapabilities(accountId?: string): SieveCapabilities | null { const session = jmapClient.currentSession; if (!session) return null; - const info = session.accounts?.[getSieveAccountId()]; + const info = session.accounts?.[accountId ?? getSieveAccountId()]; const caps = info?.accountCapabilities?.[CAPABILITIES.SIEVE]; return (caps as SieveCapabilities) ?? null; } -export async function getSieveScripts(): Promise { +export async function getSieveScripts(accountId: string = getSieveAccountId()): Promise { const res = await jmapClient.request( - [['SieveScript/get', { accountId: getSieveAccountId() }, '0']], + [['SieveScript/get', { accountId }, '0']], SIEVE_USING, ); const resp = res.methodResponses?.[0]; @@ -48,10 +69,15 @@ export async function getSieveScripts(): Promise { throw new Error('Failed to fetch Sieve scripts'); } -export async function getSieveScriptContent(blobId: string): Promise { +export async function getSieveScriptContent( + blobId: string, + accountId: string = getSieveAccountId(), +): Promise { const session = requireSession(); + // Blobs are scoped per account, so a shared account's script is downloaded + // against that account's id. const url = session.downloadUrl - .replace('{accountId}', encodeURIComponent(getSieveAccountId())) + .replace('{accountId}', encodeURIComponent(accountId)) .replace('{blobId}', encodeURIComponent(blobId)) .replace('{name}', encodeURIComponent('script.sieve')) .replace('{type}', encodeURIComponent('application/sieve')); @@ -63,11 +89,11 @@ export async function getSieveScriptContent(blobId: string): Promise { return response.text(); } -async function uploadSieveBlob(content: string): Promise { +async function uploadSieveBlob(content: string, accountId: string): Promise { const session = requireSession(); const uploadUrl = session.uploadUrl.replace( '{accountId}', - encodeURIComponent(getSieveAccountId()), + encodeURIComponent(accountId), ); const response = await secureFetch(uploadUrl, { @@ -89,7 +115,7 @@ async function uploadSieveBlob(content: string): Promise { const raw = (await response.json()) as Record; const direct = raw as { blobId?: string }; if (typeof direct.blobId === 'string') return direct.blobId; - const nested = raw[getSieveAccountId()] as { blobId?: string } | undefined; + const nested = raw[accountId] as { blobId?: string } | undefined; if (nested?.blobId) return nested.blobId; throw new Error('Upload succeeded but response did not include a blobId'); } @@ -98,9 +124,9 @@ export async function createSieveScript( name: string, content: string, activate = true, + accountId: string = getSieveAccountId(), ): Promise { - const blobId = await uploadSieveBlob(content); - const accountId = getSieveAccountId(); + const blobId = await uploadSieveBlob(content, accountId); const setArgs: Record = { accountId, @@ -120,7 +146,7 @@ export async function createSieveScript( } const createdId = result.created?.['new-script']?.id; if (createdId) { - const scripts = await getSieveScripts(); + const scripts = await getSieveScripts(accountId); const script = scripts.find((s) => s.id === createdId); if (script) return script; } @@ -132,9 +158,9 @@ export async function updateSieveScript( scriptId: string, content: string, activate = true, + accountId: string = getSieveAccountId(), ): Promise { - const blobId = await uploadSieveBlob(content); - const accountId = getSieveAccountId(); + const blobId = await uploadSieveBlob(content, accountId); const setArgs: Record = { accountId, @@ -154,9 +180,12 @@ export async function updateSieveScript( throw new Error('Failed to update Sieve script'); } -export async function deleteSieveScript(scriptId: string): Promise { +export async function deleteSieveScript( + scriptId: string, + accountId: string = getSieveAccountId(), +): Promise { const res = await jmapClient.request( - [['SieveScript/set', { accountId: getSieveAccountId(), destroy: [scriptId] }, '0']], + [['SieveScript/set', { accountId, destroy: [scriptId] }, '0']], SIEVE_USING, ); const resp = res.methodResponses?.[0]; @@ -172,10 +201,11 @@ export async function deleteSieveScript(scriptId: string): Promise { export async function validateSieveScript( content: string, + accountId: string = getSieveAccountId(), ): Promise<{ isValid: boolean; errors?: string[] }> { - const blobId = await uploadSieveBlob(content); + const blobId = await uploadSieveBlob(content, accountId); const res = await jmapClient.request( - [['SieveScript/validate', { accountId: getSieveAccountId(), blobId }, '0']], + [['SieveScript/validate', { accountId, blobId }, '0']], SIEVE_USING, ); const resp = res.methodResponses?.[0]; diff --git a/src/api/vacation.ts b/src/api/vacation.ts index 1aba8a3b..a7c629a7 100644 --- a/src/api/vacation.ts +++ b/src/api/vacation.ts @@ -1,5 +1,5 @@ import { jmapClient } from './jmap-client'; -import { CAPABILITIES } from './types'; +import { CAPABILITIES, type JMAPAccountInfo } from './types'; export interface VacationResponse { id: string; @@ -23,8 +23,12 @@ const DEFAULT: VacationResponse = { htmlBody: null, }; -export async function getVacationResponse(): Promise { - const accountId = jmapClient.accountId; +// The calls take an optional accountId so a shared/group account's responder +// can be managed too (webmail: "Shared with me" in Account settings). The +// default is the user's own mail account. +export async function getVacationResponse( + accountId: string = jmapClient.accountId, +): Promise { const res = await jmapClient.request( [['VacationResponse/get', { accountId, ids: ['singleton'] }, '0']], VACATION_USING, @@ -39,8 +43,8 @@ export async function getVacationResponse(): Promise { export async function setVacationResponse( updates: Partial>, + accountId: string = jmapClient.accountId, ): Promise { - const accountId = jmapClient.accountId; const res = await jmapClient.request( [['VacationResponse/set', { accountId, @@ -59,9 +63,26 @@ export async function setVacationResponse( throw new Error('Unexpected response from VacationResponse/set'); } -export function isVacationSupported(): boolean { +// Gate on the ACCOUNT capability: RFC 8621 advertises VacationResponse per +// account, so the session can list it while a given account lacks it. +// Stalwart doesn't always advertise capabilities on shared/group accounts, so +// treat non-personal accounts as capable, as the webmail does. A server that +// populates no accountCapabilities at all keeps the session-wide answer. +export function accountSupportsVacation( + account: JMAPAccountInfo | undefined, + sessionCapabilities: Record | undefined, +): boolean { + if (!sessionCapabilities || !(CAPABILITIES.VACATION in sessionCapabilities)) return false; + if (!account) return false; + if (!account.isPersonal || !account.accountCapabilities) return true; + return CAPABILITIES.VACATION in account.accountCapabilities; +} + +export function isVacationSupported(accountId?: string): boolean { const session = jmapClient.currentSession; if (!session) return false; - const caps = session.capabilities ?? {}; - return CAPABILITIES.VACATION in caps; + return accountSupportsVacation( + session.accounts?.[accountId ?? jmapClient.accountId], + session.capabilities, + ); } diff --git a/src/components/settings/AccountSettings.tsx b/src/components/settings/AccountSettings.tsx index 7b6820d2..9c3250d3 100644 --- a/src/components/settings/AccountSettings.tsx +++ b/src/components/settings/AccountSettings.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import { View, Text, StyleSheet, Pressable, Alert } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { ArrowDown, ArrowUp, Check, Plus, Star, Trash2, Users } from 'lucide-react-native'; +import { ArrowDown, ArrowUp, Check, ChevronRight, Plus, Star, Trash2, Users } from 'lucide-react-native'; import { SettingsSection, SettingItem } from './settings-section'; import Button from '../Button'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; @@ -10,6 +10,9 @@ import { useColors } from '../../theme/colors'; import { useAuthStore } from '../../stores/auth-store'; import { useAccountStore } from '../../stores/account-store'; import { useLocaleStore } from '../../stores/locale-store'; +import { useManagedAccountStore } from '../../stores/managed-account-store'; +import { setPendingSettingsTab } from '../../navigation/pending-settings-tab'; +import { sharedAccountSettingsTabs } from '../../lib/capabilities'; import { jmapClient } from '../../api/jmap-client'; import { fetchMailQuota, type MailQuota } from '../../api/quota'; import { MAX_ACCOUNTS } from '../../lib/account-utils'; @@ -65,6 +68,14 @@ export function AccountSettings() { const percent = quota && quota.total > 0 ? Math.min(100, Math.round((quota.used / quota.total) * 100)) : 0; const sharedAccounts = session ? jmapClient.getSharedMailAccounts() : []; const [busyId, setBusyId] = useState(null); + const setManagedAccount = useManagedAccountStore((s) => s.setManagedAccount); + + // Manage a shared/group account's filters and vacation responder: scope + // Settings to it and open its first pane (webmail: "Shared with me"). + const manageShared = (account: { id: string; name: string }, firstTab: string) => { + setManagedAccount(account); + setPendingSettingsTab(firstTab); + }; const move = (id: string, delta: -1 | 1) => { const ids = accounts.map((a) => a.id); @@ -244,20 +255,34 @@ export function AccountSettings() { {sharedAccounts.length > 0 && ( - {sharedAccounts.map((s, index) => ( - 0 && styles.rowBorder]}> - - - - - {s.name} - {t('settings.account.shared_accounts.shared_label', 'Shared account')} - - - ))} + {sharedAccounts.map((s, index) => { + const firstTab = sharedAccountSettingsTabs(s.id)[0]; + return ( + manageShared(s, firstTab) : undefined} + disabled={!firstTab} + accessibilityRole={firstTab ? 'button' : undefined} + style={({ pressed }) => [ + styles.row, + index > 0 && styles.rowBorder, + pressed && firstTab && { backgroundColor: c.muted }, + ]} + > + + + + + {s.name} + {t('settings.account.shared_accounts.shared_label', 'Shared account')} + + {firstTab && } + + ); + })} )} diff --git a/src/components/settings/FilterSettings.tsx b/src/components/settings/FilterSettings.tsx index c5d3d085..0bdfd24b 100644 --- a/src/components/settings/FilterSettings.tsx +++ b/src/components/settings/FilterSettings.tsx @@ -12,6 +12,9 @@ import { useSettingsStore } from '../../stores/settings-store'; import { useFilterStore } from '../../stores/filter-store'; import { useVacationStore } from '../../stores/vacation-store'; import { useEmailStore } from '../../stores/email-store'; +import { useManagedAccountStore } from '../../stores/managed-account-store'; +import { getMailboxes } from '../../api/email'; +import type { Mailbox } from '../../api/types'; import { ownMailboxes } from '../../lib/mailbox-tree'; import { useLocaleStore } from '../../stores/locale-store'; import { FilterRuleModal } from '../filters/FilterRuleModal'; @@ -106,15 +109,41 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) { const expandedView = useSettingsStore((s) => s.filtersExpandedView); const updateSetting = useSettingsStore((s) => s.updateSetting); - // Sieve rules run on the user's own account, so only own folders are - // valid "file into" targets. + // Scoped to a shared/group account when Settings is managing one. + const managedAccountId = useManagedAccountStore((s) => s.managedAccountId); + + // Rules run in the account whose filters they are, so only that account's + // folders are valid "file into" targets: the user's own, or the managed + // account's. Those are fetched here and presented as own folders, since the + // rule editor's folder tree leaves shared folders out (webmail does the same). const allMailboxes = useEmailStore((s) => s.mailboxes); - const mailboxes = useMemo(() => ownMailboxes(allMailboxes), [allMailboxes]); - const vacationEnabled = useVacationStore((s) => s.isEnabled); + const [scopedMailboxes, setScopedMailboxes] = useState([]); + useEffect(() => { + if (!managedAccountId) { + setScopedMailboxes([]); + return; + } + let cancelled = false; + getMailboxes(managedAccountId) + .then((list) => { + if (!cancelled) setScopedMailboxes(list.map((m) => ({ ...m, isShared: false }))); + }) + .catch(() => { + if (!cancelled) setScopedMailboxes([]); + }); + return () => { cancelled = true; }; + }, [managedAccountId]); + const mailboxes = useMemo( + () => (managedAccountId ? scopedMailboxes : ownMailboxes(allMailboxes)), + [managedAccountId, scopedMailboxes, allMailboxes], + ); + // The responder the vacation store holds must be the user's own: the + // "active" banner is only about the own account's auto-reply. + const vacationEnabled = useVacationStore((s) => s.accountId === null && s.isEnabled); const { rules, isLoading, isSaving, error, isSupported, isOpaque, rawScript, vacationSettings, - fetchFilters, saveFilters, addRule, updateRule, deleteRule, reorderRules, toggleRule, + selectAccount, saveFilters, addRule, updateRule, deleteRule, reorderRules, toggleRule, setOpaqueScript, resetToVisualBuilder, validateScript, } = useFilterStore(); @@ -127,11 +156,14 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) { if (!hydrated) void hydrate(); }, [hydrated, hydrate]); + // Always name the account (null = own) so leaving a shared account never + // keeps showing, or saving into, its script. useEffect(() => { - void fetchFilters(); - }, [fetchFilters]); + void selectAccount(managedAccountId); + }, [managedAccountId, selectAccount]); - const showVacationBanner = (vacationEnabled || vacationSettings?.isEnabled) ?? false; + const showVacationBanner = + !managedAccountId && ((vacationEnabled || vacationSettings?.isEnabled) ?? false); // Editable (Bulwark-managed) rules, in order, for reorder math. const editableIds = useMemo( diff --git a/src/components/settings/VacationSettings.tsx b/src/components/settings/VacationSettings.tsx index 5981c227..e9382d08 100644 --- a/src/components/settings/VacationSettings.tsx +++ b/src/components/settings/VacationSettings.tsx @@ -10,6 +10,7 @@ import RichTextEditor, { type RichTextEditorHandle } from '../RichTextEditor'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useVacationStore } from '../../stores/vacation-store'; +import { useManagedAccountStore } from '../../stores/managed-account-store'; import { useLocaleStore } from '../../stores/locale-store'; import { htmlToPlainText } from '../../lib/compose-html'; import { stripDangerousTags, escapeHtml } from '../../lib/email-html'; @@ -31,6 +32,8 @@ export function VacationSettings() { const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); const store = useVacationStore(); + // Scoped to a shared/group account when Settings is managing one. + const managedAccountId = useManagedAccountStore((s) => s.managedAccountId); const [enabled, setEnabled] = useState(store.isEnabled); const [fromDate, setFromDate] = useState(utcIsoToLocalInput(store.fromDate)); @@ -48,9 +51,10 @@ export function VacationSettings() { // initial fetch itself failed (that's the only case that blanks the form). const [fetchError, setFetchError] = useState(null); useEffect(() => { - void store.fetch().then(() => setFetchError(useVacationStore.getState().error)); + void store.fetch(managedAccountId ?? undefined) + .then(() => setFetchError(useVacationStore.getState().error)); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [managedAccountId]); useEffect(() => { if (!store.hasLoaded) return; diff --git a/src/lib/capabilities.ts b/src/lib/capabilities.ts index 541ef70e..27b17b63 100644 --- a/src/lib/capabilities.ts +++ b/src/lib/capabilities.ts @@ -1,7 +1,9 @@ import { CAPABILITIES } from '../api/types'; -import type { JMAPSession } from '../api/types'; +import type { JMAPAccountInfo, JMAPSession } from '../api/types'; import { useAuthStore } from '../stores/auth-store'; import { accountSupportsFiles } from '../api/files'; +import { accountSupportsSieve, isSieveSupported } from '../api/sieve'; +import { accountSupportsVacation, isVacationSupported } from '../api/vacation'; // When the session is null (cold start, offline restore) we assume features // are available so they don't flicker off mid-restore. Once the live session @@ -49,14 +51,42 @@ export function useHasFiles(): boolean { return useAuthStore((s) => sessionSupportsFiles(s.session)); } +// The account api/sieve.ts and api/vacation.ts target by default: the +// capability's primary account, else the mail account jmapClient resolves. +function primaryAccount(session: JMAPSession, capability: string): JMAPAccountInfo | undefined { + const id = session.primaryAccounts?.[capability] + ?? session.primaryAccounts?.[CAPABILITIES.MAIL] + ?? session.primaryAccounts?.[CAPABILITIES.CORE] + ?? Object.keys(session.accounts ?? {})[0]; + return id ? session.accounts?.[id] : undefined; +} + // Settings tabs for Sieve filters and the vacation responder are gated on the -// same session capabilities api/sieve.ts and api/vacation.ts check, so the -// tab is disabled up front instead of opening onto a "not supported" screen. +// same per-account checks api/sieve.ts and api/vacation.ts make, so the tab is +// disabled up front instead of opening onto a "not supported" screen. export function useHasSieve(): boolean { - return useAuthStore((s) => (s.session ? CAPABILITIES.SIEVE in s.session.capabilities : true)); + return useAuthStore((s) => (s.session + ? accountSupportsSieve(primaryAccount(s.session, CAPABILITIES.SIEVE), s.session.capabilities) + : true)); } export function useHasVacation(): boolean { - return useAuthStore((s) => (s.session ? CAPABILITIES.VACATION in s.session.capabilities : true)); + return useAuthStore((s) => (s.session + ? accountSupportsVacation(primaryAccount(s.session, CAPABILITIES.MAIL), s.session.capabilities) + : true)); +} + +export type SharedAccountSettingsTab = 'filters' | 'vacation'; + +/** + * Settings panes a shared/group account can be managed in, the first one + * being where "Shared with me" lands. Mirrors the webmail's scoped settings + * tabs (its calendar and contacts panes aren't scoped on mobile). + */ +export function sharedAccountSettingsTabs(accountId: string): SharedAccountSettingsTab[] { + const tabs: SharedAccountSettingsTab[] = []; + if (isSieveSupported(accountId)) tabs.push('filters'); + if (isVacationSupported(accountId)) tabs.push('vacation'); + return tabs; } diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index bd2be286..14a78654 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -37,7 +37,11 @@ import { ComposingSettings } from '../components/settings/ComposingSettings'; import { LayoutSettings } from '../components/settings/LayoutSettings'; import { DownloadsSettings } from '../components/settings/DownloadsSettings'; import { useLocaleStore } from '../stores/locale-store'; -import { useHasCalendar, useHasContacts, useHasFiles, useHasSieve, useHasVacation } from '../lib/capabilities'; +import { useAuthStore } from '../stores/auth-store'; +import { useManagedAccountStore } from '../stores/managed-account-store'; +import { + sharedAccountSettingsTabs, useHasCalendar, useHasContacts, useHasFiles, useHasSieve, useHasVacation, +} from '../lib/capabilities'; import { supportsSideloadUpdates } from '../lib/platform-capabilities'; import { usePendingSettingsTab } from '../navigation/pending-settings-tab'; @@ -150,11 +154,11 @@ const AVAILABLE_TABS: TabDef[] = TABS.filter( (t) => !t.hidden && (t.id !== 'updates' || supportsSideloadUpdates), ); -function groupTabs() { +function groupTabs(only: ReadonlySet | null) { return GROUP_ORDER.map(group => ({ group, label: GROUP_LABELS[group], - items: AVAILABLE_TABS.filter(t => t.group === group), + items: AVAILABLE_TABS.filter(t => t.group === group && (!only || only.has(t.id))), })).filter(g => g.items.length > 0); } @@ -176,25 +180,49 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin const hasFiles = useHasFiles(); const hasSieve = useHasSieve(); const hasVacation = useHasVacation(); + + // A shared/group account picked under "Shared with me" scopes Settings to + // the panes that account can be managed in (webmail: scoped settings). + const managedAccount = useManagedAccountStore((s) => s.managedAccount); + const clearManagedAccount = useManagedAccountStore((s) => s.clear); + const session = useAuthStore((s) => s.session); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const scopedTabs = React.useMemo | null>(() => { + void session; // dependency: capabilities come from the live session + return managedAccount ? new Set(sharedAccountSettingsTabs(managedAccount.id)) : null; + }, [managedAccount, session]); + // The scope belongs to one signed-in account and one visit to Settings. + useEffect(() => { clearManagedAccount(); }, [activeAccountId, clearManagedAccount]); + useEffect(() => () => clearManagedAccount(), [clearManagedAccount]); + const leaveScope = React.useCallback(() => { + clearManagedAccount(); + setSelectedTab('account'); + }, [clearManagedAccount]); + const unavailableTabs = React.useMemo(() => { const set = new Set(); + if (scopedTabs) return set; // only the shared account's supported panes are listed if (!hasCalendar) set.add('calendar'); if (!hasContacts) set.add('contacts'); if (!hasFiles) set.add('files'); if (!hasSieve) set.add('filters'); if (!hasVacation) set.add('vacation'); return set; - }, [hasCalendar, hasContacts, hasFiles, hasSieve, hasVacation]); - // A settings/ deep link parks its target here; open it once. + }, [scopedTabs, hasCalendar, hasContacts, hasFiles, hasSieve, hasVacation]); + // A settings/ deep link parks its target here; open it once. A tab the + // managed shared account has no pane for returns to the user's own account. const pendingTab = usePendingSettingsTab((s) => s.tab); useEffect(() => { if (!pendingTab) return; const tab = usePendingSettingsTab.getState().consume(); - if (tab && TABS.some((t) => t.id === tab && t.implemented)) setSelectedTab(tab as Tab); - }, [pendingTab]); + if (!tab || !TABS.some((t) => t.id === tab && t.implemented)) return; + const scoped = useManagedAccountStore.getState().managedAccount; + if (scoped && !(sharedAccountSettingsTabs(scoped.id) as string[]).includes(tab)) clearManagedAccount(); + setSelectedTab(tab as Tab); + }, [pendingTab, clearManagedAccount]); const groupedTabs = React.useMemo(() => { void locale; // dependency: re-translate on locale change - return groupTabs().map((g) => ({ + return groupTabs(scopedTabs).map((g) => ({ ...g, label: t(`settings.tab_groups.${g.group}`, g.label), items: g.items.map((tab) => ({ @@ -202,16 +230,31 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin label: t(`settings.tabs.${tab.id}`, tab.label), })), })); - }, [locale, t]); + }, [locale, t, scopedTabs]); useEffect(() => { - if (!selectedTab) return; + if (!selectedTab && !managedAccount) return; const sub = BackHandler.addEventListener('hardwareBackPress', () => { - setSelectedTab(null); + if (selectedTab) setSelectedTab(null); + else leaveScope(); return true; }); return () => sub.remove(); - }, [selectedTab]); + }, [selectedTab, managedAccount, leaveScope]); + + const scopeBanner = managedAccount ? ( + [styles.scopeBanner, pressed && styles.scopeBannerPressed]} + > + + {t('settings.scoped.back', 'Back to my account')} + + {t('settings.scoped.managing', 'Managing: {name}', { name: managedAccount.name })} + + + ) : null; const handleTabPress = (tab: TabDef) => { if (!tab.implemented) return; @@ -242,6 +285,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin + {scopeBanner} {selectedTab === 'filters' ? ( setSelectedTab('vacation') : undefined} @@ -276,6 +320,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin {/* Tab list - matches webmail mobile: flat grouped list, no cards */} + {scopeBanner && {scopeBanner}} {groupedTabs.map((group, groupIndex) => ( @@ -377,6 +422,23 @@ function makeStyles(c: ThemePalette) { tabList: { paddingVertical: spacing.sm }, + scopeBanner: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginBottom: spacing.lg, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.md, + backgroundColor: c.muted, + }, + scopeBannerPressed: { opacity: 0.8 }, + scopeBannerList: { paddingHorizontal: spacing.lg, paddingTop: spacing.lg }, + scopeBack: { ...typography.body, color: c.mutedForeground }, + scopeName: { ...typography.bodyMedium, color: c.text, flex: 1, textAlign: 'right' }, + groupDivider: { height: 1, backgroundColor: c.border, marginHorizontal: 20, marginVertical: spacing.sm, diff --git a/src/stores/__tests__/filter-store.test.ts b/src/stores/__tests__/filter-store.test.ts new file mode 100644 index 00000000..5409bd85 --- /dev/null +++ b/src/stores/__tests__/filter-store.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../api/sieve', () => ({ + createSieveScript: vi.fn(), + getSieveAccountId: vi.fn(() => 'own'), + getSieveCapabilities: vi.fn(() => null), + getSieveScriptContent: vi.fn(), + getSieveScripts: vi.fn(), + isSieveSupported: vi.fn(() => true), + updateSieveScript: vi.fn(), + validateSieveScript: vi.fn(async () => ({ isValid: true })), +})); + +import * as sieve from '../../api/sieve'; +import { generateScript } from '../../lib/sieve/generator'; +import type { FilterRule } from '../../lib/sieve/types'; +import { useFilterStore } from '../filter-store'; + +const api = vi.mocked(sieve); + +function makeRule(overrides: Partial = {}): FilterRule { + return { + id: 'rule-1', + name: 'Test Rule', + enabled: true, + matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: 'a@example.com' }], + actions: [{ type: 'move', value: 'Archive' }], + stopProcessing: false, + ...overrides, + }; +} + +function serveScript(rules: FilterRule[]) { + api.getSieveScripts.mockResolvedValue([{ id: 's1', name: 'filters', blobId: 'b1', isActive: true }]); + api.getSieveScriptContent.mockResolvedValue(generateScript(rules)); +} + +beforeEach(() => { + vi.clearAllMocks(); + api.getSieveAccountId.mockReturnValue('own'); + api.isSieveSupported.mockReturnValue(true); + useFilterStore.getState().clearState(); +}); + +describe('filter-store account selection', () => { + it('loads the own Sieve account by default', async () => { + serveScript([makeRule()]); + await useFilterStore.getState().selectAccount(null); + + const s = useFilterStore.getState(); + expect(s.selectedAccountId).toBe('own'); + expect(api.getSieveScripts).toHaveBeenCalledWith('own'); + expect(api.getSieveScriptContent).toHaveBeenCalledWith('b1', 'own'); + expect(s.rules).toHaveLength(1); + }); + + it('loads a shared account and drops the previous account\'s script first', async () => { + useFilterStore.setState({ rules: [makeRule()], activeScriptId: 'old', isOpaque: true, rawScript: 'x' }); + api.getSieveScripts.mockResolvedValue([]); + + await useFilterStore.getState().selectAccount('team'); + + const s = useFilterStore.getState(); + expect(api.isSieveSupported).toHaveBeenCalledWith('team'); + expect(api.getSieveScripts).toHaveBeenCalledWith('team'); + expect(s.selectedAccountId).toBe('team'); + expect(s.rules).toEqual([]); + expect(s.activeScriptId).toBeNull(); + expect(s.isOpaque).toBe(false); + }); + + it('reports an account without Sieve as unsupported', async () => { + api.isSieveSupported.mockReturnValue(false); + await useFilterStore.getState().selectAccount('team'); + expect(useFilterStore.getState().isSupported).toBe(false); + expect(api.getSieveScripts).not.toHaveBeenCalled(); + }); + + it('saves and validates into the selected account', async () => { + serveScript([makeRule()]); + await useFilterStore.getState().selectAccount('team'); + + await useFilterStore.getState().saveFilters(); + expect(api.updateSieveScript).toHaveBeenCalledWith('s1', expect.any(String), true, 'team'); + + await useFilterStore.getState().validateScript('keep;'); + expect(api.validateSieveScript).toHaveBeenCalledWith('keep;', 'team'); + }); + + it('creates the script in the selected account when it has none', async () => { + api.getSieveScripts.mockResolvedValue([]); + api.createSieveScript.mockResolvedValue({ id: 'new', name: 'filters', blobId: 'b', isActive: true }); + await useFilterStore.getState().selectAccount('team'); + + await useFilterStore.getState().saveFilters(); + expect(api.createSieveScript).toHaveBeenCalledWith('filters', expect.any(String), true, 'team'); + }); + + it('ignores a reply for an account the user already switched away from', async () => { + let releaseTeam: (v: unknown) => void = () => {}; + api.getSieveScripts.mockImplementation(async (accountId?: string) => { + if (accountId === 'team') { + await new Promise((r) => { releaseTeam = r; }); + return [{ id: 'team-script', name: 'filters', blobId: 'bt', isActive: true }]; + } + return []; + }); + + const teamLoad = useFilterStore.getState().selectAccount('team'); + await useFilterStore.getState().selectAccount(null); + releaseTeam(undefined); + await teamLoad; + + const s = useFilterStore.getState(); + expect(s.selectedAccountId).toBe('own'); + expect(s.activeScriptId).toBeNull(); + expect(api.getSieveScriptContent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/stores/__tests__/vacation-store.test.ts b/src/stores/__tests__/vacation-store.test.ts new file mode 100644 index 00000000..43d336e5 --- /dev/null +++ b/src/stores/__tests__/vacation-store.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../api/vacation', () => ({ + getVacationResponse: vi.fn(), + setVacationResponse: vi.fn(async () => undefined), + isVacationSupported: vi.fn(() => true), +})); + +import * as vacation from '../../api/vacation'; +import { useVacationStore } from '../vacation-store'; + +const api = vi.mocked(vacation); + +function responder(overrides: Partial = {}): vacation.VacationResponse { + return { + id: 'singleton', + isEnabled: false, + fromDate: null, + toDate: null, + subject: '', + textBody: '', + htmlBody: null, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + api.isVacationSupported.mockReturnValue(true); + useVacationStore.getState().reset(); +}); + +describe('vacation-store account scoping', () => { + it('loads and saves the own responder by default', async () => { + api.getVacationResponse.mockResolvedValue(responder({ isEnabled: true, subject: 'Away' })); + await useVacationStore.getState().fetch(); + expect(api.getVacationResponse).toHaveBeenCalledWith(undefined); + expect(useVacationStore.getState()).toMatchObject({ accountId: null, isEnabled: true, subject: 'Away' }); + + await useVacationStore.getState().save({ isEnabled: false }); + expect(api.setVacationResponse).toHaveBeenCalledWith({ isEnabled: false }, undefined); + }); + + it('loads and saves a shared account\'s responder', async () => { + api.getVacationResponse.mockResolvedValue(responder({ subject: 'Team away' })); + await useVacationStore.getState().fetch('team'); + expect(api.isVacationSupported).toHaveBeenCalledWith('team'); + expect(api.getVacationResponse).toHaveBeenCalledWith('team'); + expect(useVacationStore.getState()).toMatchObject({ accountId: 'team', subject: 'Team away' }); + + await useVacationStore.getState().save({ subject: 'Closed' }); + expect(api.setVacationResponse).toHaveBeenCalledWith({ subject: 'Closed' }, 'team'); + }); + + it('does not show the previous account\'s responder while another one loads', async () => { + api.getVacationResponse.mockResolvedValueOnce(responder({ isEnabled: true, subject: 'Mine' })); + await useVacationStore.getState().fetch(); + + api.getVacationResponse.mockReturnValueOnce(new Promise(() => {})); + void useVacationStore.getState().fetch('team'); + + expect(useVacationStore.getState()).toMatchObject({ + accountId: 'team', isEnabled: false, subject: '', hasLoaded: false, isLoading: true, + }); + }); + + it('ignores a reply for an account the user already switched away from', async () => { + let releaseTeam: (v: vacation.VacationResponse) => void = () => {}; + api.getVacationResponse.mockImplementation((accountId?: string) => + accountId === 'team' + ? new Promise((r) => { releaseTeam = r; }) + : Promise.resolve(responder({ subject: 'Mine' }))); + + const teamLoad = useVacationStore.getState().fetch('team'); + await useVacationStore.getState().fetch(); + releaseTeam(responder({ subject: 'Team away' })); + await teamLoad; + + expect(useVacationStore.getState()).toMatchObject({ accountId: null, subject: 'Mine' }); + }); +}); diff --git a/src/stores/filter-store.ts b/src/stores/filter-store.ts index 8065bc8a..3031071c 100644 --- a/src/stores/filter-store.ts +++ b/src/stores/filter-store.ts @@ -4,6 +4,7 @@ import { parseScript } from '../lib/sieve/parser'; import { generateScript } from '../lib/sieve/generator'; import { createSieveScript, + getSieveAccountId, getSieveCapabilities, getSieveScriptContent, getSieveScripts, @@ -14,7 +15,8 @@ import { // Ported from the webmail's stores/filter-store.ts. The mobile client is a // singleton (api/sieve drives `jmapClient` directly), so the actions drop the -// `client` argument the web store threads through. +// `client` argument the web store threads through. `selectedAccountId` is the +// Sieve account being edited: the user's own, or a shared/group account. interface FilterStore { rules: FilterRule[]; @@ -28,8 +30,11 @@ interface FilterStore { rawScript: string; vacationSettings: VacationSieveConfig | null; externalRequires: string[]; + selectedAccountId: string | null; - fetchFilters: () => Promise; + fetchFilters: (accountId?: string) => Promise; + /** Load another account's filters; null is the user's own Sieve account. */ + selectAccount: (accountId: string | null) => Promise; saveFilters: () => Promise; validateScript: (content: string) => Promise<{ isValid: boolean; errors?: string[] }>; addRule: (rule: FilterRule) => void; @@ -55,17 +60,24 @@ export const useFilterStore = create()((set, get) => ({ rawScript: '', vacationSettings: null, externalRequires: [], + selectedAccountId: null, - fetchFilters: async () => { - if (!isSieveSupported()) { + fetchFilters: async (accountId) => { + const requestedId = accountId || get().selectedAccountId || undefined; + if (!isSieveSupported(requestedId)) { set({ isSupported: false, isLoading: false }); return; } - set({ isLoading: true, error: null, isSupported: true }); + const resolvedId = requestedId ?? getSieveAccountId(); + set({ isLoading: true, error: null, isSupported: true, selectedAccountId: resolvedId }); + // A reply for an account the user already switched away from must not + // land in the store: the next save would write it into the other account. + const stale = () => get().selectedAccountId !== resolvedId; try { - set({ sieveCapabilities: getSieveCapabilities() }); + set({ sieveCapabilities: getSieveCapabilities(resolvedId) }); - const allScripts = await getSieveScripts(); + const allScripts = await getSieveScripts(resolvedId); + if (stale()) return; // Skip the server-managed 'vacation' script (RFC 9661 ยง4) - it can only // be modified via VacationResponse/set, not SieveScript/set. @@ -79,7 +91,8 @@ export const useFilterStore = create()((set, get) => ({ set({ activeScriptId: activeScript.id }); - const content = await getSieveScriptContent(activeScript.blobId); + const content = await getSieveScriptContent(activeScript.blobId, resolvedId); + if (stale()) return; set({ rawScript: content }); const result = parseScript(content); @@ -92,6 +105,7 @@ export const useFilterStore = create()((set, get) => ({ externalRequires: result.externalRequires, }); } catch (error) { + if (stale()) return; set({ isLoading: false, error: error instanceof Error ? error.message : 'Failed to fetch filters', @@ -99,19 +113,37 @@ export const useFilterStore = create()((set, get) => ({ } }, + selectAccount: async (accountId) => { + // Drop the previous account's script first so its rules never show (or + // get saved) under the newly selected account while the fetch runs. + set({ + selectedAccountId: accountId, + rules: [], + rawScript: '', + activeScriptId: null, + isOpaque: false, + vacationSettings: null, + externalRequires: [], + }); + await get().fetchFilters(accountId ?? undefined); + }, + saveFilters: async () => { set({ isSaving: true, error: null }); try { - const { isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires } = get(); + const { + isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires, selectedAccountId, + } = get(); + const accountId = selectedAccountId ?? undefined; const content = isOpaque ? rawScript : generateScript(rules, vacationSettings || undefined, { externalRequires }); if (activeScriptId) { - await updateSieveScript(activeScriptId, content, true); + await updateSieveScript(activeScriptId, content, true, accountId); } else { - const script = await createSieveScript('filters', content, true); + const script = await createSieveScript('filters', content, true, accountId); set({ activeScriptId: script.id }); } @@ -125,7 +157,8 @@ export const useFilterStore = create()((set, get) => ({ } }, - validateScript: async (content) => validateSieveScript(content), + validateScript: async (content) => + validateSieveScript(content, get().selectedAccountId ?? undefined), addRule: (rule) => { // Insert new bulwark rules before external/opaque rules so Bulwark's @@ -199,5 +232,6 @@ export const useFilterStore = create()((set, get) => ({ rawScript: '', vacationSettings: null, externalRequires: [], + selectedAccountId: null, }), })); diff --git a/src/stores/managed-account-store.ts b/src/stores/managed-account-store.ts new file mode 100644 index 00000000..314f7999 --- /dev/null +++ b/src/stores/managed-account-store.ts @@ -0,0 +1,32 @@ +import { create } from 'zustand'; + +// Ported from the webmail's stores/managed-account-store.ts. Tracks which +// account the settings screen is scoped to. `null` means the user's own +// account, the full settings list. When set to a shared/group account (picked +// under "Shared with me" in Account settings), Settings shows only the tabs +// that account supports behind a "Managing: " banner, and the filters +// and vacation panes read `managedAccountId` to target it. +// +// Session-only navigation state (never persisted), so a shared-account scope +// can't leak into another account or a later launch. + +export interface ManagedAccount { + id: string; + name: string; +} + +interface ManagedAccountState { + managedAccountId: string | null; + managedAccount: ManagedAccount | null; + /** Enter scoped mode for `account`, or pass `null` to return to the own account. */ + setManagedAccount: (account: ManagedAccount | null) => void; + clear: () => void; +} + +export const useManagedAccountStore = create((set) => ({ + managedAccountId: null, + managedAccount: null, + setManagedAccount: (account) => + set({ managedAccountId: account?.id ?? null, managedAccount: account }), + clear: () => set({ managedAccountId: null, managedAccount: null }), +})); diff --git a/src/stores/vacation-store.ts b/src/stores/vacation-store.ts index 95a3e983..0a476fc4 100644 --- a/src/stores/vacation-store.ts +++ b/src/stores/vacation-store.ts @@ -18,13 +18,16 @@ export interface VacationState { error: string | null; isSupported: boolean; hasLoaded: boolean; + /** Account the responder belongs to: null for the user's own, else a shared/group account. */ + accountId: string | null; - fetch: () => Promise; + fetch: (accountId?: string) => Promise; save: (updates: Partial>) => Promise; reset: () => void; } const INITIAL = { + accountId: null, isEnabled: false, fromDate: null, toDate: null, @@ -38,13 +41,20 @@ const INITIAL = { hasLoaded: false, }; -export const useVacationStore = create((set) => ({ +export const useVacationStore = create((set, get) => ({ ...INITIAL, - fetch: async () => { - set({ isLoading: true, error: null, isSupported: isVacationSupported() }); + fetch: async (accountId) => { + const target = accountId ?? null; + // Switching accounts starts from a blank form rather than showing (and + // letting the user save) the previous account's responder. + if (get().accountId !== target) set({ ...INITIAL, accountId: target }); + set({ isLoading: true, error: null, isSupported: isVacationSupported(accountId) }); + // A reply for an account the user already switched away from is dropped. + const stale = () => get().accountId !== target; try { - const vacation = await getVacationResponse(); + const vacation = await getVacationResponse(accountId); + if (stale()) return; set({ isEnabled: vacation.isEnabled, fromDate: vacation.fromDate, @@ -56,6 +66,7 @@ export const useVacationStore = create((set) => ({ hasLoaded: true, }); } catch (err) { + if (stale()) return; set({ isLoading: false, hasLoaded: true, @@ -67,7 +78,7 @@ export const useVacationStore = create((set) => ({ save: async (updates) => { set({ isSaving: true, error: null }); try { - await setVacationResponse(updates); + await setVacationResponse(updates, get().accountId ?? undefined); set((s) => ({ ...s, ...updates,