Files
native/src/lib/__tests__/push-config-mailboxes.test.ts
T

191 lines
8.0 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
// The push filter needs the Junk folders. It used to send its own full
// Mailbox/get (and the shared accounts') on every sign-in and start, next to
// the mail store's identical load (PF7).
// A group the user belongs to lists emailpush on 0.16.16+ and 1.0; a mailbox
// shared by ACL does on 0.16.16-0.16.23 only.
const { EMAIL_PUSH } = vi.hoisted(() => ({
EMAIL_PUSH: { 'urn:ietf:params:jmap:emailpush': {} },
}));
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
username: 'user@example.com',
serverUrl: 'https://mail.example.com',
accountId: 'jmap-primary',
currentSession: {
capabilities: EMAIL_PUSH,
accounts: {
'jmap-primary': { name: 'user', isPersonal: true, isReadOnly: false, accountCapabilities: EMAIL_PUSH },
team: { name: 'team', isPersonal: false, isReadOnly: false, accountCapabilities: EMAIL_PUSH },
'acl-b': { name: 'userb', isPersonal: false, isReadOnly: true, accountCapabilities: {} },
},
},
},
}));
vi.mock('../../api/email', () => ({
getMailboxes: vi.fn(async () => [{ id: 'junk-own', role: 'junk', accountId: 'jmap-primary' }]),
getSharedMailboxes: vi.fn(async () => []),
}));
import { getMailboxes, getSharedMailboxes } from '../../api/email';
import { EMPTY_NOTIFICATION_MUTES, type NotificationMutes } from '../notification-mutes';
import { buildEmailPushConfig } from '../push-notifications';
import { provideLoadedMailboxes } from '../mailbox-source';
import { generateAccountId } from '../account-utils';
const ACCOUNT_ID = generateAccountId('user@example.com', 'https://mail.example.com');
beforeEach(() => {
vi.clearAllMocks();
provideLoadedMailboxes(null);
});
describe('push filter folders', () => {
it('takes the Junk folders from the mail store instead of fetching them', async () => {
const provider = vi.fn(async () => [
{ id: 'junk', role: 'junk', accountId: 'jmap-primary' },
{ id: 'team:j', originalId: 'j', role: 'junk', accountId: 'team', isShared: true },
] as never);
provideLoadedMailboxes(provider);
const config = await buildEmailPushConfig();
expect(provider).toHaveBeenCalledWith(ACCOUNT_ID);
expect(getMailboxes).not.toHaveBeenCalled();
expect(getSharedMailboxes).not.toHaveBeenCalled();
expect(config['jmap-primary'].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: ['junk'] }],
});
expect(config.team.filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: ['j'] }],
});
});
it('leaves out an account that does not list emailpush (a mailbox shared by ACL on Stalwart 1.0)', async () => {
provideLoadedMailboxes(async () => [
{ id: 'junk', role: 'junk', accountId: 'jmap-primary' },
{ id: 'team:j', originalId: 'j', role: 'junk', accountId: 'team', isShared: true },
{ id: 'acl-b:inbox', originalId: 'inbox', role: 'inbox', accountId: 'acl-b', isShared: true },
] as never);
const config = await buildEmailPushConfig();
expect(Object.keys(config).sort()).toEqual(['jmap-primary', 'team']);
});
it('fetches them itself when the store has none for this account', async () => {
provideLoadedMailboxes(async () => null);
const config = await buildEmailPushConfig();
expect(getMailboxes).toHaveBeenCalledTimes(1);
expect(config['jmap-primary'].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: ['junk-own'] }],
});
});
});
describe('push filter for the Inbox only (bulwarkmail/webmail#983)', () => {
it("lets through only mail that lands in each account's Inbox", async () => {
provideLoadedMailboxes(async () => [
{ id: 'inbox', role: 'inbox', accountId: 'jmap-primary' },
{ id: 'junk', role: 'junk', accountId: 'jmap-primary' },
{ id: 'team:in', originalId: 'in', role: 'inbox', accountId: 'team', isShared: true },
] as never);
const config = await buildEmailPushConfig(EMPTY_NOTIFICATION_MUTES, true);
expect(config['jmap-primary'].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailbox: 'inbox' }],
});
expect(config.team.filter).toEqual({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailbox: 'in' }] });
});
it('silences an account whose Inbox is muted, and refuses to build without the own Inbox', async () => {
provideLoadedMailboxes(async () => [{ id: 'inbox', role: 'inbox', accountId: 'jmap-primary' }] as never);
const muted: NotificationMutes = { accounts: [], mailboxes: { 'jmap-primary/inbox': true } };
const config = await buildEmailPushConfig(muted, true);
expect(config['jmap-primary'].filter).toEqual({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }] });
provideLoadedMailboxes(async () => [{ id: 'junk', role: 'junk', accountId: 'jmap-primary' }] as never);
await expect(buildEmailPushConfig(EMPTY_NOTIFICATION_MUTES, true)).rejects.toThrow(/No Inbox/);
});
});
// Muted accounts and folders (#78) are left out by the server-side filter.
describe('push filter with muted folders and accounts (#78)', () => {
const folders = [
{ id: 'inbox', role: 'inbox', accountId: 'jmap-primary' },
{ id: 'junk', role: 'junk', accountId: 'jmap-primary' },
{ id: 'renovate', accountId: 'jmap-primary' },
{ id: 'team:in', originalId: 'in', role: 'inbox', accountId: 'team', isShared: true },
{ id: 'team:j', originalId: 'j', role: 'junk', accountId: 'team', isShared: true },
];
const mutes = (accounts: string[], mailboxes: Record<string, boolean> = {}): NotificationMutes => ({ accounts, mailboxes });
const junkOnly = (ids: string[]) => ({ operator: 'AND', conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: ids }] });
beforeEach(() => {
provideLoadedMailboxes(async () => folders as never);
});
it('changes nothing without mutes', async () => {
const config = await buildEmailPushConfig(EMPTY_NOTIFICATION_MUTES);
expect(config['jmap-primary'].filter).toEqual(junkOnly(['junk']));
expect(config.team.filter).toEqual(junkOnly(['j']));
});
it('adds a muted folder to the excluded mailboxes next to Junk', async () => {
const config = await buildEmailPushConfig(mutes([], { 'jmap-primary/renovate': true, 'jmap-primary/gone': true }));
expect(config['jmap-primary'].filter).toEqual(junkOnly(['junk', 'renovate']));
// Another account's folders are untouched.
expect(config.team.filter).toEqual(junkOnly(['j']));
});
it('silences a muted account entirely', async () => {
const config = await buildEmailPushConfig(mutes(['team']));
expect(config.team.filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }],
});
expect(config['jmap-primary'].filter).toEqual(junkOnly(['junk']));
});
it('lets a muted account notify for the folders unmuted again', async () => {
const one = await buildEmailPushConfig(mutes(['jmap-primary'], { 'jmap-primary/renovate': false }));
expect(one['jmap-primary'].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailbox: 'renovate' }],
});
const two = await buildEmailPushConfig(mutes(['jmap-primary'], {
'jmap-primary/renovate': false,
'jmap-primary/inbox': false,
// Gone from the server: must not end up in the filter.
'jmap-primary/deleted': false,
// Unmuting Junk does not bring spam back.
'jmap-primary/junk': false,
}));
expect(two['jmap-primary'].filter).toEqual({
operator: 'AND',
conditions: [
{ notKeyword: '$junk' },
{ operator: 'OR', conditions: [{ inMailbox: 'inbox' }, { inMailbox: 'renovate' }] },
],
});
});
it('fails instead of dropping the mutes when the folders cannot be read', async () => {
provideLoadedMailboxes(async () => null);
vi.mocked(getMailboxes).mockRejectedValueOnce(new Error('JMAP down'));
await expect(buildEmailPushConfig(mutes(['jmap-primary']))).rejects.toThrow(/JMAP down/);
});
});