Merge branch 's10/push' into feat/stalwart-1.0

This commit is contained in:
Linus Rath
2026-10-29 02:22:54 +01:00
7 changed files with 598 additions and 13 deletions
+1
View File
@@ -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 });
+40
View File
@@ -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;
+99 -12
View File
@@ -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) {