feat: manage filters and vacation of shared and group accounts

The phone only ever read and wrote Sieve scripts and the vacation
responder of the user's own account, and the "Shared with me" list in
Account settings said to use the webmail for the rest. Port the
webmail's scoped settings (4c6c1aab) for these two panes:

- `api/sieve.ts` and `api/vacation.ts` take an optional account id and
  gate on the account's own `accountCapabilities` for
  `urn:ietf:params:jmap:sieve` and `urn:ietf:params:jmap:vacationresponse`,
  treating non-personal accounts as capable because Stalwart doesn't
  always advertise capabilities on them. Script blobs are uploaded and
  downloaded against the same account.
- Tapping a shared account in Account settings scopes Settings to it:
  only the Filters and Vacation Responder panes it supports are listed,
  behind a "Managing: <name>" banner that leads back to the own account.
  The scope is dropped when the signed-in account changes.
- The filter and vacation stores remember which account they hold,
  reset when it changes and drop replies for an account the user has
  already left, so one account's rules are never saved into another.
- The rule editor offers the managed account's folders as targets, and
  the "vacation active" banner only shows for the own account.
This commit is contained in:
Linus Rath
2026-09-24 19:01:24 +02:00
parent f63d739fb3
commit a86cd1d0eb
15 changed files with 797 additions and 89 deletions
+1 -1
View File
@@ -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"
},
+134
View File
@@ -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<typeof vi.fn>;
const mockFetch = secureFetch as unknown as ReturnType<typeof vi.fn>;
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' });
});
});
+92
View File
@@ -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<typeof vi.fn>;
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');
});
});
+53 -23
View File
@@ -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<string, unknown> | 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<SieveScript[]> {
export async function getSieveScripts(accountId: string = getSieveAccountId()): Promise<SieveScript[]> {
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<SieveScript[]> {
throw new Error('Failed to fetch Sieve scripts');
}
export async function getSieveScriptContent(blobId: string): Promise<string> {
export async function getSieveScriptContent(
blobId: string,
accountId: string = getSieveAccountId(),
): Promise<string> {
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<string> {
return response.text();
}
async function uploadSieveBlob(content: string): Promise<string> {
async function uploadSieveBlob(content: string, accountId: string): Promise<string> {
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<string> {
const raw = (await response.json()) as Record<string, unknown>;
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<SieveScript> {
const blobId = await uploadSieveBlob(content);
const accountId = getSieveAccountId();
const blobId = await uploadSieveBlob(content, accountId);
const setArgs: Record<string, unknown> = {
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<void> {
const blobId = await uploadSieveBlob(content);
const accountId = getSieveAccountId();
const blobId = await uploadSieveBlob(content, accountId);
const setArgs: Record<string, unknown> = {
accountId,
@@ -154,9 +180,12 @@ export async function updateSieveScript(
throw new Error('Failed to update Sieve script');
}
export async function deleteSieveScript(scriptId: string): Promise<void> {
export async function deleteSieveScript(
scriptId: string,
accountId: string = getSieveAccountId(),
): Promise<void> {
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<void> {
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];
+28 -7
View File
@@ -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<VacationResponse> {
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<VacationResponse> {
const res = await jmapClient.request(
[['VacationResponse/get', { accountId, ids: ['singleton'] }, '0']],
VACATION_USING,
@@ -39,8 +43,8 @@ export async function getVacationResponse(): Promise<VacationResponse> {
export async function setVacationResponse(
updates: Partial<Omit<VacationResponse, 'id'>>,
accountId: string = jmapClient.accountId,
): Promise<void> {
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<string, unknown> | 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,
);
}
+38 -13
View File
@@ -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<string | null>(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 && (
<SettingsSection
title={t('settings.account.shared_accounts.title', 'Shared with me')}
description={t('settings.account.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={t('settings.account.shared_accounts.description_manage', 'Group and shared accounts you can read through this account. Select one to manage its filters and vacation responder.')}
>
<View style={styles.list}>
{sharedAccounts.map((s, index) => (
<View key={s.id} style={[styles.row, index > 0 && styles.rowBorder]}>
<View style={[styles.avatar, { backgroundColor: c.muted }]}>
<Users size={14} color={c.mutedForeground} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.rowName} numberOfLines={1}>{s.name}</Text>
<Text style={styles.rowSub}>{t('settings.account.shared_accounts.shared_label', 'Shared account')}</Text>
</View>
</View>
))}
{sharedAccounts.map((s, index) => {
const firstTab = sharedAccountSettingsTabs(s.id)[0];
return (
<Pressable
key={s.id}
onPress={firstTab ? () => manageShared(s, firstTab) : undefined}
disabled={!firstTab}
accessibilityRole={firstTab ? 'button' : undefined}
style={({ pressed }) => [
styles.row,
index > 0 && styles.rowBorder,
pressed && firstTab && { backgroundColor: c.muted },
]}
>
<View style={[styles.avatar, { backgroundColor: c.muted }]}>
<Users size={14} color={c.mutedForeground} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.rowName} numberOfLines={1}>{s.name}</Text>
<Text style={styles.rowSub}>{t('settings.account.shared_accounts.shared_label', 'Shared account')}</Text>
</View>
{firstTab && <ChevronRight size={16} color={c.mutedForeground} />}
</Pressable>
);
})}
</View>
</SettingsSection>
)}
+40 -8
View File
@@ -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<Mailbox[]>([]);
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(
+6 -2
View File
@@ -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<string | null>(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;
+35 -5
View File
@@ -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;
}
+74 -12
View File
@@ -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<Tab> | 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<ReadonlySet<Tab> | null>(() => {
void session; // dependency: capabilities come from the live session
return managedAccount ? new Set<Tab>(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<Tab>();
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/<tab> deep link parks its target here; open it once.
}, [scopedTabs, hasCalendar, hasContacts, hasFiles, hasSieve, hasVacation]);
// A settings/<tab> 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 ? (
<Pressable
onPress={leaveScope}
accessibilityRole="button"
style={({ pressed }) => [styles.scopeBanner, pressed && styles.scopeBannerPressed]}
>
<ArrowLeft size={16} color={c.mutedForeground} />
<Text style={styles.scopeBack}>{t('settings.scoped.back', 'Back to my account')}</Text>
<Text style={styles.scopeName} numberOfLines={1}>
{t('settings.scoped.managing', 'Managing: {name}', { name: managedAccount.name })}
</Text>
</Pressable>
) : null;
const handleTabPress = (tab: TabDef) => {
if (!tab.implemented) return;
@@ -242,6 +285,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
</View>
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
{scopeBanner}
{selectedTab === 'filters' ? (
<FilterSettings
onOpenVacation={hasVacation ? () => 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 */}
<ScrollView style={styles.scrollArea} contentContainerStyle={styles.scrollContent}>
{scopeBanner && <View style={styles.scopeBannerList}>{scopeBanner}</View>}
<View style={styles.tabList}>
{groupedTabs.map((group, groupIndex) => (
<View key={group.group}>
@@ -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,
+120
View File
@@ -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> = {}): 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();
});
});
@@ -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> = {}): 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' });
});
});
+46 -12
View File
@@ -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<void>;
fetchFilters: (accountId?: string) => Promise<void>;
/** Load another account's filters; null is the user's own Sieve account. */
selectAccount: (accountId: string | null) => Promise<void>;
saveFilters: () => Promise<void>;
validateScript: (content: string) => Promise<{ isValid: boolean; errors?: string[] }>;
addRule: (rule: FilterRule) => void;
@@ -55,17 +60,24 @@ export const useFilterStore = create<FilterStore>()((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<FilterStore>()((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<FilterStore>()((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<FilterStore>()((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<FilterStore>()((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<FilterStore>()((set, get) => ({
rawScript: '',
vacationSettings: null,
externalRequires: [],
selectedAccountId: null,
}),
}));
+32
View File
@@ -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: <name>" 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<ManagedAccountState>((set) => ({
managedAccountId: null,
managedAccount: null,
setManagedAccount: (account) =>
set({ managedAccountId: account?.id ?? null, managedAccount: account }),
clear: () => set({ managedAccountId: null, managedAccount: null }),
}));
+17 -6
View File
@@ -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<void>;
fetch: (accountId?: string) => Promise<void>;
save: (updates: Partial<Omit<VacationResponse, 'id'>>) => Promise<void>;
reset: () => void;
}
const INITIAL = {
accountId: null,
isEnabled: false,
fromDate: null,
toDate: null,
@@ -38,13 +41,20 @@ const INITIAL = {
hasLoaded: false,
};
export const useVacationStore = create<VacationState>((set) => ({
export const useVacationStore = create<VacationState>((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<VacationState>((set) => ({
hasLoaded: true,
});
} catch (err) {
if (stale()) return;
set({
isLoading: false,
hasLoaded: true,
@@ -67,7 +78,7 @@ export const useVacationStore = create<VacationState>((set) => ({
save: async (updates) => {
set({ isSaving: true, error: null });
try {
await setVacationResponse(updates);
await setVacationResponse(updates, get().accountId ?? undefined);
set((s) => ({
...s,
...updates,