From 67732348c219536d05f9eb971fc56318501381da Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:38:56 +0100 Subject: [PATCH] fix: offer filters and auto-reply only for shared accounts that support them (bulwarkmail/webmail#1239) --- src/api/__tests__/sieve.test.ts | 21 ++++++++++++++++++++- src/api/__tests__/vacation.test.ts | 18 +++++++++++++++++- src/api/sieve.ts | 13 +++++-------- src/api/vacation.ts | 12 +++++------- src/lib/account-capability.ts | 24 ++++++++++++++++++++++++ 5 files changed, 71 insertions(+), 17 deletions(-) create mode 100644 src/lib/account-capability.ts diff --git a/src/api/__tests__/sieve.test.ts b/src/api/__tests__/sieve.test.ts index 80f39772..b3d4677e 100644 --- a/src/api/__tests__/sieve.test.ts +++ b/src/api/__tests__/sieve.test.ts @@ -43,11 +43,28 @@ beforeEach(() => { accounts: { own: { name: 'me', isPersonal: true, isReadOnly: false, accountCapabilities: { [CAPABILITIES.SIEVE]: SIEVE_CAPS } }, team: { name: 'Team', isPersonal: false, isReadOnly: false, accountCapabilities: { [CAPABILITIES.MAIL]: {} } }, + crew: { name: 'Crew', isPersonal: false, isReadOnly: false }, other: { name: 'Other', isPersonal: true, isReadOnly: false, accountCapabilities: { [CAPABILITIES.MAIL]: {} } }, }, }); }); +describe('accountSupportsSieve on a shared account that lists its capabilities (#1239)', () => { + it('is false when the list leaves this one out', () => { + const caps = { [CAPABILITIES.SIEVE]: {}, [CAPABILITIES.MAIL]: {} }; + expect(accountSupportsSieve( + { name: 'grp', isPersonal: false, isReadOnly: false, accountCapabilities: { [CAPABILITIES.MAIL]: {} } }, + caps, + )).toBe(false); + expect(accountSupportsSieve( + { name: 'grp', isPersonal: false, isReadOnly: false, accountCapabilities: { [CAPABILITIES.SIEVE]: {} } }, + caps, + )).toBe(true); + // An empty list says nothing: the server-wide answer stands. + expect(accountSupportsSieve({ name: 'grp', isPersonal: false, isReadOnly: false, accountCapabilities: {} }, caps)).toBe(true); + }); +}); + describe('accountSupportsSieve', () => { const caps = { [CAPABILITIES.SIEVE]: {} }; @@ -76,7 +93,9 @@ describe('accountSupportsSieve', () => { 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); + // Team lists its capabilities and Sieve isn't one (#1239); Crew lists none. + expect(isSieveSupported('team')).toBe(false); + expect(isSieveSupported('crew')).toBe(true); expect(isSieveSupported('other')).toBe(false); expect(isSieveSupported('missing')).toBe(false); }); diff --git a/src/api/__tests__/vacation.test.ts b/src/api/__tests__/vacation.test.ts index 24d36591..11e18874 100644 --- a/src/api/__tests__/vacation.test.ts +++ b/src/api/__tests__/vacation.test.ts @@ -30,12 +30,28 @@ beforeEach(() => { capabilities: { [CAPABILITIES.VACATION]: {} }, accounts: { own: { name: 'me', isPersonal: true, isReadOnly: false, accountCapabilities: { [CAPABILITIES.VACATION]: {} } }, - team: { name: 'Team', isPersonal: false, isReadOnly: false, accountCapabilities: { [CAPABILITIES.MAIL]: {} } }, + team: { name: 'Team', isPersonal: false, isReadOnly: false, accountCapabilities: { [CAPABILITIES.MAIL]: {}, [CAPABILITIES.VACATION]: {} } }, other: { name: 'Other', isPersonal: true, isReadOnly: false, accountCapabilities: { [CAPABILITIES.MAIL]: {} } }, }, }); }); +describe('accountSupportsVacation on a shared account that lists its capabilities (#1239)', () => { + it('is false when the list leaves this one out', () => { + const caps = { [CAPABILITIES.VACATION]: {}, [CAPABILITIES.MAIL]: {} }; + expect(accountSupportsVacation( + { name: 'grp', isPersonal: false, isReadOnly: false, accountCapabilities: { [CAPABILITIES.MAIL]: {} } }, + caps, + )).toBe(false); + expect(accountSupportsVacation( + { name: 'grp', isPersonal: false, isReadOnly: false, accountCapabilities: { [CAPABILITIES.VACATION]: {} } }, + caps, + )).toBe(true); + // An empty list says nothing: the server-wide answer stands. + expect(accountSupportsVacation({ name: 'grp', isPersonal: false, isReadOnly: false, accountCapabilities: {} }, caps)).toBe(true); + }); +}); + describe('accountSupportsVacation', () => { const caps = { [CAPABILITIES.VACATION]: {} }; diff --git a/src/api/sieve.ts b/src/api/sieve.ts index 6b3b3522..8225bdc0 100644 --- a/src/api/sieve.ts +++ b/src/api/sieve.ts @@ -2,6 +2,7 @@ import { jmapClient } from './jmap-client'; import { CAPABILITIES, type JMAPAccountInfo } from './types'; import { secureFetch } from '../lib/client-cert'; import type { SieveScript, SieveCapabilities } from '../lib/sieve/types'; +import { accountSupports } from '../lib/account-capability'; // JMAP Sieve (RFC 9661) bindings. Mirrors the webmail JMAPClient Sieve methods // but follows the mobile convention of a functional api/* module driving the @@ -30,18 +31,14 @@ export function getSieveAccountId(): string { // 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. +// fails every SieveScript call. A shared account that lists no capabilities +// at all, and a server that populates none, keep the session-wide answer +// (lib/account-capability, webmail #1239). 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; + return accountSupports(account, sessionCapabilities, CAPABILITIES.SIEVE); } export function isSieveSupported(accountId?: string): boolean { diff --git a/src/api/vacation.ts b/src/api/vacation.ts index a7c629a7..4faf7e7e 100644 --- a/src/api/vacation.ts +++ b/src/api/vacation.ts @@ -1,5 +1,6 @@ import { jmapClient } from './jmap-client'; import { CAPABILITIES, type JMAPAccountInfo } from './types'; +import { accountSupports } from '../lib/account-capability'; export interface VacationResponse { id: string; @@ -65,17 +66,14 @@ export async function setVacationResponse( // 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. +// A shared account that lists no capabilities at all, and a server that +// populates none, keep the session-wide answer (lib/account-capability, +// webmail #1239). 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; + return accountSupports(account, sessionCapabilities, CAPABILITIES.VACATION); } export function isVacationSupported(accountId?: string): boolean { diff --git a/src/lib/account-capability.ts b/src/lib/account-capability.ts new file mode 100644 index 00000000..f027b3a6 --- /dev/null +++ b/src/lib/account-capability.ts @@ -0,0 +1,24 @@ +import type { JMAPAccountInfo } from '../api/types'; + +/** + * Whether the user may use a capability's methods with an account (webmail + * JMAPClient.accountSupports, #1239). The account's own accountCapabilities + * decide (RFC 8620 2): a server can advertise a capability while an account + * has the matching rights revoked. Some shared accounts list no capabilities + * at all, so a shared account (isPersonal false) with an empty list falls + * back to the server-wide capability, as does a server that populates no + * accountCapabilities anywhere. A shared account that lists capabilities but + * not this one used to count as capable. + */ +export function accountSupports( + account: JMAPAccountInfo | undefined, + sessionCapabilities: Record | undefined, + capability: string, +): boolean { + if (!sessionCapabilities || !(capability in sessionCapabilities)) return false; + if (!account) return false; + const caps = account.accountCapabilities; + if (caps && capability in caps) return true; + if (caps && Object.keys(caps).length > 0) return false; + return true; +}