fix: offer filters and auto-reply only for shared accounts that support them (bulwarkmail/webmail#1239)

This commit is contained in:
Linus Rath
2026-10-31 04:38:56 +01:00
parent af86339bf1
commit 67732348c2
5 changed files with 71 additions and 17 deletions
+20 -1
View File
@@ -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);
});
+17 -1
View File
@@ -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]: {} };
+5 -8
View File
@@ -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<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;
return accountSupports(account, sessionCapabilities, CAPABILITIES.SIEVE);
}
export function isSieveSupported(accountId?: string): boolean {
+5 -7
View File
@@ -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<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;
return accountSupports(account, sessionCapabilities, CAPABILITIES.VACATION);
}
export function isVacationSupported(accountId?: string): boolean {
+24
View File
@@ -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<string, unknown> | 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;
}