mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
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:
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user