Files
native/src/lib/__tests__/push-background-task.test.ts
T

837 lines
36 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
accountId: 'jmap-primary',
getStoredCredentials: vi.fn(async () => null),
setStoredCredentials: vi.fn(async () => undefined),
},
}));
vi.mock('../client-cert', () => ({ secureFetch: vi.fn(async () => ({ ok: false, status: 500 })) }));
vi.mock('../oauth', () => ({ refreshOAuthAccessToken: vi.fn(async (t: unknown) => t) }));
import AsyncStorage from '@react-native-async-storage/async-storage';
import { NativeModules } from 'react-native';
import { jmapClient } from '../../api/jmap-client';
import { secureFetch } from '../client-cert';
import {
carriesNoMail,
handleNotificationAction,
matchAccountsForPush,
parseRelayPushData,
pushBackgroundTask,
selectNotifiableEmails,
senderFaviconsAllowed,
} from '../push-background-task';
import type { Email } from '../../api/types';
describe('parseRelayPushData', () => {
it('decodes the relay FCM data payload (all values are strings)', () => {
const parsed = parseRelayPushData({
kind: 'jmap-email-push',
accountLabel: 'alice',
accountId: 'a1',
emailIds: JSON.stringify(['m1', 'm2']),
changed: JSON.stringify({ a1: { EmailDelivery: 's1' } }),
});
expect(parsed).toEqual({
kind: 'jmap-email-push',
accountLabel: 'alice',
jmapAccountId: 'a1',
emailIds: ['m1', 'm2'],
changed: { a1: { EmailDelivery: 's1' } },
});
});
it('falls back to the first key of `changed` for the account id', () => {
const parsed = parseRelayPushData({
kind: 'jmap-state-change',
emailIds: '[]',
changed: JSON.stringify({ a9: { Email: 'x' } }),
});
expect(parsed.jmapAccountId).toBe('a9');
expect(parsed.emailIds).toEqual([]);
});
it('tolerates garbage', () => {
expect(parseRelayPushData(null).emailIds).toEqual([]);
expect(parseRelayPushData({ emailIds: '{not json' }).emailIds).toEqual([]);
expect(parseRelayPushData({ kind: 'weird' }).kind).toBeNull();
});
});
describe('matchAccountsForPush', () => {
const accounts = ['alice@mail.example.com', 'bob@mail.example.com'];
const registry = [
{ id: 'alice@mail.example.com', username: 'alice' },
{ id: 'bob@mail.example.com', username: 'bob' },
];
it('matches on the recorded JMAP account id first', () => {
const payload = parseRelayPushData({ accountId: 'jb', accountLabel: 'bob', emailIds: '["m1"]' });
expect(matchAccountsForPush(payload, accounts, { 'bob@mail.example.com': 'jb' }, registry))
.toEqual({ accountIds: ['bob@mail.example.com'], ambiguous: false });
// A payload without a label (an older relay) goes by the id alone.
const unlabelled = parseRelayPushData({ accountId: 'jb', emailIds: '["m1"]' });
expect(matchAccountsForPush(unlabelled, accounts, { 'bob@mail.example.com': 'jb' }, registry))
.toEqual({ accountIds: ['bob@mail.example.com'], ambiguous: false });
});
it("does not take another login's group push for the one whose own id it carries", () => {
// Bob's group account "c" on his server; Alice's own account is "c" on hers.
const payload = parseRelayPushData({ accountId: 'c', accountLabel: 'bob', emailIds: '["m1"]' });
expect(matchAccountsForPush(payload, accounts, { 'alice@mail.example.com': 'c', 'bob@mail.example.com': 'b' }, registry))
.toMatchObject({ ambiguous: true });
});
it('falls back to the relay accountLabel (username)', () => {
const payload = parseRelayPushData({ accountId: 'unknown', accountLabel: 'alice' });
expect(matchAccountsForPush(payload, accounts, {}, registry).accountIds).toEqual(['alice@mail.example.com']);
});
it('checks every account when nothing matches', () => {
const payload = parseRelayPushData({ accountId: 'unknown', accountLabel: 'carol' });
expect(matchAccountsForPush(payload, accounts, {}, registry)).toEqual({ accountIds: accounts, ambiguous: false });
});
describe('with two logins on different servers that share a JMAP account id', () => {
const logins = ['alice@one.example.com', 'bob@two.example.net'];
const ids = { 'alice@one.example.com': 'b', 'bob@two.example.net': 'b' };
const users = [
{ id: 'alice@one.example.com', username: 'alice' },
{ id: 'bob@two.example.net', username: 'bob' },
];
it('picks the login the relay label names', () => {
const payload = parseRelayPushData({ accountId: 'b', accountLabel: 'bob', emailIds: '["m1"]' });
expect(matchAccountsForPush(payload, logins, ids, users)).toEqual({ accountIds: ['bob@two.example.net'], ambiguous: false });
});
it('calls a push naming messages ambiguous when the label does not tell them apart', () => {
const sameName = [
{ id: 'alice@one.example.com', username: 'alice' },
{ id: 'alice@two.example.net', username: 'alice' },
];
const both = ['alice@one.example.com', 'alice@two.example.net'];
const map = { 'alice@one.example.com': 'b', 'alice@two.example.net': 'b' };
const payload = parseRelayPushData({ accountId: 'b', accountLabel: 'alice', emailIds: '["m1"]' });
expect(matchAccountsForPush(payload, both, map, sameName)).toEqual({ accountIds: both, ambiguous: true });
// Without ids each login only looks at its own newest mail.
const legacy = parseRelayPushData({ accountId: 'b', accountLabel: 'alice', changed: '{"b":{"Email":"s"}}' });
expect(matchAccountsForPush(legacy, both, map, sameName).ambiguous).toBe(false);
});
});
});
describe('pushBackgroundTask notifications', () => {
const LOCAL = 'alice@mail.example.com';
const showNotification = vi.fn(async () => undefined);
let emailGetAccount: string | null = null;
beforeEach(async () => {
vi.clearAllMocks();
await AsyncStorage.clear();
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([LOCAL]));
await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({ [LOCAL]: 'jmap-primary' }));
(NativeModules as Record<string, unknown>).BulwarkFcm = { showNotification };
(jmapClient.getStoredCredentials as ReturnType<typeof vi.fn>).mockResolvedValue({
serverUrl: 'https://mail.example.com',
username: 'alice',
password: 'secret',
});
emailGetAccount = null;
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, init?: { body?: string }) => {
if (url.endsWith('/.well-known/jmap')) {
return {
ok: true,
json: async () => ({
apiUrl: 'https://mail.example.com/jmap/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
accounts: { 'jmap-primary': {}, team: {} },
}),
};
}
const [[, args]] = JSON.parse(init?.body ?? '{}').methodCalls;
emailGetAccount = args.accountId;
return {
ok: true,
json: async () => ({
methodResponses: [[
'Email/get',
{ list: [{ id: 'm1', threadId: 't1', keywords: {}, subject: 'Hi', from: [{ email: 'bob@example.com' }] }] },
'0',
]],
}),
};
});
});
afterEach(() => {
delete (NativeModules as Record<string, unknown>).BulwarkFcm;
});
it('tags a group mailbox notification with the JMAP account the message lives in (#839)', async () => {
await pushBackgroundTask({
kind: 'jmap-email-push',
accountLabel: 'alice',
accountId: 'team',
emailIds: JSON.stringify(['m1']),
});
expect(emailGetAccount).toBe('team');
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ emailId: 'm1', accountId: LOCAL, jmapAccountId: 'team' }),
);
});
it("shows a plain notification, not another server's message, when two logins fit the push", async () => {
// A second login named alice, on another server whose account id is the same.
const SECOND = 'alice@other.example.net';
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([LOCAL, SECOND]));
await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({ [LOCAL]: 'jmap-primary', [SECOND]: 'jmap-primary' }));
await pushBackgroundTask({
kind: 'jmap-email-push',
accountLabel: 'alice',
accountId: 'jmap-primary',
emailIds: JSON.stringify(['m1']),
});
expect(emailGetAccount).toBeNull();
expect(showNotification).toHaveBeenCalledTimes(1);
const shown = (showNotification.mock.calls[0] as unknown[])[0] as Record<string, unknown>;
expect(shown).toMatchObject({ title: 'New email', emailId: '', threadId: '' });
expect(shown.accountId).toBeUndefined();
expect(shown.subject).toBeUndefined();
});
it('tags the user\'s own mail with the primary account', async () => {
await pushBackgroundTask({
kind: 'jmap-email-push',
accountLabel: 'alice',
accountId: 'jmap-primary',
emailIds: JSON.stringify(['m1']),
});
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ emailId: 'm1', accountId: LOCAL, jmapAccountId: 'jmap-primary' }),
);
});
// #85: Delete only moves to Trash, so with a Trash it needs no unlock and
// the shade stays open; without one the unlock stays.
it.each([
['without unlocking when the account has a Trash', [{ id: 'tr', role: 'trash' }], false],
['behind the unlock when the account has no Trash', [{ id: 'in', role: 'inbox' }], true],
])('offers Delete %s', async (_label, mailboxes, needsUnlock) => {
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, init?: { body?: string }) => {
if (url.endsWith('/.well-known/jmap')) {
return {
ok: true,
json: async () => ({
apiUrl: 'https://mail.example.com/jmap/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
accounts: { 'jmap-primary': {} },
}),
};
}
const calls = JSON.parse(init?.body ?? '{}').methodCalls as Array<[string, Record<string, unknown>, string]>;
return {
ok: true,
json: async () => ({
methodResponses: calls.map(([name, , id]) => (name === 'Mailbox/get'
? [name, { list: mailboxes }, id]
: [name, { list: [{ id: 'm1', threadId: 't1', keywords: {}, subject: 'Hi', from: [{ email: 'bob@example.com' }] }] }, id])),
}),
};
});
await pushBackgroundTask({ kind: 'jmap-email-push', accountLabel: 'alice', accountId: 'jmap-primary', emailIds: '["m1"]' });
expect(showNotification).toHaveBeenCalledWith(expect.objectContaining({ emailId: 'm1', deleteNeedsUnlock: needsUnlock }));
});
it('shows a generic notification when the lookup fails, not none (#1041)', async () => {
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => {
if (url.endsWith('/.well-known/jmap')) return { ok: false, status: 503 };
throw new Error('unexpected');
});
await pushBackgroundTask({ kind: 'jmap-email-push', accountLabel: 'alice', accountId: 'jmap-primary', emailIds: '["m1"]' });
expect(showNotification).toHaveBeenCalledTimes(1);
expect((showNotification.mock.calls[0] as unknown[])[0]).toMatchObject({
title: 'New email',
body: 'alice',
emailId: '',
threadId: '',
groupKey: `bulwark-mail:${LOCAL}`,
});
});
it('takes a method error for a failed lookup, not for "nothing new" (#1041)', async () => {
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => {
if (url.endsWith('/.well-known/jmap')) {
return { ok: true, json: async () => ({ apiUrl: 'https://mail.example.com/jmap/', primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' } }) };
}
return { ok: true, json: async () => ({ methodResponses: [['error', { type: 'serverFail' }, '0']] }) };
});
await pushBackgroundTask({ kind: 'jmap-state-change', accountLabel: 'alice', changed: '{"jmap-primary":{"EmailDelivery":"s"}}' });
expect(showNotification).toHaveBeenCalledTimes(1);
expect((showNotification.mock.calls[0] as unknown[])[0]).toMatchObject({ title: 'New email', emailId: '' });
});
it('stays quiet when the lookup worked and found nothing new', async () => {
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => {
if (url.endsWith('/.well-known/jmap')) {
return { ok: true, json: async () => ({ apiUrl: 'https://mail.example.com/jmap/', primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' } }) };
}
return { ok: true, json: async () => ({ methodResponses: [['Email/get', { list: [{ id: 'm1', keywords: { $seen: true } }] }, '0']] }) };
});
await pushBackgroundTask({ kind: 'jmap-email-push', accountLabel: 'alice', accountId: 'jmap-primary', emailIds: '["m1"]' });
expect(showNotification).not.toHaveBeenCalled();
});
it("says a message has no subject in the app's language", async () => {
await AsyncStorage.setItem('webmail:locale:v1', JSON.stringify({ override: 'de' }));
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => ({
ok: true,
json: async () => (url.endsWith('/.well-known/jmap')
? {
apiUrl: 'https://mail.example.com/jmap/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
accounts: { 'jmap-primary': {} },
}
: {
methodResponses: [[
'Email/get',
{ list: [{ id: 'm1', threadId: 't1', keywords: {}, subject: '', from: [{ email: 'bob@example.com' }] }] },
'0',
]],
}),
}));
await pushBackgroundTask({
kind: 'jmap-email-push',
accountLabel: 'alice',
accountId: 'jmap-primary',
emailIds: JSON.stringify(['m1']),
});
expect(showNotification).toHaveBeenCalledWith(expect.objectContaining({ emailId: 'm1', body: '(Kein Betreff)' }));
});
it('includes preview and action labels on notification', async () => {
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => ({
ok: true,
json: async () => (url.endsWith('/.well-known/jmap')
? {
apiUrl: 'https://mail.example.com/jmap/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
accounts: { 'jmap-primary': {} },
}
: {
methodResponses: [[
'Email/get',
{ list: [{ id: 'm1', threadId: 't1', keywords: {}, subject: 'Hello', preview: 'World snippet', from: [{ name: 'Bob', email: 'bob@example.com' }] }] },
'0',
]],
}),
}));
await pushBackgroundTask({
kind: 'jmap-email-push',
accountLabel: 'alice',
accountId: 'jmap-primary',
emailIds: JSON.stringify(['m1']),
});
expect(showNotification).toHaveBeenCalledWith(expect.objectContaining({
emailId: 'm1',
title: 'Bob',
body: 'Hello',
preview: 'World snippet',
markReadLabel: expect.any(String),
deleteLabel: expect.any(String),
replyLabel: expect.any(String),
}));
});
it('drops a leading style sheet from the notification preview (webmail #1136)', async () => {
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => ({
ok: true,
json: async () => (url.endsWith('/.well-known/jmap')
? {
apiUrl: 'https://mail.example.com/jmap/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
accounts: { 'jmap-primary': {} },
}
: {
methodResponses: [[
'Email/get',
{ list: [{ id: 'm1', threadId: 't1', keywords: {}, subject: 'Sale', preview: '@media screen and (min-width:600px){.hide{display:none!important}} Our autumn sale\nstarts today', from: [{ name: 'Shop', email: 'news@shop.example' }] }] },
'0',
]],
}),
}));
await pushBackgroundTask({
kind: 'jmap-email-push',
accountLabel: 'alice',
accountId: 'jmap-primary',
emailIds: JSON.stringify(['m1']),
});
expect(showNotification).toHaveBeenCalledWith(expect.objectContaining({
emailId: 'm1',
preview: 'Our autumn sale starts today',
}));
});
it('handles markRead action via detached JMAP', async () => {
const postCalls: any[] = [];
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, opts?: any) => {
if (opts?.body) {
postCalls.push(JSON.parse(opts.body));
}
return {
ok: true,
json: async () => (url.endsWith('/.well-known/jmap')
? {
apiUrl: 'https://mail.example.com/jmap/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
accounts: { 'jmap-primary': {} },
}
: { methodResponses: [['Email/set', { updated: { m1: {} } }, '0']] }),
};
});
await handleNotificationAction({
action: 'markRead',
emailId: 'm1',
accountId: LOCAL,
});
expect(postCalls.length).toBe(1);
expect(postCalls[0].methodCalls).toEqual([
['Email/set', { accountId: 'jmap-primary', update: { m1: { 'keywords/$seen': true } } }, '0'],
]);
});
it('handles delete action by moving email to trash', async () => {
const postCalls: any[] = [];
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, opts?: any) => {
if (opts?.body) {
postCalls.push(JSON.parse(opts.body));
}
return {
ok: true,
json: async () => (url.endsWith('/.well-known/jmap')
? {
apiUrl: 'https://mail.example.com/jmap/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
accounts: { 'jmap-primary': {} },
}
: {
methodResponses: [
['Mailbox/get', { list: [{ id: 'trash-box', role: 'trash' }] }, '0'],
['Email/set', { updated: { m1: {} } }, '0'],
],
}),
};
});
await handleNotificationAction({
'bulwark.notification.action': 'delete',
'bulwark.notification.emailId': 'm1',
'bulwark.notification.accountId': LOCAL,
});
expect(postCalls.length).toBe(2);
expect(postCalls[1].methodCalls).toEqual([
['Email/set', { accountId: 'jmap-primary', update: { m1: { mailboxIds: { 'trash-box': true } } } }, '0'],
]);
});
it.each([
['trash-and-read', { mailboxIds: { 'trash-box': true }, 'keywords/$seen': true }],
['trash', { mailboxIds: { 'trash-box': true } }],
// Never destroyed from a notification (#68), but not marked read either.
['permanent', { mailboxIds: { 'trash-box': true } }],
])('follows the delete action setting "%s" (#86)', async (deleteAction, patch) => {
await AsyncStorage.setItem('webmail:settings:v1', JSON.stringify({ deleteAction }));
const postCalls: any[] = [];
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, opts?: any) => {
if (opts?.body) postCalls.push(JSON.parse(opts.body));
return {
ok: true,
json: async () => (url.endsWith('/.well-known/jmap')
? {
apiUrl: 'https://mail.example.com/jmap/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
accounts: { 'jmap-primary': {} },
}
: {
methodResponses: [
['Mailbox/get', { list: [{ id: 'trash-box', role: 'trash' }] }, '0'],
['Email/set', { updated: { m1: {} } }, '0'],
],
}),
};
});
await handleNotificationAction({ action: 'delete', emailId: 'm1', accountId: LOCAL });
expect(postCalls[1].methodCalls).toEqual([
['Email/set', { accountId: 'jmap-primary', update: { m1: patch } }, '0'],
]);
});
it.each([
['has no trash mailbox', ['Mailbox/get', { list: [{ id: 'inbox', role: 'inbox' }] }, '0']],
['cannot list mailboxes', ['error', { type: 'accountNotFound' }, '0']],
])('never destroys from a notification when the account %s', async (_label, mailboxResponse) => {
const postCalls: any[] = [];
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, opts?: any) => {
if (opts?.body) postCalls.push(JSON.parse(opts.body));
return {
ok: true,
json: async () => (url.endsWith('/.well-known/jmap')
? {
apiUrl: 'https://mail.example.com/jmap/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
accounts: { 'jmap-primary': {} },
}
: { methodResponses: [mailboxResponse] }),
};
});
await handleNotificationAction({ action: 'delete', emailId: 'm1', accountId: LOCAL, jmapAccountId: 'shared' });
expect(postCalls).toHaveLength(1);
expect(postCalls[0].methodCalls[0][0]).toBe('Mailbox/get');
expect(postCalls[0].methodCalls[0][1].accountId).toBe('shared');
});
});
describe('pushes for device sync (#34)', () => {
const LOCAL = 'alice@mail.example.com';
const showNotification = vi.fn(async () => undefined);
const stateChange = (changed: Record<string, Record<string, string>>) => ({
kind: 'jmap-state-change',
accountLabel: 'alice',
accountId: Object.keys(changed)[0],
emailIds: '[]',
changed: JSON.stringify(changed),
});
beforeEach(async () => {
vi.clearAllMocks();
await AsyncStorage.clear();
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([LOCAL]));
await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({ [LOCAL]: 'jmap-primary' }));
(NativeModules as Record<string, unknown>).BulwarkFcm = { showNotification };
(jmapClient.getStoredCredentials as ReturnType<typeof vi.fn>).mockResolvedValue({
serverUrl: 'https://mail.example.com',
username: 'alice',
password: 'secret',
});
(secureFetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: false, status: 500 });
});
afterEach(() => {
delete (NativeModules as Record<string, unknown>).BulwarkFcm;
});
it('tells contact and calendar changes from new mail', () => {
expect(carriesNoMail(parseRelayPushData(stateChange({ a: { ContactCard: 's1', AddressBook: 's2' } })))).toBe(true);
expect(carriesNoMail(parseRelayPushData(stateChange({ a: { CalendarEvent: 'e1' }, b: { Calendar: 'c1' } })))).toBe(true);
// Mail in any account of the map, an EmailPush, or a map it can't read: as before.
expect(carriesNoMail(parseRelayPushData(stateChange({ a: { ContactCard: 's1' }, b: { EmailDelivery: 'm1' } })))).toBe(false);
expect(carriesNoMail(parseRelayPushData({ kind: 'jmap-email-push', accountId: 'a', emailIds: '["m1"]' }))).toBe(false);
expect(carriesNoMail(parseRelayPushData({ kind: 'jmap-state-change', emailIds: '[]', changed: '{}' }))).toBe(false);
expect(carriesNoMail(parseRelayPushData({ kind: 'jmap-state-change', emailIds: '[]' }))).toBe(false);
});
it('neither looks for mail nor notifies for a change without new mail', async () => {
await pushBackgroundTask(stateChange({ 'jmap-primary': { ContactCard: 's1' }, team: { CalendarEvent: 'e1' } }));
expect(jmapClient.getStoredCredentials).not.toHaveBeenCalled();
expect(secureFetch).not.toHaveBeenCalled();
expect(showNotification).not.toHaveBeenCalled();
});
it('still checks for mail when the map carries new mail', async () => {
await pushBackgroundTask(stateChange({ 'jmap-primary': { EmailDelivery: 'm1', ContactCard: 's1' } }));
expect(jmapClient.getStoredCredentials).toHaveBeenCalledWith(LOCAL);
});
// kind (undefined: none), changed (undefined: none) → whether the push may
// carry mail. The same table as "reads a push the same way as the push task"
// in PushRoutingTest.kt, whose startMailTask must agree.
const STATE = 'jmap-state-change';
const EMAIL_PUSH = 'jmap-email-push';
const MAIL_CASES: Array<[string | undefined, string | undefined, boolean]> = [
// No readable map: as before device sync.
[STATE, undefined, true],
[STATE, '', true],
[STATE, 'not json', true],
[STATE, '[1]', true],
[STATE, '{}', true],
// A map that names no type is read like a missing one.
[STATE, '{"c":{}}', true],
[STATE, '{"c":{},"team":{}}', true],
// An account entry that can't be read may be mail.
[STATE, '{"c":null}', true],
[STATE, '{"c":"garbled","team":{"ContactCard":"s"}}', true],
[STATE, '{"c":[],"team":{"ContactCard":"s"}}', true],
[STATE, '{"c":5}', true],
// Mail types anywhere in the map.
[STATE, '{"c":{"EmailDelivery":"s"}}', true],
[STATE, '{"c":{"Email":"s"}}', true],
[STATE, '{"c":{"Mailbox":"s"}}', true],
[STATE, '{"c":{"ContactCard":"s1"},"team":{"EmailDelivery":"s2"}}', true],
// Only a StateChange can say it carries no mail.
[undefined, '{"c":{"ContactCard":"s"}}', true],
[EMAIL_PUSH, '{"c":{"EmailDelivery":"s"}}', true],
[EMAIL_PUSH, undefined, true],
// Contact and calendar changes, and types that say nothing about mail.
[STATE, '{"c":{"ContactCard":"s1","AddressBook":"s2"}}', false],
[STATE, '{"c":{"CalendarEvent":"e1"},"team":{"Calendar":"c1"}}', false],
[STATE, '{"c":{"ContactCard":"s"},"team":{}}', false],
[STATE, '{"elsewhere":{"ContactCard":"s"}}', false],
[STATE, '{"c":{"Thread":"t1","ContactCard":"s1"}}', false],
];
it('reads a push the same way as the native router', () => {
for (const [kind, changed, mail] of MAIL_CASES) {
const data: Record<string, string> = { accountLabel: 'alice', accountId: 'c', emailIds: '[]' };
if (kind !== undefined) data.kind = kind;
if (changed !== undefined) data.changed = changed;
expect(carriesNoMail(parseRelayPushData(data)), `kind=${kind} changed=${changed}`).toBe(!mail);
}
// Native code hands `changed` over as a string; a map that is not an
// object is unreadable in any form.
expect(carriesNoMail(parseRelayPushData({ kind: STATE, changed: [{ ContactCard: 's' }] }))).toBe(false);
});
it("looks for mail when it can't read an account's entry, as the native router does", async () => {
for (const changed of ['{"jmap-primary":null}', '{"jmap-primary":{}}']) {
vi.clearAllMocks();
await pushBackgroundTask({ kind: STATE, accountLabel: 'alice', accountId: 'jmap-primary', emailIds: '[]', changed });
expect(jmapClient.getStoredCredentials, changed).toHaveBeenCalledWith(LOCAL);
}
});
it('takes an Email or Mailbox change for possible mail, as a subscription from an older build sends it', () => {
expect(carriesNoMail(parseRelayPushData(stateChange({ a: { Email: 's1', Mailbox: 's2' } })))).toBe(false);
expect(carriesNoMail(parseRelayPushData(stateChange({ a: { Email: 's1' } })))).toBe(false);
expect(carriesNoMail(parseRelayPushData(stateChange({ a: { Mailbox: 's1' } })))).toBe(false);
expect(carriesNoMail(parseRelayPushData(stateChange({ a: { CalendarEvent: 'e1' }, b: { Email: 's1' } })))).toBe(false);
// Types that are neither mail nor device sync say nothing about mail.
expect(carriesNoMail(parseRelayPushData(stateChange({ a: { Thread: 't1', ContactCard: 's1' } })))).toBe(true);
});
it('notifies new mail from a subscription that listens to Email and Mailbox', async () => {
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, init?: { body?: string }) => {
if (url.endsWith('/.well-known/jmap')) {
return {
ok: true,
json: async () => ({
apiUrl: 'https://mail.example.com/jmap/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
accounts: { 'jmap-primary': {} },
}),
};
}
// The legacy path: the inbox, its newest unread ids, then the messages.
const [[name]] = JSON.parse(init?.body ?? '{}').methodCalls;
const body = name === 'Mailbox/get'
? { list: [{ id: 'inbox', role: 'inbox' }] }
: name === 'Email/query'
? { ids: ['m1'] }
: { list: [{ id: 'm1', threadId: 't1', keywords: {}, subject: 'Hi', from: [{ email: 'bob@example.com' }] }] };
return { ok: true, json: async () => ({ methodResponses: [[name, body, '0']] }) };
});
await pushBackgroundTask(stateChange({ 'jmap-primary': { Email: 's1', Mailbox: 's2' } }));
expect(showNotification).toHaveBeenCalledWith(expect.objectContaining({ emailId: 'm1', accountId: LOCAL }));
});
});
// Stalwart keeps a subscription for seven days from its last refresh.
describe('pushBackgroundTask keeps the push subscription alive', () => {
const LOCAL = 'alice@mail.example.com';
const DAY = 86_400_000;
const showNotification = vi.fn(async () => undefined);
const server = { deviceClientId: 'dcid-ours', expires: '', calls: [] as string[], failPush: false };
const push = () => pushBackgroundTask({
kind: 'jmap-email-push',
accountLabel: 'alice',
accountId: 'jmap-primary',
emailIds: JSON.stringify(['m1']),
});
const recorded = (msFromNow: number) => AsyncStorage.setItem(`push:subscriptionExpires:v1:${LOCAL}`, new Date(Date.now() + msFromNow).toISOString());
beforeEach(async () => {
vi.clearAllMocks();
await AsyncStorage.clear();
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([LOCAL]));
await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({ [LOCAL]: 'jmap-primary' }));
await AsyncStorage.setItem(`push:subscriptionId:v2:${LOCAL}`, 'b');
await AsyncStorage.setItem(`push:deviceClientId:v2:${LOCAL}`, 'dcid-ours');
(NativeModules as Record<string, unknown>).BulwarkFcm = { showNotification };
(jmapClient.getStoredCredentials as ReturnType<typeof vi.fn>).mockResolvedValue({
serverUrl: 'https://mail.example.com',
username: 'alice',
password: 'secret',
});
Object.assign(server, { deviceClientId: 'dcid-ours', expires: new Date(Date.now() + 2 * DAY).toISOString(), calls: [], failPush: false });
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, init?: { body?: string }) => {
if (url.endsWith('/.well-known/jmap')) {
return {
ok: true,
json: async () => ({
apiUrl: 'https://mail.example.com/jmap/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
accounts: { 'jmap-primary': {} },
}),
};
}
const calls = JSON.parse(init?.body ?? '{}').methodCalls as Array<[string, Record<string, any>, string]>;
if (server.failPush && calls[0][0].startsWith('PushSubscription/')) return { ok: false, status: 500 };
const methodResponses = calls.map(([name, args, id]) => {
server.calls.push(name);
if (name === 'PushSubscription/set') {
// Stalwart clamps the expiry and answers the update with null.
server.expires = new Date(Date.now() + 7 * DAY).toISOString().replace(/\.\d+Z$/, 'Z');
return [name, { updated: { [Object.keys(args.update)[0]]: null } }, id];
}
if (name === 'PushSubscription/get') {
return [name, { list: [{ id: 'b', deviceClientId: server.deviceClientId, expires: server.expires }] }, id];
}
return [name, { list: [{ id: 'm1', threadId: 't1', keywords: {}, subject: 'Hi', from: [{ email: 'bob@example.com' }] }] }, id];
});
return { ok: true, json: async () => ({ methodResponses }) };
});
});
afterEach(() => {
delete (NativeModules as Record<string, unknown>).BulwarkFcm;
});
it('extends a subscription with less than six days left and records what the server settled on', async () => {
await recorded(2 * DAY);
await push();
expect(showNotification).toHaveBeenCalled();
expect(server.calls).toEqual(['Email/get', 'Mailbox/get', 'PushSubscription/get', 'PushSubscription/set', 'PushSubscription/get']);
expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${LOCAL}`)).toBe(server.expires);
// Recorded now: the next pushes leave it alone for a day.
server.calls = [];
await AsyncStorage.multiRemove([`push:notifiedIds:v1:${LOCAL}`, `push:lastNotifiedEmailId:v2:${LOCAL}`]);
await push();
expect(server.calls).toEqual(['Email/get', 'Mailbox/get']);
});
it('extends it also when the push brings nothing new to show', async () => {
await recorded(2 * DAY);
await AsyncStorage.setItem(`push:notifiedIds:v1:${LOCAL}`, JSON.stringify(['m1']));
await push();
expect(showNotification).not.toHaveBeenCalled();
expect(server.calls).toContain('PushSubscription/set');
});
it('asks the server when the recorded expiry has passed or is unknown, which may be stale', async () => {
for (const setup of [
() => recorded(-DAY),
() => AsyncStorage.removeItem(`push:subscriptionExpires:v1:${LOCAL}`),
]) {
// The server's copy runs out in two days: extended.
Object.assign(server, { expires: new Date(Date.now() + 2 * DAY).toISOString(), calls: [] });
await AsyncStorage.multiRemove([`push:notifiedIds:v1:${LOCAL}`, `push:lastNotifiedEmailId:v2:${LOCAL}`]);
await setup();
await push();
expect(server.calls).toEqual(['Email/get', 'Mailbox/get', 'PushSubscription/get', 'PushSubscription/set', 'PushSubscription/get']);
expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${LOCAL}`)).toBe(server.expires);
// The server's copy has a week left: recorded, not written.
const serverExpires = new Date(Date.now() + 6.5 * DAY).toISOString();
Object.assign(server, { expires: serverExpires, calls: [] });
await AsyncStorage.multiRemove([`push:notifiedIds:v1:${LOCAL}`, `push:lastNotifiedEmailId:v2:${LOCAL}`]);
await setup();
await push();
expect(server.calls).toEqual(['Email/get', 'Mailbox/get', 'PushSubscription/get']);
expect(await AsyncStorage.getItem(`push:subscriptionExpires:v1:${LOCAL}`)).toBe(serverExpires);
}
});
it('leaves alone a subscription with time left and one push was turned off for', async () => {
for (const setup of [
() => recorded(6.5 * DAY),
async () => {
await recorded(2 * DAY);
await AsyncStorage.setItem(`push:optedOut:v1:${LOCAL}`, '1');
},
]) {
server.calls = [];
await AsyncStorage.multiRemove([`push:notifiedIds:v1:${LOCAL}`, `push:lastNotifiedEmailId:v2:${LOCAL}`]);
await AsyncStorage.removeItem(`push:optedOut:v1:${LOCAL}`);
await setup();
await push();
expect(server.calls).toEqual(['Email/get', 'Mailbox/get']);
}
});
it("does not extend another device's subscription that reuses the recorded id", async () => {
await recorded(2 * DAY);
server.deviceClientId = 'dcid-someone-else';
await push();
expect(server.calls).toEqual(['Email/get', 'Mailbox/get', 'PushSubscription/get']);
});
it('still notifies when the server cannot be asked about the subscription', async () => {
await recorded(2 * DAY);
server.failPush = true;
await push();
expect(showNotification).toHaveBeenCalled();
});
});
describe('selectNotifiableEmails', () => {
const email = (id: string, keywords: Record<string, boolean> = {}): Email =>
({ id, threadId: 't', keywords, mailboxIds: {}, size: 0, receivedAt: '', hasAttachment: false } as Email);
it('drops read, junk and already-notified messages', () => {
const out = selectNotifiableEmails(
[email('a'), email('b', { $seen: true }), email('c', { $junk: true }), email('d')],
['d'],
);
expect(out.map((e) => e.id)).toEqual(['a']);
});
});
describe('senderFaviconsAllowed', () => {
const KEY = 'webmail:settings:v1';
it('follows the "Sender Favicons" setting the settings store persisted', async () => {
await AsyncStorage.setItem(KEY, JSON.stringify({ senderFavicons: false }));
expect(await senderFaviconsAllowed()).toBe(false);
await AsyncStorage.setItem(KEY, JSON.stringify({ senderFavicons: true }));
expect(await senderFaviconsAllowed()).toBe(true);
});
it('defaults to off like the store, and skips icons when the settings are unreadable', async () => {
await AsyncStorage.removeItem(KEY);
expect(await senderFaviconsAllowed()).toBe(false);
// Settings saved before the key existed: the store fills in its default (off).
await AsyncStorage.setItem(KEY, JSON.stringify({ emailNotificationsEnabled: true }));
expect(await senderFaviconsAllowed()).toBe(false);
await AsyncStorage.setItem(KEY, '{not json');
expect(await senderFaviconsAllowed()).toBe(false);
await AsyncStorage.removeItem(KEY);
});
});