mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Merge branch 's10/push' into feat/stalwart-1.0
This commit is contained in:
@@ -1060,6 +1060,7 @@
|
||||
"err_relay_rejected": "The push relay rejected the registration: {detail}",
|
||||
"err_relay_unreachable": "Could not reach the push relay at {url} ({detail}).",
|
||||
"err_up_android_only": "UnifiedPush is only available on Android.",
|
||||
"err_url_refused": "The mail server refuses to send notifications to the relay at {url}. It only accepts https addresses on a public domain name, not a private IP address or a local name such as localhost, .local or .lan. Use the hosted relay, or give your relay a public domain name.",
|
||||
"err_verify_rejected": "The mail server rejected the verification code: {detail}",
|
||||
"err_verify_relay": "The relay never received the verification code from the mail server (last relay response: {detail}).",
|
||||
"err_verify_timeout": "The mail server did not send a verification code to the relay within 75 s. Check that the server can reach the relay URL.",
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Live push subscription checks against a real Stalwart server.
|
||||
*
|
||||
* Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP` and
|
||||
* `BULWARK_LIVE_PASS` are set. No device is involved (there is no FCM or APNs
|
||||
* token here): it drives the PushSubscription calls the app makes, with the
|
||||
* URLs, types and emailPush maps the app builds, and destroys every
|
||||
* subscription it creates.
|
||||
*
|
||||
* BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
|
||||
* BULWARK_LIVE_USER=e2e-tablet-dark@example.org \
|
||||
* BULWARK_LIVE_PASS=… npx vitest run src/api/__tests__/live-stalwart-push.test.ts
|
||||
*
|
||||
* `BULWARK_LIVE_PUSH_RELAY=1` also runs the verification round trip through
|
||||
* the hosted relay (https://notifications.relay.bulwarkmail.org): the server
|
||||
* posts PushVerification there and the test reads it back the way the app's
|
||||
* poll does. It registers a throwaway record with the relay under a random
|
||||
* id and removes it again. Run it at least a minute after any other push
|
||||
* test against the same account: Stalwart sends one PushVerification per
|
||||
* account per minute.
|
||||
*
|
||||
* Verified against stalwartlabs/stalwart:v0.16.27 and v1.0.0.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { JMAPClient } from '../jmap-client';
|
||||
import {
|
||||
createPushSubscription,
|
||||
destroyPushSubscription,
|
||||
listPushSubscriptions,
|
||||
updatePushSubscription,
|
||||
verifyPushSubscription,
|
||||
} from '../push';
|
||||
import { isJMAPSetError, JMAPSetError } from '../jmap-result';
|
||||
import { getMailboxes } from '../email';
|
||||
import {
|
||||
buildEmailPushConfig,
|
||||
DEFAULT_RELAY_BASE_URL,
|
||||
emailPushFallbacks,
|
||||
EMAIL_PUSH_CAPABILITY,
|
||||
sameEmailPush,
|
||||
sameTypes,
|
||||
} from '../../lib/push-notifications';
|
||||
import type { EmailPushConfig } from '../types';
|
||||
|
||||
const SERVER = process.env.BULWARK_LIVE_JMAP;
|
||||
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
|
||||
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
|
||||
const live = SERVER && PASS ? describe : describe.skip;
|
||||
const withRelay = process.env.BULWARK_LIVE_PUSH_RELAY === '1' ? it : it.skip;
|
||||
|
||||
const DAY = 86_400_000;
|
||||
// What pushTypesFor gives an account that syncs contacts and calendars.
|
||||
const ALL_TYPES = ['EmailDelivery', 'ContactCard', 'AddressBook', 'CalendarEvent', 'Calendar'];
|
||||
|
||||
let client: JMAPClient;
|
||||
|
||||
async function connectSingleton(): Promise<void> {
|
||||
const mod = await import('../jmap-client');
|
||||
client = new JMAPClient();
|
||||
await client.connect(SERVER!, USER, PASS);
|
||||
(mod.jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot());
|
||||
}
|
||||
|
||||
/** Stalwart 1.0 lists principals:owner in the account capabilities, 0.16 doesn't. */
|
||||
function isStalwart1(): boolean {
|
||||
const caps = client.currentSession?.accounts?.[client.accountId]?.accountCapabilities ?? {};
|
||||
return 'urn:ietf:params:jmap:principals:owner' in caps;
|
||||
}
|
||||
|
||||
live('live Stalwart push subscriptions', () => {
|
||||
const run = randomBytes(4).toString('hex');
|
||||
const deviceClientId = (label: string) => `live-push-${run}-${label}`;
|
||||
// The URL the app subscribes with (setupPushNotifications).
|
||||
const relayUrl = (dcid: string) => `${DEFAULT_RELAY_BASE_URL}/api/push/jmap/${encodeURIComponent(dcid)}`;
|
||||
const created: string[] = [];
|
||||
let junk = '';
|
||||
let inbox = '';
|
||||
|
||||
async function create(label: string, params: Partial<Parameters<typeof createPushSubscription>[0]> = {}) {
|
||||
const dcid = deviceClientId(label);
|
||||
const result = await createPushSubscription({
|
||||
deviceClientId: dcid,
|
||||
url: relayUrl(dcid),
|
||||
types: ['EmailDelivery'],
|
||||
expires: new Date(Date.now() + 90 * DAY).toISOString(),
|
||||
...params,
|
||||
});
|
||||
created.push(result.id);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function refusal(promise: Promise<unknown>): Promise<JMAPSetError> {
|
||||
const err = await promise.then(
|
||||
(value) => {
|
||||
// A create that went through still has to be cleaned up.
|
||||
const id = (value as { id?: string } | undefined)?.id;
|
||||
if (id) created.push(id);
|
||||
return null;
|
||||
},
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(isJMAPSetError(err), String(err)).toBe(true);
|
||||
return err as JMAPSetError;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await connectSingleton();
|
||||
const boxes = await getMailboxes();
|
||||
junk = boxes.find((b) => b.role === 'junk')?.id ?? '';
|
||||
inbox = boxes.find((b) => b.role === 'inbox')?.id ?? '';
|
||||
expect(inbox).toBeTruthy();
|
||||
expect(junk).toBeTruthy();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
// Everything this run made, also what a failed assertion left behind.
|
||||
const left = await listPushSubscriptions().catch(() => []);
|
||||
const ids = new Set([...created, ...left.filter((s) => s.deviceClientId.startsWith('live-push-')).map((s) => s.id)]);
|
||||
for (const id of ids) await destroyPushSubscription(id).catch(() => undefined);
|
||||
}, 60_000);
|
||||
|
||||
// First, so earlier creates of this run don't hold the verification slot.
|
||||
withRelay('delivers PushVerification to the hosted relay and accepts the code', async () => {
|
||||
const dcid = `liveprobe-${randomBytes(8).toString('hex')}`;
|
||||
const relay = (path: string, init?: RequestInit) => fetch(`${DEFAULT_RELAY_BASE_URL}${path}`, init);
|
||||
const registered = await relay('/api/push/register', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ subscriptionId: dcid, fcmToken: `liveprobe${'x'.repeat(100)}`, accountLabel: 'live test (delete me)' }),
|
||||
});
|
||||
expect(registered.status).toBe(200);
|
||||
try {
|
||||
const { id } = await createPushSubscription({ deviceClientId: dcid, url: relayUrl(dcid), types: ['EmailDelivery'] });
|
||||
created.push(id);
|
||||
// The app's poll: up to 75 s, enough for one verification window.
|
||||
let code: string | null = null;
|
||||
const until = Date.now() + 75_000;
|
||||
while (!code && Date.now() < until) {
|
||||
const res = await relay(`/api/push/verify/${dcid}`);
|
||||
if (res.ok) code = ((await res.json()) as { verificationCode?: string | null }).verificationCode ?? null;
|
||||
if (!code) await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
expect(code).toBeTruthy();
|
||||
await expect(verifyPushSubscription(id, 'not-the-code')).rejects.toMatchObject({ type: 'invalidProperties' });
|
||||
await verifyPushSubscription(id, code!);
|
||||
} finally {
|
||||
await relay(`/api/push/register/${dcid}`, { method: 'DELETE' });
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it('advertises emailpush, on the own account too', () => {
|
||||
expect(EMAIL_PUSH_CAPABILITY in (client.currentSession?.capabilities ?? {})).toBe(true);
|
||||
expect(EMAIL_PUSH_CAPABILITY in (client.currentSession?.accounts?.[client.accountId]?.accountCapabilities ?? {})).toBe(true);
|
||||
});
|
||||
|
||||
it('creates the subscription the app makes and clamps its expiry to seven days', async () => {
|
||||
const emailPush = await buildEmailPushConfig();
|
||||
expect(Object.keys(emailPush)).toContain(client.accountId);
|
||||
const { id, expires } = await create('full', { types: ALL_TYPES, emailPush });
|
||||
const left = Date.parse(expires ?? '') - Date.now();
|
||||
expect(left).toBeGreaterThan(7 * DAY - 5 * 60_000);
|
||||
expect(left).toBeLessThanOrEqual(7 * DAY + 60_000);
|
||||
|
||||
// Read back as the app compares it, so a launch doesn't re-patch it.
|
||||
const listed = (await listPushSubscriptions()).find((s) => s.id === id);
|
||||
expect(listed?.deviceClientId).toBe(deviceClientId('full'));
|
||||
expect(sameTypes(listed?.types, ALL_TYPES)).toBe(true);
|
||||
expect(sameEmailPush(listed?.emailPush, emailPush), JSON.stringify(listed?.emailPush)).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
it('takes the refresh the launch sends: expiry, types and filter in one update', async () => {
|
||||
const { id } = await create('refresh', { types: ALL_TYPES });
|
||||
const emailPush = await buildEmailPushConfig();
|
||||
await updatePushSubscription(id, { expires: new Date(Date.now() + 90 * DAY).toISOString(), types: ['EmailDelivery'], emailPush });
|
||||
const listed = (await listPushSubscriptions()).find((s) => s.id === id);
|
||||
expect(listed?.types).toEqual(['EmailDelivery']);
|
||||
expect(sameEmailPush(listed?.emailPush, emailPush)).toBe(true);
|
||||
expect(Date.parse(listed?.expires ?? '') - Date.now()).toBeLessThanOrEqual(7 * DAY + 60_000);
|
||||
|
||||
// A record that is gone answers notFound, which the setup answers with a
|
||||
// replacement; destroying it again counts as done.
|
||||
await destroyPushSubscription(id);
|
||||
expect((await refusal(updatePushSubscription(id, { expires: new Date(Date.now() + DAY).toISOString() }))).type).toBe('notFound');
|
||||
await destroyPushSubscription(id);
|
||||
}, 30_000);
|
||||
|
||||
it('refuses a map naming an account the user is not a member of as forbidden, and takes the narrowed one', async () => {
|
||||
const own = await buildEmailPushConfig();
|
||||
// Account ids are short and sequential; one the user doesn't belong to.
|
||||
const foreign = ['a', 'b', 'c', 'd', 'e'].find((id) => !(id in (client.currentSession?.accounts ?? {})))!;
|
||||
const wanted: Record<string, EmailPushConfig> = { ...own, [foreign]: own[client.accountId] };
|
||||
|
||||
const refused = await refusal(create('foreign', { emailPush: wanted }));
|
||||
expect(refused.type).toBe('forbidden');
|
||||
|
||||
// What the setup tries next: the primary account alone.
|
||||
const attempts = emailPushFallbacks(wanted, client.accountId, client.currentSession?.accounts);
|
||||
expect(Object.keys(attempts[attempts.length - 1])).toEqual([client.accountId]);
|
||||
const { id } = await create('narrowed', { emailPush: attempts[attempts.length - 1] });
|
||||
|
||||
// An update is refused the same way and leaves the subscription as it was.
|
||||
expect((await refusal(updatePushSubscription(id, { emailPush: wanted }))).type).toBe('forbidden');
|
||||
expect(sameEmailPush((await listPushSubscriptions()).find((s) => s.id === id)?.emailPush, own)).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
it('takes the filters mutes produce and echoes them so they compare equal (#78)', async () => {
|
||||
const me = client.accountId;
|
||||
const cases = {
|
||||
folderMuted: { accounts: [], mailboxes: { [`${me}/${inbox}`]: true } },
|
||||
accountMutedInboxUnmuted: { accounts: [me], mailboxes: { [`${me}/${inbox}`]: false } },
|
||||
accountMuted: { accounts: [me], mailboxes: {} },
|
||||
};
|
||||
const built = Object.fromEntries(await Promise.all(
|
||||
Object.entries(cases).map(async ([name, mutes]) => [name, await buildEmailPushConfig(mutes)] as const),
|
||||
));
|
||||
expect(built.folderMuted[me].filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: [inbox, junk].sort() }],
|
||||
});
|
||||
expect(built.accountMutedInboxUnmuted[me].filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ notKeyword: '$junk' }, { inMailbox: inbox }],
|
||||
});
|
||||
expect(built.accountMuted[me].filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }],
|
||||
});
|
||||
for (const [name, emailPush] of Object.entries(built)) {
|
||||
const { id } = await create(`mute-${name}`, { emailPush });
|
||||
const listed = (await listPushSubscriptions()).find((s) => s.id === id);
|
||||
expect(sameEmailPush(listed?.emailPush, emailPush), `${name}: ${JSON.stringify(listed?.emailPush)}`).toBe(true);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it('refuses push addresses that are not public: http and private IPs everywhere, local names on 1.0', async () => {
|
||||
for (const url of ['http://notifications.relay.bulwarkmail.org/api/push/jmap/x', 'https://192.168.1.20/api/push/jmap/x', 'https://10.0.0.5:8443/api/push/jmap/x']) {
|
||||
const err = await refusal(create('private-ip', { url }));
|
||||
expect(err.type).toBe('invalidProperties');
|
||||
expect(err.properties).toEqual(['url']);
|
||||
}
|
||||
for (const url of ['https://relay.home.lan/api/push/jmap/x', 'https://relay.local/api/push/jmap/x', 'https://localhost/api/push/jmap/x']) {
|
||||
if (isStalwart1()) {
|
||||
const err = await refusal(create('local-name', { url }));
|
||||
expect(err.type).toBe('invalidProperties');
|
||||
expect(err.properties).toEqual(['url']);
|
||||
expect(err.message).toContain('public domain');
|
||||
} else {
|
||||
// 0.16 takes them; it would only fail to deliver.
|
||||
await create('local-name', { url });
|
||||
}
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('refuses a type it does not know over the types property', async () => {
|
||||
const err = await refusal(create('bogus-type', { types: ['EmailDelivery', 'NoSuchType'] }));
|
||||
expect(err.type).toBe('invalidProperties');
|
||||
expect(err.properties).toEqual(['types']);
|
||||
});
|
||||
|
||||
it('caps the subscriptions of an account and says so with overQuota', async () => {
|
||||
let err: JMAPSetError | null = null;
|
||||
for (let i = 0; i < 30 && !err; i++) {
|
||||
err = await create(`cap-${i}`).then(() => null, (e: unknown) => (isJMAPSetError(e) ? e : Promise.reject(e)));
|
||||
}
|
||||
expect(err?.type).toBe('overQuota');
|
||||
expect((await listPushSubscriptions()).length).toBe(15);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -197,7 +197,8 @@ export function NotificationSettings() {
|
||||
message: t('settings.notifications.push.status_busy', 'Working…'),
|
||||
});
|
||||
try {
|
||||
await setStoredRelayBaseUrl(trimmed);
|
||||
// The setup stores the relay itself, and keeps the one in use when the
|
||||
// mail server refuses the new one's address.
|
||||
await setupPushNotifications({
|
||||
relayBaseUrl: trimmed,
|
||||
accountLabel: username ?? undefined,
|
||||
|
||||
@@ -460,6 +460,124 @@ describe('pushes for device sync (#34)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 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', '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']);
|
||||
});
|
||||
|
||||
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('leaves alone a subscription with time left, one that lapsed, one of unknown expiry and one push was turned off for', async () => {
|
||||
for (const setup of [
|
||||
() => recorded(6.5 * DAY),
|
||||
() => recorded(-DAY),
|
||||
() => AsyncStorage.removeItem(`push:subscriptionExpires:v1:${LOCAL}`),
|
||||
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']);
|
||||
}
|
||||
});
|
||||
|
||||
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', '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);
|
||||
|
||||
@@ -371,6 +371,45 @@ describe('setupPushNotifications subscription shape', () => {
|
||||
expect(result.subscriptionId).toBe('new-server-id');
|
||||
});
|
||||
|
||||
it('says the mail server refuses a relay that is not on a public domain (Stalwart 1.0)', async () => {
|
||||
const relay = 'https://relay.home.lan';
|
||||
createMock.mockRejectedValueOnce(new JMAPSetError(
|
||||
'invalidProperties',
|
||||
'Failed to create push subscription new: invalidProperties – properties=[url] – Host is not a public domain name',
|
||||
['url'],
|
||||
));
|
||||
const err = await setupPushNotifications({ relayBaseUrl: relay }).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(PushSetupError);
|
||||
expect(err.phase).toBe('jmap');
|
||||
expect(err.message).toContain(relay);
|
||||
expect(err.message).toContain('public domain name');
|
||||
expect(err.message).not.toContain('properties=[url]');
|
||||
});
|
||||
|
||||
it('keeps the relay a working registration goes through when the server refuses the new one', async () => {
|
||||
await AsyncStorage.setItem('push:relayBaseUrl:v1', RELAY);
|
||||
await AsyncStorage.setItem(SUB_KEY, 'existing');
|
||||
listMock.mockResolvedValue([{ id: 'existing', deviceClientId: OUR_DCID, expires: new Date(Date.now() + 5 * 86400000).toISOString(), types: ['EmailDelivery'] }]);
|
||||
createMock.mockRejectedValueOnce(new JMAPSetError('invalidProperties', 'Host is not a public domain name', ['url']));
|
||||
const fetchMock = global.fetch as ReturnType<typeof vi.fn>;
|
||||
|
||||
const err = await setupPushNotifications({ relayBaseUrl: 'https://relay.home.lan', forceRecreate: true }).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(PushSetupError);
|
||||
expect(await AsyncStorage.getItem('push:relayBaseUrl:v1')).toBe(RELAY);
|
||||
expect(await AsyncStorage.getItem(SUB_KEY)).toBe('existing');
|
||||
expect(destroyMock).not.toHaveBeenCalledWith('existing');
|
||||
const deleted = fetchMock.mock.calls.filter((c) => (c[1] as RequestInit | undefined)?.method === 'DELETE').map((c) => String(c[0]));
|
||||
expect(deleted).toEqual([`https://relay.home.lan/api/push/register/${OUR_DCID}`]);
|
||||
|
||||
// The same relay refused (a server that tightened its rules) stays as it is.
|
||||
fetchMock.mockClear();
|
||||
createMock.mockRejectedValueOnce(new JMAPSetError('invalidProperties', 'Host is not a public domain name', ['url']));
|
||||
await setupPushNotifications({ relayBaseUrl: RELAY, forceRecreate: true }).catch(() => undefined);
|
||||
expect(await AsyncStorage.getItem('push:relayBaseUrl:v1')).toBe(RELAY);
|
||||
expect(fetchMock.mock.calls.some((c) => (c[1] as RequestInit | undefined)?.method === 'DELETE')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects plain-http relay URLs', async () => {
|
||||
await expect(setupPushNotifications({ relayBaseUrl: 'http://relay.example.com' })).rejects.toMatchObject({ phase: 'relay' });
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
@@ -514,6 +553,35 @@ describe('push types for device sync (#34)', () => {
|
||||
expect(destroyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries with the mail types alone when Stalwart refuses the types property", async () => {
|
||||
await syncing({ [CONTACTS]: true });
|
||||
// Stalwart's own answer to a type it doesn't know.
|
||||
createMock.mockRejectedValueOnce(new JMAPSetError('invalidProperties', 'Invalid data type.', ['types']));
|
||||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
expect(createMock.mock.calls.map((c) => c[0].types))
|
||||
.toEqual([['EmailDelivery', 'ContactCard', 'AddressBook'], ['EmailDelivery']]);
|
||||
});
|
||||
|
||||
it('does not drop the device sync types over a failure that is not about them', async () => {
|
||||
await syncing({ [CONTACTS]: true });
|
||||
for (const failure of [
|
||||
new Error('Network request failed'),
|
||||
new JMAPSetError('invalidProperties', 'Host is not a public domain name', ['url']),
|
||||
new JMAPSetError('overQuota', 'There are too many subscriptions'),
|
||||
]) {
|
||||
createMock.mockClear();
|
||||
createMock.mockRejectedValueOnce(failure);
|
||||
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(PushSetupError);
|
||||
expect(createMock.mock.calls.map((c) => c[0].types)).toEqual([['EmailDelivery', 'ContactCard', 'AddressBook']]);
|
||||
}
|
||||
|
||||
// The next attempt (the next launch) subscribes with them.
|
||||
createMock.mockClear();
|
||||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
expect(createMock.mock.calls.map((c) => c[0].types)).toEqual([['EmailDelivery', 'ContactCard', 'AddressBook']]);
|
||||
});
|
||||
|
||||
it('subscribes with them', async () => {
|
||||
await syncing({ [CALENDAR]: true });
|
||||
await setupPushNotifications({ relayBaseUrl: RELAY });
|
||||
|
||||
@@ -17,8 +17,11 @@ import {
|
||||
lastNotifiedKey,
|
||||
migrateLegacyPushKeys,
|
||||
notifiedIdsKey,
|
||||
pushSubscriptionDueForExtension,
|
||||
readPushAccountIds,
|
||||
readPushJmapAccountIds,
|
||||
recordPushSubscriptionExpires,
|
||||
wantedPushSubscriptionExpires,
|
||||
} from './push-notifications';
|
||||
|
||||
// Mirrors STORAGE_KEY in `stores/settings-store.ts`. The headless task can't
|
||||
@@ -457,10 +460,47 @@ export function selectNotifiableEmails(emails: Email[], alreadyNotified: readonl
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the account's subscription expiry forward from the background (see
|
||||
* pushSubscriptionDueForExtension). Best effort: anything unexpected leaves
|
||||
* it to the next launch.
|
||||
*/
|
||||
async function extendPushSubscription(accountId: string, session: DetachedSession): Promise<void> {
|
||||
const due = await pushSubscriptionDueForExtension(accountId);
|
||||
if (!due) return;
|
||||
const subscriptionOf = (responses: Array<[string, Record<string, any>, string]>, callId: string) => {
|
||||
const response = responses.find((r) => r[2] === callId);
|
||||
if (response?.[0] !== 'PushSubscription/get') return undefined;
|
||||
return (response[1].list as Array<{ id: string; deviceClientId?: string; expires?: string | null }> | undefined)
|
||||
?.find((s) => s.id === due.id);
|
||||
};
|
||||
const properties = ['id', 'deviceClientId', 'expires'];
|
||||
// Stalwart reuses the ids of destroyed subscriptions: extend only our own.
|
||||
const current = subscriptionOf(
|
||||
await jmapPost(session, [['PushSubscription/get', { ids: [due.id], properties }, '0']]),
|
||||
'0',
|
||||
);
|
||||
if (!current || (current.deviceClientId && due.deviceClientId && current.deviceClientId !== due.deviceClientId)) return;
|
||||
const responses = await jmapPost(session, [
|
||||
['PushSubscription/set', { update: { [due.id]: { expires: wantedPushSubscriptionExpires() } } }, '0'],
|
||||
// The update's answer doesn't say what the server settled on.
|
||||
['PushSubscription/get', { ids: [due.id], properties }, '1'],
|
||||
]);
|
||||
const extended = subscriptionOf(responses, '1');
|
||||
if (extended?.expires) await recordPushSubscriptionExpires(accountId, extended.expires);
|
||||
}
|
||||
|
||||
async function processAccountForPush(accountId: string, payload: RelayPushData): Promise<void> {
|
||||
const session = await openDetachedSession(accountId);
|
||||
if (!session) return;
|
||||
try {
|
||||
await notifyForPush(accountId, session, payload);
|
||||
} finally {
|
||||
await extendPushSubscription(accountId, session).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyForPush(accountId: string, session: DetachedSession, payload: RelayPushData): Promise<void> {
|
||||
const alreadyNotified = await readNotifiedIds(accountId);
|
||||
let candidates: Email[];
|
||||
let emailAccountId = session.jmapAccountId;
|
||||
|
||||
@@ -275,7 +275,8 @@ const EMAIL_PUSH_PROPERTIES = ['id', 'threadId'];
|
||||
// user re-enables it from settings.
|
||||
const SUBSCRIPTION_EXPIRES_DAYS = 90;
|
||||
// When an existing subscription has less than this much lifetime left, push
|
||||
// expires forward on the next app start.
|
||||
// expires forward on the next app start. Stalwart (0.16 and 1.0) clamps every
|
||||
// expiry to seven days, so there each start refreshes it.
|
||||
const SUBSCRIPTION_REFRESH_THRESHOLD_DAYS = 7;
|
||||
// A recorded subscription missing from the server was revoked only if it still
|
||||
// had at least this long to live; closer to its expiry (or with the clock a
|
||||
@@ -508,10 +509,25 @@ async function writeWithEmailPush<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a refused subscription write may be about its types. A failed
|
||||
* request says nothing about them, and a SetError naming other properties
|
||||
* (the push URL, the emailPush map) or meaning the subscription limit or a
|
||||
* missing record would fail the same way with fewer types; retrying those
|
||||
* with the mail types alone would only drop device sync's on a write that
|
||||
* later goes through.
|
||||
*/
|
||||
function mayConcernTypes(err: unknown): boolean {
|
||||
if (!(err instanceof JMAPMethodError)) return false;
|
||||
if (!isJMAPSetError(err)) return true;
|
||||
if (err.type !== 'invalidProperties') return false;
|
||||
return !err.properties || err.properties.includes('types');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a subscription write with the account's push types and, when the server
|
||||
* refuses it while they include device sync's, once more with the mail types
|
||||
* alone: mail push never depends on device sync (#34).
|
||||
* refuses it over them while they include device sync's, once more with the
|
||||
* mail types alone: mail push never depends on device sync (#34).
|
||||
*/
|
||||
async function writeWithPushTypes<T>(
|
||||
types: readonly string[],
|
||||
@@ -520,7 +536,7 @@ async function writeWithPushTypes<T>(
|
||||
try {
|
||||
return await write(types);
|
||||
} catch (err) {
|
||||
if (sameTypes(types, PUSH_TYPES)) throw err;
|
||||
if (sameTypes(types, PUSH_TYPES) || !mayConcernTypes(err)) throw err;
|
||||
logPhase('jmap', 'push types refused, retrying with the mail types alone');
|
||||
return write(PUSH_TYPES);
|
||||
}
|
||||
@@ -668,10 +684,30 @@ export class PushSetupError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the server refused the push address itself. Stalwart checks the
|
||||
* URL when a subscription is created: 0.16.27 and 1.0 refuse plain http and
|
||||
* private or reserved IP addresses, and 1.0 also refuses any host that is not
|
||||
* a public domain name (localhost, .local, .lan, .internal, a single label).
|
||||
*/
|
||||
function isRefusedPushUrl(err: unknown): boolean {
|
||||
return isJMAPSetError(err) && err.type === 'invalidProperties' && !!err.properties?.includes('url');
|
||||
}
|
||||
|
||||
// A subscription write the mail server refused or could not take. The
|
||||
// refusals a user can do something about read as plain text; anything else
|
||||
// keeps the error's own message.
|
||||
function subscriptionError(err: unknown): PushSetupError {
|
||||
function subscriptionError(err: unknown, relayBaseUrl: string): PushSetupError {
|
||||
if (isRefusedPushUrl(err)) {
|
||||
return new PushSetupError(
|
||||
'jmap',
|
||||
t(
|
||||
'settings.notifications.push.err_url_refused',
|
||||
'The mail server refuses to send notifications to the relay at {url}. It only accepts https addresses on a public domain name, not a private IP address or a local name such as localhost, .local or .lan. Use the hosted relay, or give your relay a public domain name.',
|
||||
{ url: relayBaseUrl },
|
||||
),
|
||||
);
|
||||
}
|
||||
const type = err instanceof JMAPMethodError ? err.type : undefined;
|
||||
if (type === 'forbidden') {
|
||||
return new PushSetupError(
|
||||
@@ -1188,6 +1224,9 @@ async function setupPushNotificationsInner(
|
||||
await migrateLegacyPushKeys();
|
||||
|
||||
const deviceClientId = await getOrCreateDeviceClientId(accountId);
|
||||
// The relay a working registration goes through, put back should the mail
|
||||
// server refuse the new one's address.
|
||||
const previousRelayBaseUrl = await getStoredRelayBaseUrl();
|
||||
await setStoredRelayBaseUrl(relayBaseUrl);
|
||||
|
||||
// Register this account's device-client-id with the relay. Multiple
|
||||
@@ -1275,7 +1314,7 @@ async function setupPushNotificationsInner(
|
||||
// Nothing the server said about the subscription (a failed request,
|
||||
// rate limiting, a refused method call): it keeps delivering, and
|
||||
// the next run tries again.
|
||||
throw subscriptionError(err);
|
||||
throw subscriptionError(err, relayBaseUrl);
|
||||
}
|
||||
if (refreshed) {
|
||||
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush);
|
||||
@@ -1301,10 +1340,12 @@ async function setupPushNotificationsInner(
|
||||
}
|
||||
|
||||
// Reap leftover Stalwart subscriptions that would otherwise starve the new
|
||||
// one's verification. Stalwart emits only one PushVerification per account
|
||||
// per ~60s and picks the oldest unverified subscription, so a single stale
|
||||
// straggler blocks every fresh attempt - the symptom is the confusing
|
||||
// "Timed out waiting for PushVerification" error. We can't read a
|
||||
// one's verification. Stalwart (0.16 and 1.0) emits only one
|
||||
// PushVerification per account per 60 s, to the newest unverified
|
||||
// subscription, and sends the next one when that window has passed: every
|
||||
// straggler created or touched just before ours delays it by a window, and
|
||||
// a swarm of them ends in the confusing "Timed out waiting for
|
||||
// PushVerification" error. We can't read a
|
||||
// subscription's verified state or URL over JMAP (Stalwart hides both), only
|
||||
// its deviceClientId, so we decide what's safe to remove like this:
|
||||
// - same deviceClientId as ours: a previous attempt from THIS device,
|
||||
@@ -1359,7 +1400,15 @@ async function setupPushNotificationsInner(
|
||||
serverExpires = created.result.expires;
|
||||
await rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush);
|
||||
} catch (err) {
|
||||
throw subscriptionError(err);
|
||||
// The server will never take this relay's address. Whatever registration
|
||||
// still works goes through the relay used before: keep that one stored
|
||||
// (token refreshes and the next launch register with it) and drop the
|
||||
// record this run left on the refused relay.
|
||||
if (isRefusedPushUrl(err) && previousRelayBaseUrl !== relayBaseUrl) {
|
||||
await setStoredRelayBaseUrl(previousRelayBaseUrl);
|
||||
await deregisterFromRelay(relayBaseUrl, deviceClientId);
|
||||
}
|
||||
throw subscriptionError(err, relayBaseUrl);
|
||||
}
|
||||
|
||||
logPhase('verify');
|
||||
@@ -1499,6 +1548,43 @@ export async function forgetPushSubscriptionExpiry(accountId: string): Promise<v
|
||||
await AsyncStorage.removeItem(subscriptionExpiresKey(accountId));
|
||||
}
|
||||
|
||||
// Stalwart (0.16 and 1.0) keeps a subscription for seven days from its last
|
||||
// refresh, and only the app's start refreshes it. Someone who reads new mail
|
||||
// from the notifications without opening the app would lose push after a
|
||||
// week, so a push handled in the background extends the subscription too,
|
||||
// at most about once a day: once less than this much of it is left.
|
||||
const BACKGROUND_EXTEND_BELOW_MS = 6 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The account's subscription when a background push should extend it: it is
|
||||
* recorded with a known expiry that has not passed yet (a lapsed one is the
|
||||
* launch-time resync's to re-create) and is less than six days away. Carries
|
||||
* this device's client id, so the caller can make sure a server that reused
|
||||
* the id hands back our own subscription.
|
||||
*/
|
||||
export async function pushSubscriptionDueForExtension(
|
||||
accountId: string,
|
||||
): Promise<{ id: string; deviceClientId: string | null } | null> {
|
||||
if (await AsyncStorage.getItem(optedOutKey(accountId))) return null;
|
||||
const id = await AsyncStorage.getItem(subscriptionIdKey(accountId));
|
||||
if (!id) return null;
|
||||
const expires = Date.parse((await AsyncStorage.getItem(subscriptionExpiresKey(accountId))) ?? '');
|
||||
if (!Number.isFinite(expires)) return null;
|
||||
const left = expires - Date.now();
|
||||
if (left <= 0 || left >= BACKGROUND_EXTEND_BELOW_MS) return null;
|
||||
return { id, deviceClientId: await AsyncStorage.getItem(deviceClientIdKey(accountId)) };
|
||||
}
|
||||
|
||||
/** The expiry to ask for when extending a subscription; servers clamp it. */
|
||||
export function wantedPushSubscriptionExpires(): string {
|
||||
return expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS);
|
||||
}
|
||||
|
||||
/** Record the expiry the server reported for the account's subscription. */
|
||||
export async function recordPushSubscriptionExpires(accountId: string, expires: string): Promise<void> {
|
||||
await AsyncStorage.setItem(subscriptionExpiresKey(accountId), expires);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down push for a single account. Destroys every JMAP subscription the
|
||||
* server holds for this device (assumes the jmapClient is currently
|
||||
@@ -1663,7 +1749,8 @@ export async function teardownPushNotifications(): Promise<void> {
|
||||
if (storedSubId) {
|
||||
// Will only succeed if the jmapClient happens to be authenticated to
|
||||
// this account right now. We don't switch the client to attempt each
|
||||
// one; the subscription will expire server-side instead (90-day TTL).
|
||||
// one; the subscription expires server-side instead (Stalwart keeps
|
||||
// one for at most seven days without a refresh).
|
||||
await destroyPushSubscription(storedSubId).catch(() => undefined);
|
||||
}
|
||||
if (relayBaseUrl && storedDcid) {
|
||||
|
||||
Reference in New Issue
Block a user