fix: keep web push on when someone shares a mailbox with you

Stalwart refuses a push subscription's whole emailPush map as
`forbidden` when it names a mailbox shared by ACL (0.16.16 and later,
and 1.0). Bulwark put every account with mailboxes in the map, so
enabling push showed raw JSON, and the background re-sync destroyed the
working subscription after its refresh was refused and dropped its key:
push stopped without a word and could not be turned back on while the
share existed. A network error, a timeout or rate limiting during the
refresh ended it the same way.

- The map holds the primary account and the accounts that list
  `urn:ietf:params:jmap:emailpush`, which 1.0 leaves off ACL shares.
  0.16 lists it everywhere, so a refused map narrows to the accounts
  that look owned, then to the primary account, as the mobile app does.
  Refused accounts are remembered under
  `bulwark.push.emailPushRefused.v1.<id>`; Re-register and disable
  clear the list.
- A subscription ends only when a verified replacement exists, the user
  turns push off, or the server no longer has it. The old record is
  destroyed after the new one is verified. A failed listing, a network
  error or a refused method call leave it and its key alone.
- PushSubscription/set failures throw typed errors (`jmapSetError` for
  a refused object, `jmapError` for a refused call) instead of JSON in
  the message or `false`, and the client exposes `getAccountInfo`.
- Settings says that mailboxes others share don't send background
  notifications, and `forbidden` and `overQuota` read as plain text in
  settings and the onboarding prompt.
This commit is contained in:
Linus Rath
2026-09-25 17:59:17 +02:00
parent 6152e45b88
commit ffa909600c
40 changed files with 1280 additions and 119 deletions
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PUSH_NOTIFICATION_PROMPT_DELAY_MS, PushNotificationPrompt } from "../push-notification-prompt";
import { PWA_INSTALL_PROMPT_VISIBILITY_EVENT } from "../pwa-install-prompt";
import { jmapSetError } from "@/lib/jmap/jmap-error";
const mocks = vi.hoisted(() => ({
pathname: "/en",
@@ -165,6 +166,33 @@ describe("PushNotificationPrompt", () => {
);
});
it.each([
[
"forbidden",
{ type: "forbidden", description: "No access to one of the accounts in the emailPush map." },
"error_forbidden",
],
[
"overQuota",
{ type: "overQuota", description: "There are too many subscriptions, please delete some before adding a new one." },
"error_over_quota",
],
])("explains a %s refusal instead of showing the raw server error", async (_type, setError, key) => {
mocks.enableWebPush.mockRejectedValue(
jmapSetError(`PushSubscription/set create failed: ${setError.description}`, setError),
);
render(<PushNotificationPrompt />);
await advancePromptDelay();
await act(async () => {
fireEvent.click(screen.getByText("enable"));
await Promise.resolve();
});
expect(screen.getByRole("alert")).toHaveTextContent(key);
expect(screen.getByRole("alert")).not.toHaveTextContent("emailPush");
});
it("does not render when push is already enabled", async () => {
mocks.enabled = true;
render(<PushNotificationPrompt />);
+6 -1
View File
@@ -14,6 +14,7 @@ import {
resyncWebPush,
} from "@/lib/web-push";
import { resolveActiveRelayUrl } from "@/lib/push-relays";
import { jmapErrorType } from "@/lib/jmap/jmap-error";
import {
isPWAInstallPromptVisible,
PWA_INSTALL_PROMPT_VISIBILITY_EVENT,
@@ -201,7 +202,11 @@ export function PushNotificationPrompt() {
});
setShowPrompt(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to enable push");
// Readable text for the server refusals a user can act on.
const type = jmapErrorType(err);
if (type === "forbidden") setError(tPush("error_forbidden"));
else if (type === "overQuota") setError(tPush("error_over_quota"));
else setError(err instanceof Error ? err.message : "Failed to enable push");
} finally {
setIsEnabling(false);
}
@@ -1,10 +1,13 @@
import { render, waitFor } from '@testing-library/react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import { act } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NotificationSettings } from '../notification-settings';
import { useSettingsStore } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import { useEmailStore } from '@/stores/email-store';
import { jmapSetError } from '@/lib/jmap/jmap-error';
import type { Mailbox } from '@/lib/jmap/types';
import * as webPush from '@/lib/web-push';
// next-intl's useTranslations -> identity so labels are their keys.
@@ -25,6 +28,7 @@ vi.mock('@/lib/web-push', () => ({
enableWebPush: vi.fn(async () => ({ subscriptionId: 'sub-1' })),
disableWebPush: vi.fn(async () => undefined),
listPushDevices: vi.fn(async () => []),
mailAccountsWithoutPush: vi.fn(() => []),
revokePushDevice: vi.fn(async () => undefined),
serverSupportsEmailPush: vi.fn(() => true),
}));
@@ -117,3 +121,92 @@ describe('NotificationSettings - pushNotifyInboxOnly', () => {
expect(webPush.enableWebPush).not.toHaveBeenCalled();
});
});
describe('NotificationSettings - mailboxes shared by ACL', () => {
const mailbox = (id: string, accountId: string, isShared: boolean) =>
({ id, originalId: id, name: id, accountId, isShared }) as unknown as Mailbox;
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({ emailNotificationsEnabled: true, pushNotifyInboxOnly: false });
useAuthStore.setState({ client: fakeClient, username: 'me@example.com' });
usePolicyStore.setState({
...usePolicyStore.getState(),
policy: {},
isSettingLocked: () => false,
isSettingHidden: () => false,
} as never);
useEmailStore.setState({
mailboxes: [
mailbox('inbox', 'acct-1', false),
mailbox('acct-group:inbox', 'acct-group', true),
mailbox('acct-acl:projects', 'acct-acl', true),
],
});
});
it('says that mailboxes shared by other users send no background notifications', async () => {
(webPush.mailAccountsWithoutPush as ReturnType<typeof vi.fn>).mockReturnValue(['acct-acl']);
const { findByText } = render(<NotificationSettings />);
await findByText('push.shared_mailboxes_no_push');
expect(webPush.mailAccountsWithoutPush).toHaveBeenCalledWith(fakeClient, ['acct-group', 'acct-acl']);
});
it('says nothing when every shared mailbox is covered', async () => {
(webPush.mailAccountsWithoutPush as ReturnType<typeof vi.fn>).mockReturnValue([]);
const { findByText, queryByText } = render(<NotificationSettings />);
await findByText('push.status_active');
expect(queryByText('push.shared_mailboxes_no_push')).toBeNull();
});
});
describe('NotificationSettings - push errors', () => {
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({ emailNotificationsEnabled: true, pushNotifyInboxOnly: false });
useAuthStore.setState({ client: fakeClient, username: 'me@example.com' });
usePolicyStore.setState({
...usePolicyStore.getState(),
policy: {},
isSettingLocked: () => false,
isSettingHidden: () => false,
} as never);
(webPush.isWebPushEnabled as ReturnType<typeof vi.fn>).mockResolvedValue(false);
});
async function enableAndFail(error: unknown) {
(webPush.enableWebPush as ReturnType<typeof vi.fn>).mockRejectedValueOnce(error);
const view = render(<NotificationSettings />);
await view.findByText('push.status_inactive');
fireEvent.click(view.getByRole('switch', { name: 'push.enable' }));
return (await view.findByRole('alert')).textContent;
}
it('explains a forbidden refusal instead of showing the raw server error', async () => {
const text = await enableAndFail(jmapSetError('PushSubscription/set create failed: forbidden', {
type: 'forbidden',
description: 'No access to one of the accounts in the emailPush map.',
}));
expect(text).toBe('push.error_forbidden');
});
it('explains the subscription limit on overQuota', async () => {
const text = await enableAndFail(jmapSetError('PushSubscription/set create failed', {
type: 'overQuota',
description: 'There are too many subscriptions, please delete some before adding a new one.',
}));
expect(text).toBe('push.error_over_quota');
});
it('keeps the message of any other failure', async () => {
const text = await enableAndFail(new Error('Relay register failed: 502'));
expect(text).toBe('Relay register failed: 502');
});
});
+34 -9
View File
@@ -1,8 +1,10 @@
"use client";
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { useEmailStore } from '@/stores/email-store';
import { jmapErrorType } from '@/lib/jmap/jmap-error';
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
import type { NotificationSoundChoice } from '@/lib/notification-sound';
@@ -19,6 +21,7 @@ import {
isWebPushEnabled,
isWebPushSupported,
listPushDevices,
mailAccountsWithoutPush,
revokePushDevice,
serverSupportsEmailPush,
} from '@/lib/web-push';
@@ -120,6 +123,30 @@ export function NotificationSettings() {
? t('push.status_active')
: t('push.status_inactive');
// Stalwart pushes only the user's own mailboxes and their groups', so
// mailboxes shared by other users stay silent in the background (they still
// update while the app is open). Say so instead of leaving users to wonder.
const mailboxes = useEmailStore((s) => s.mailboxes);
const sharedMailboxesWithoutPush = useMemo(() => {
// Re-read after each enable run too: it may have added refused accounts.
if (!client || pushStatus.kind === 'unsupported') return false;
const accountIds = mailboxes.flatMap((m) => (m.isShared && m.accountId ? [m.accountId] : []));
return mailAccountsWithoutPush(client, accountIds).length > 0;
}, [client, mailboxes, pushStatus.kind]);
// The refusals a user can do something about get readable text; anything
// else keeps the error's own message.
const pushErrorMessage = (err: unknown, fallback: string) => {
switch (jmapErrorType(err)) {
case 'forbidden':
return t('push.error_forbidden');
case 'overQuota':
return t('push.error_over_quota');
default:
return err instanceof Error ? err.message : fallback;
}
};
// `forceRecreate` is what the Re-register button is for: without it the enable
// path just refreshes the existing subscription's expiry, so a registration
// holding stale shared-mailbox access survives the round trip (#841).
@@ -143,10 +170,7 @@ export function NotificationSettings() {
setPushStatus({ kind: 'unsupported' });
return;
}
setPushStatus({
kind: 'error',
message: err instanceof Error ? err.message : 'Failed to enable push',
});
setPushStatus({ kind: 'error', message: pushErrorMessage(err, 'Failed to enable push') });
} finally {
await refreshDevices();
}
@@ -180,10 +204,7 @@ export function NotificationSettings() {
setPushStatus({ kind: 'unsupported' });
return;
}
setPushStatus({
kind: 'error',
message: err instanceof Error ? err.message : 'Failed to update push filter',
});
setPushStatus({ kind: 'error', message: pushErrorMessage(err, 'Failed to update push filter') });
})
.finally(() => { if (!cancelled) void refreshDevices(); });
return () => { cancelled = true; };
@@ -274,6 +295,10 @@ export function NotificationSettings() {
</div>
</SettingItem>
{sharedMailboxesWithoutPush && (
<p className="text-xs text-muted-foreground">{t('push.shared_mailboxes_no_push')}</p>
)}
<SettingItem
label={t('push.relay_label')}
description={pushRelayLocked ? t('push.relay_locked_desc') : t('push.relay_desc')}
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { JMAPClient, submissionEnvelopeParameters } from '../jmap/client';
import { isJmapSetError, jmapErrorType } from '../jmap/jmap-error';
describe('submissionEnvelopeParameters', () => {
it('is empty without options', () => {
@@ -432,3 +433,190 @@ describe('JMAP client extensions', () => {
});
});
});
/**
* PushSubscription/set refusals reach web-push as typed errors: a SetError
* (jmapSetError, the server refused this subscription) apart from a refused
* method call (plain jmapError). Bodies are Stalwart's (jmap/src/push/set.rs,
* identical on 0.16.16 to 0.16.23 and 1.0).
*/
describe('JMAP client push subscriptions', () => {
const EMAIL_PUSH = 'urn:ietf:params:jmap:emailpush';
const FORBIDDEN_MAP = { type: 'forbidden', description: 'No access to one of the accounts in the emailPush map.' };
const OVER_QUOTA = {
type: 'overQuota',
description: 'There are too many subscriptions, please delete some before adding a new one.',
};
const EMAIL_PUSH_MAP = {
'acct-1': { filter: { operator: 'AND', conditions: [{ notKeyword: '$junk' }] }, properties: ['id', 'threadId'] },
b: { filter: { operator: 'AND', conditions: [{ notKeyword: '$junk' }] }, properties: ['id', 'threadId'] },
};
let fetchSpy: ReturnType<typeof vi.spyOn>;
let client: JMAPClient;
let requests: RequestBody[];
// 0.16.16 to 0.16.23 and 1.0 advertise emailpush in the session; 0.16.6
// to 0.16.15 don't. `b` is a mailbox shared by ACL as 1.0 lists it:
// read-only, without emailpush and the other owner-only capabilities.
async function setup(withEmailPush: boolean) {
fetchSpy = vi.spyOn(globalThis, 'fetch');
const session = makeSession(withEmailPush ? { [EMAIL_PUSH]: {} } : {});
if (withEmailPush) (session.capabilities as Record<string, unknown>)[EMAIL_PUSH] = {};
(session.accounts as Record<string, unknown>).b = {
name: 'userb',
isPersonal: false,
isReadOnly: true,
accountCapabilities: {
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:calendars': { mayCreateCalendar: false },
},
};
fetchSpy.mockResolvedValueOnce(jsonResponse(session));
client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
await client.connect();
fetchSpy.mockReset();
requests = [];
}
function answer(handler: (body: RequestBody) => unknown) {
fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
const body = JSON.parse(String(init?.body ?? '{}')) as RequestBody;
requests.push(body);
return jsonResponse(handler(body));
});
}
const newSubscription = {
deviceClientId: 'dcid',
url: 'https://relay.example/api/push/jmap/dcid',
types: ['EmailDelivery'],
emailPush: EMAIL_PUSH_MAP,
};
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
client?.disconnect();
vi.restoreAllMocks();
});
it('throws a forbidden SetError when Stalwart refuses the emailPush map on create (0.16.16+ and 1.0)', async () => {
await setup(true);
answer(() => ({ methodResponses: [['PushSubscription/set', { notCreated: { new: FORBIDDEN_MAP } }, '0']] }));
const err = await client.createPushSubscription(newSubscription).catch((e) => e);
expect(requests[0].using).toEqual(['urn:ietf:params:jmap:core', EMAIL_PUSH]);
expect(requests[0].methodCalls[0][1]).toEqual({ create: { new: { ...newSubscription } } });
expect(jmapErrorType(err)).toBe('forbidden');
expect(isJmapSetError(err)).toBe(true);
expect(err.message).toContain('No access to one of the accounts in the emailPush map.');
expect(err.message).not.toContain('{');
});
it('throws an overQuota SetError past the subscription limit', async () => {
await setup(true);
answer(() => ({ methodResponses: [['PushSubscription/set', { notCreated: { new: OVER_QUOTA } }, '0']] }));
const err = await client.createPushSubscription(newSubscription).catch((e) => e);
expect(jmapErrorType(err)).toBe('overQuota');
expect(isJmapSetError(err)).toBe(true);
});
it('returns the id Stalwart assigns', async () => {
await setup(true);
answer(() => ({
methodResponses: [['PushSubscription/set', {
created: { new: { id: 'k', keys: null, expires: '2026-10-02T15:00:00Z' } },
}, '0']],
}));
expect(await client.createPushSubscription(newSubscription)).toBe('k');
});
it('never puts emailpush in `using` or sends the map on 0.16.6 to 0.16.15', async () => {
await setup(false);
answer(() => ({ methodResponses: [['PushSubscription/set', { created: { new: { id: 'k' } } }, '0']] }));
await client.createPushSubscription(newSubscription);
expect(requests[0].using).toEqual(['urn:ietf:params:jmap:core']);
expect(requests[0].methodCalls[0][1]).toEqual({
create: { new: { deviceClientId: 'dcid', url: newSubscription.url, types: ['EmailDelivery'] } },
});
});
it('throws a forbidden SetError when Stalwart refuses the emailPush map on update', async () => {
await setup(true);
answer(() => ({ methodResponses: [['PushSubscription/set', { notUpdated: { k: FORBIDDEN_MAP } }, '0']] }));
const err = await client
.updatePushSubscription('k', { expires: '2026-12-01T00:00:00Z', emailPush: EMAIL_PUSH_MAP })
.catch((e) => e);
expect(requests[0].methodCalls[0][1]).toEqual({
update: { k: { expires: '2026-12-01T00:00:00Z', emailPush: EMAIL_PUSH_MAP } },
});
expect(jmapErrorType(err)).toBe('forbidden');
expect(isJmapSetError(err)).toBe(true);
});
it('throws a notFound SetError for a subscription the server no longer has', async () => {
await setup(true);
answer(() => ({ methodResponses: [['PushSubscription/set', { notUpdated: { k: { type: 'notFound' } } }, '0']] }));
const err = await client.updatePushSubscription('k', { expires: '2026-12-01T00:00:00Z' }).catch((e) => e);
expect(jmapErrorType(err)).toBe('notFound');
expect(isJmapSetError(err)).toBe(true);
});
it('resolves when the update is applied', async () => {
await setup(true);
answer(() => ({ methodResponses: [['PushSubscription/set', { updated: { k: null } }, '0']] }));
await expect(client.updatePushSubscription('k', { expires: '2026-12-01T00:00:00Z' })).resolves.toBeUndefined();
});
it('throws a plain error, not a SetError, when the whole method call is refused', async () => {
await setup(true);
answer(() => ({ methodResponses: [['error', { type: 'forbidden', description: 'Not allowed' }, '0']] }));
const createErr = await client.createPushSubscription(newSubscription).catch((e) => e);
const updateErr = await client.updatePushSubscription('k', { expires: '2026-12-01T00:00:00Z' }).catch((e) => e);
const verifyErr = await client.verifyPushSubscription('k', 'code').catch((e) => e);
for (const err of [createErr, updateErr, verifyErr]) {
expect(jmapErrorType(err)).toBe('forbidden');
expect(isJmapSetError(err)).toBe(false);
}
});
it('throws instead of listing nothing when PushSubscription/get fails', async () => {
await setup(true);
answer(() => ({ methodResponses: [['error', { type: 'serverFail' }, '0']] }));
const err = await client.listPushSubscriptions().catch((e) => e);
expect(err).toBeInstanceOf(Error);
expect(jmapErrorType(err)).toBe('serverFail');
});
it('describes each session account without flattening its capabilities', async () => {
await setup(true);
expect(client.getAccountInfo('b')).toEqual({
isPersonal: false,
isReadOnly: true,
accountCapabilities: {
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:calendars': { mayCreateCalendar: false },
},
});
expect(client.getAccountInfo('acct-1')?.isPersonal).toBe(true);
expect(client.getAccountInfo('nope')).toBeNull();
});
});
+22 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { jmapError, jmapErrorProperties, jmapErrorType } from '../jmap/jmap-error';
import { isJmapSetError, jmapError, jmapErrorProperties, jmapErrorType, jmapSetError } from '../jmap/jmap-error';
describe('jmapError', () => {
it('keeps the SetError type and properties on the thrown error', () => {
@@ -37,3 +37,24 @@ describe('jmapError', () => {
expect(jmapErrorProperties(null)).toEqual([]);
});
});
describe('jmapSetError', () => {
it('tells a refused object apart from a refused method call of the same type', () => {
const setError = jmapSetError('PushSubscription/set create failed', {
type: 'forbidden',
description: 'No access to one of the accounts in the emailPush map.',
});
const methodError = jmapError('PushSubscription/set failed', { type: 'forbidden' });
expect(jmapErrorType(setError)).toBe('forbidden');
expect(isJmapSetError(setError)).toBe(true);
expect(jmapErrorType(methodError)).toBe('forbidden');
expect(isJmapSetError(methodError)).toBe(false);
});
it('is false for anything that is not a SetError', () => {
expect(isJmapSetError(new Error('Failed to fetch'))).toBe(false);
expect(isJmapSetError(undefined)).toBe(false);
expect(isJmapSetError({ jmapSetError: 'yes' })).toBe(false);
});
});
+456 -20
View File
@@ -1,11 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { EmailPushConfig, PushSubscription as JmapPushSubscription } from '@/lib/jmap/types';
import type { AccountInfo, EmailPushConfig, PushSubscription as JmapPushSubscription } from '@/lib/jmap/types';
import { jmapError, jmapErrorType, jmapSetError } from '@/lib/jmap/jmap-error';
import { RateLimitError, RequestTimeoutError } from '@/lib/jmap/client';
import {
buildEmailPushConfig,
disableWebPush,
enableWebPush,
isWebPushEnabled,
listPushDevices,
mailAccountsWithoutPush,
resetWebPushResyncState,
resyncWebPush,
revokePushDevice,
@@ -18,6 +22,7 @@ const OTHER_DEVICE = '11111111222222223333333344444444';
const DEVICE_KEY = `bulwark.push.deviceClientId.v1.${ACCOUNT_ID}`;
const SUB_KEY = `bulwark.push.subscriptionId.v1.${ACCOUNT_ID}`;
const REFUSED_KEY = `bulwark.push.emailPushRefused.v1.${ACCOUNT_ID}`;
function sub(
id: string,
@@ -40,9 +45,15 @@ interface FakeClient extends Partial<IJMAPClient> {
}
const EMAIL_PUSH_CAP = 'urn:ietf:params:jmap:emailpush';
const MAIL_CAP = 'urn:ietf:params:jmap:mail';
const JUNK_ID = 'mb-junk';
const SHARED_ACCOUNT_ID = 'acct-shared';
const SHARED_JUNK_ID = 'mb-shared-junk';
// A group the user belongs to: Stalwart counts its members as owners, so the
// subscription fans out to it and it belongs in the emailPush map.
const GROUP_ACCOUNT_ID = 'acct-group';
const GROUP_JUNK_ID = 'mb-group-junk';
// Another user's account that shared a folder with this user by ACL: never
// in the fan-out, and refused in the emailPush map.
const ACL_ACCOUNT_ID = 'acct-acl';
// The delivery filter enableWebPush is expected to install for ACCOUNT_ID
// given the mailboxes makeClient hands out.
@@ -55,21 +66,92 @@ const EXPECTED_EMAIL_PUSH: Record<string, EmailPushConfig> = {
properties: ['id', 'threadId'],
urgency: 'high',
},
[SHARED_ACCOUNT_ID]: {
[GROUP_ACCOUNT_ID]: {
filter: {
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: [SHARED_JUNK_ID] }],
conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: [GROUP_JUNK_ID] }],
},
properties: ['id', 'threadId'],
urgency: 'high',
},
};
// The create flags Stalwart sets in accountCapabilities (to_account_capabilities
// in jmap-proto request/capability.rs): true on the user's own account and
// their groups, false on accounts shared with them by ACL. 0.16 and 1.0 alike.
function collectionCaps(owned: boolean): Record<string, unknown> {
return {
'urn:ietf:params:jmap:calendars': { maxCalendarsPerEvent: null, mayCreateCalendar: owned },
'urn:ietf:params:jmap:contacts': { maxAddressBooksPerCard: null, mayCreateAddressBook: owned },
'urn:ietf:params:jmap:filenode': { mayCreateTopLevelFileNode: owned },
};
}
function account(isPersonal: boolean, isReadOnly: boolean, caps: Record<string, unknown>): AccountInfo {
return { isPersonal, isReadOnly, accountCapabilities: caps };
}
// Stalwart 0.16.16 to 0.16.23 (jmap/src/api/session.rs): every secondary
// account lists emailpush, ACL shares included, and all of them are
// isPersonal:false, isReadOnly:false.
const SESSION_0_16: Record<string, AccountInfo> = {
[ACCOUNT_ID]: account(true, false, { [MAIL_CAP]: {}, [EMAIL_PUSH_CAP]: {}, ...collectionCaps(true) }),
[GROUP_ACCOUNT_ID]: account(false, false, { [MAIL_CAP]: {}, [EMAIL_PUSH_CAP]: {}, ...collectionCaps(true) }),
[ACL_ACCOUNT_ID]: account(false, false, { [MAIL_CAP]: {}, [EMAIL_PUSH_CAP]: {}, ...collectionCaps(false) }),
};
// Stalwart 0.16.6 to 0.16.15: no emailpush anywhere.
const SESSION_0_16_15: Record<string, AccountInfo> = {
[ACCOUNT_ID]: account(true, false, { [MAIL_CAP]: {}, ...collectionCaps(true) }),
[GROUP_ACCOUNT_ID]: account(false, false, { [MAIL_CAP]: {}, ...collectionCaps(true) }),
[ACL_ACCOUNT_ID]: account(false, false, { [MAIL_CAP]: {}, ...collectionCaps(false) }),
};
// Stalwart 1.0: accounts the user isn't a member of lose submission,
// vacationresponse, sieve, quota and emailpush; isReadOnly is computed (true
// for a read-only share); every account lists principals:owner.
const owner = (principalId: string) => ({
'urn:ietf:params:jmap:principals:owner': { accountIdForPrincipal: ACCOUNT_ID, principalId },
});
const SESSION_1_0: Record<string, AccountInfo> = {
[ACCOUNT_ID]: account(true, false, {
[MAIL_CAP]: {}, [EMAIL_PUSH_CAP]: {}, ...collectionCaps(true), ...owner(ACCOUNT_ID),
}),
[GROUP_ACCOUNT_ID]: account(false, false, {
[MAIL_CAP]: {}, [EMAIL_PUSH_CAP]: {}, ...collectionCaps(true), ...owner(GROUP_ACCOUNT_ID),
}),
[ACL_ACCOUNT_ID]: account(false, true, { [MAIL_CAP]: {}, ...collectionCaps(false), ...owner(ACL_ACCOUNT_ID) }),
};
// Stalwart's refusal of an emailPush map naming a non-member (push/set.rs
// parse_email_push), as client.ts throws it for notCreated.new / notUpdated.
const FORBIDDEN_MAP = { type: 'forbidden', description: 'No access to one of the accounts in the emailPush map.' };
const OVER_QUOTA = {
type: 'overQuota',
description: 'There are too many subscriptions, please delete some before adding a new one.',
};
const refusedMap = () => jmapSetError(`PushSubscription/set failed: ${FORBIDDEN_MAP.description}`, FORBIDDEN_MAP);
function makeClient(
subs: JmapPushSubscription[],
options: { emailPushCapability?: boolean } = {},
options: {
emailPushCapability?: boolean;
// The session's accounts; the primary and the group by default.
accounts?: Record<string, AccountInfo>;
// Accounts the server accepts in an emailPush map: Stalwart's is_member,
// the user's own account and their groups.
members?: string[];
} = {},
): FakeClient & IJMAPClient {
const destroyed: string[] = [];
const accounts = options.accounts ?? {
[ACCOUNT_ID]: SESSION_0_16[ACCOUNT_ID],
[GROUP_ACCOUNT_ID]: SESSION_0_16[GROUP_ACCOUNT_ID],
};
const members = options.members ?? [ACCOUNT_ID, GROUP_ACCOUNT_ID];
const accept = (emailPush?: Record<string, EmailPushConfig> | null) => {
if (emailPush && Object.keys(emailPush).some((id) => !members.includes(id))) throw refusedMap();
};
const mailbox = (id: string, role: string | undefined, accountId: string) => ({
id: accountId === ACCOUNT_ID ? id : `${accountId}:${id}`,
originalId: id,
@@ -82,17 +164,26 @@ function makeClient(
destroyed,
getAccountId: () => ACCOUNT_ID,
getCapabilities: () => (options.emailPushCapability ? { [EMAIL_PUSH_CAP]: {} } : {}),
hasAccountCapability: (capability: string, accountId?: string) =>
capability in (accounts[accountId || ACCOUNT_ID]?.accountCapabilities ?? {}),
getAccountInfo: (accountId: string) => accounts[accountId] ?? null,
getAllMailboxes: vi.fn(async () => [
mailbox('mb-inbox', 'inbox', ACCOUNT_ID),
mailbox(JUNK_ID, 'junk', ACCOUNT_ID),
mailbox('mb-archive', undefined, ACCOUNT_ID),
mailbox('mb-shared-inbox', 'inbox', SHARED_ACCOUNT_ID),
mailbox(SHARED_JUNK_ID, 'junk', SHARED_ACCOUNT_ID),
mailbox('mb-group-inbox', 'inbox', GROUP_ACCOUNT_ID),
mailbox(GROUP_JUNK_ID, 'junk', GROUP_ACCOUNT_ID),
...(accounts[ACL_ACCOUNT_ID] ? [mailbox('mb-acl-projects', undefined, ACL_ACCOUNT_ID)] : []),
]),
listPushSubscriptions: vi.fn(async () => subs.filter((s) => !destroyed.includes(s.id))),
createPushSubscription: vi.fn(async () => 'push-new'),
createPushSubscription: vi.fn(async (params: { emailPush?: Record<string, EmailPushConfig> }) => {
accept(params.emailPush);
return 'push-new';
}),
verifyPushSubscription: vi.fn(async () => undefined),
updatePushSubscription: vi.fn(async () => true),
updatePushSubscription: vi.fn(async (_id: string, patch: { emailPush?: Record<string, EmailPushConfig> | null }) => {
accept(patch.emailPush);
}),
destroyPushSubscription: vi.fn(async (id: string) => {
destroyed.push(id);
}),
@@ -100,6 +191,18 @@ function makeClient(
return client as unknown as FakeClient & IJMAPClient;
}
// The accounts an emailPush map names, sorted.
const mapKeys = (emailPush: Record<string, EmailPushConfig> | null | undefined) =>
Object.keys(emailPush ?? {}).sort();
const createCalls = (client: IJMAPClient) =>
(client.createPushSubscription as ReturnType<typeof vi.fn>).mock.calls.map(
(c) => c[0] as { emailPush?: Record<string, EmailPushConfig> },
);
const updateCalls = (client: IJMAPClient) =>
(client.updatePushSubscription as ReturnType<typeof vi.fn>).mock.calls.map(
(c) => c[1] as { emailPush?: Record<string, EmailPushConfig> },
);
// enableWebPush needs a browser that supports push; jsdom has none of it.
function installPushBrowser() {
const browserSub = {
@@ -192,6 +295,11 @@ describe('enableWebPush', () => {
expect(client.verifyPushSubscription).toHaveBeenCalledWith('push-new', 'code-123');
expect(result.subscriptionId).toBe('push-new');
expect(localStorage.getItem(SUB_KEY)).toBe('push-new');
// The old record keeps delivering until its replacement is verified.
const destroy = client.destroyPushSubscription as ReturnType<typeof vi.fn>;
const verify = client.verifyPushSubscription as ReturnType<typeof vi.fn>;
const destroyOld = destroy.mock.invocationCallOrder[destroy.mock.calls.findIndex((c) => c[0] === 'push-old')];
expect(destroyOld).toBeGreaterThan(verify.mock.invocationCallOrder[0]);
});
it('leaves another device alone when the relay cannot vouch for it', async () => {
@@ -381,9 +489,9 @@ describe('buildEmailPushConfig inbox-only mode', () => {
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-inbox' }],
});
expect(config[SHARED_ACCOUNT_ID].filter).toEqual({
expect(config[GROUP_ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-shared-inbox' }],
conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-group-inbox' }],
});
});
@@ -396,13 +504,14 @@ describe('buildEmailPushConfig inbox-only mode', () => {
await expect(buildEmailPushConfig(client, true)).rejects.toThrow(/No Inbox mailbox/);
});
it('mutes a shared account whose Inbox we cannot see instead of failing', async () => {
// Someone shared a single folder: the account shows up with no Inbox role.
// Leaving it out of the map would let the server push it unfiltered.
it('mutes a group account whose Inbox we cannot see instead of failing', async () => {
// The group's Inbox isn't visible to us, only a folder. The server fans
// the subscription out to the group anyway, so leaving it out of the map
// would let it push the group unfiltered.
const client = makeClient([], { emailPushCapability: true });
client.getAllMailboxes = vi.fn(async () => [
{ id: 'mb-inbox', originalId: 'mb-inbox', name: 'inbox', role: 'inbox', accountId: ACCOUNT_ID },
{ id: `${SHARED_ACCOUNT_ID}:mb-proj`, originalId: 'mb-proj', name: 'Projects', accountId: SHARED_ACCOUNT_ID },
{ id: `${GROUP_ACCOUNT_ID}:mb-proj`, originalId: 'mb-proj', name: 'Projects', accountId: GROUP_ACCOUNT_ID },
]) as unknown as IJMAPClient['getAllMailboxes'];
const config = await buildEmailPushConfig(client, true);
@@ -411,7 +520,7 @@ describe('buildEmailPushConfig inbox-only mode', () => {
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-inbox' }],
});
expect(config[SHARED_ACCOUNT_ID].filter).toEqual({
expect(config[GROUP_ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }],
});
@@ -430,19 +539,334 @@ describe('buildEmailPushConfig inbox-only mode', () => {
const client = makeClient([], { emailPushCapability: true });
client.getAllMailboxes = vi.fn(async () => [
{ id: 'mb-inbox', originalId: 'mb-inbox', name: 'inbox', role: 'inbox', accountId: ACCOUNT_ID },
{ id: `${SHARED_ACCOUNT_ID}:mb-si`, originalId: 'mb-si', name: 'inbox', role: 'inbox', accountId: SHARED_ACCOUNT_ID },
{ id: `${GROUP_ACCOUNT_ID}:mb-si`, originalId: 'mb-si', name: 'inbox', role: 'inbox', accountId: GROUP_ACCOUNT_ID },
]) as unknown as IJMAPClient['getAllMailboxes'];
const config = await buildEmailPushConfig(client, false);
expect(Object.keys(config).sort()).toEqual([ACCOUNT_ID, SHARED_ACCOUNT_ID].sort());
expect(config[SHARED_ACCOUNT_ID].filter).toEqual({
expect(Object.keys(config).sort()).toEqual([ACCOUNT_ID, GROUP_ACCOUNT_ID].sort());
expect(config[GROUP_ACCOUNT_ID].filter).toEqual({
operator: 'AND',
conditions: [{ notKeyword: '$junk' }],
});
});
});
describe('buildEmailPushConfig with a mailbox shared by ACL', () => {
it('leaves the ACL share out on Stalwart 1.0, which lists emailpush only on owned accounts', async () => {
const client = makeClient([], { emailPushCapability: true, accounts: SESSION_1_0 });
const config = await buildEmailPushConfig(client, false);
expect(mapKeys(config)).toEqual([ACCOUNT_ID, GROUP_ACCOUNT_ID].sort());
});
it('keeps the ACL share on Stalwart 0.16, which lists emailpush on every account', async () => {
// enableWebPush narrows the map once the server refuses it.
const client = makeClient([], { emailPushCapability: true, accounts: SESSION_0_16 });
const config = await buildEmailPushConfig(client, false);
expect(mapKeys(config)).toEqual([ACCOUNT_ID, ACL_ACCOUNT_ID, GROUP_ACCOUNT_ID].sort());
});
});
// Stalwart refuses the whole emailPush map as `forbidden` once it names a
// mailbox shared by ACL. A refused refresh used to destroy the working
// subscription, and the replacement was refused too, so push went off.
describe('enableWebPush with a mailbox shared by ACL', () => {
const healthy = () => sub('push-old', THIS_DEVICE);
beforeEach(() => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
installFetch({});
});
it('narrows a refused map on refresh instead of destroying the working subscription on Stalwart 0.16', async () => {
localStorage.setItem(SUB_KEY, 'push-old');
const client = makeClient([healthy()], { emailPushCapability: true, accounts: SESSION_0_16 });
const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(result.subscriptionId).toBe('push-old');
const updates = updateCalls(client);
expect(updates).toHaveLength(2);
expect(mapKeys(updates[0].emailPush)).toEqual([ACCOUNT_ID, ACL_ACCOUNT_ID, GROUP_ACCOUNT_ID].sort());
// The group keeps its junk filter; only the ACL share is dropped.
expect(mapKeys(updates[1].emailPush)).toEqual([ACCOUNT_ID, GROUP_ACCOUNT_ID].sort());
expect(client.destroyPushSubscription).not.toHaveBeenCalled();
expect(client.createPushSubscription).not.toHaveBeenCalled();
expect(localStorage.getItem(SUB_KEY)).toBe('push-old');
expect(JSON.parse(localStorage.getItem(REFUSED_KEY)!)).toEqual([ACL_ACCOUNT_ID]);
});
it('leaves a remembered refusal out of the map on the next run on Stalwart 0.16', async () => {
localStorage.setItem(SUB_KEY, 'push-old');
localStorage.setItem(REFUSED_KEY, JSON.stringify([ACL_ACCOUNT_ID]));
const client = makeClient([healthy()], { emailPushCapability: true, accounts: SESSION_0_16 });
await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
const updates = updateCalls(client);
expect(updates).toHaveLength(1);
expect(mapKeys(updates[0].emailPush)).toEqual([ACCOUNT_ID, GROUP_ACCOUNT_ID].sort());
});
it('makes no refused call at all once the stored map matches on Stalwart 0.16', async () => {
localStorage.setItem(SUB_KEY, 'push-old');
localStorage.setItem(REFUSED_KEY, JSON.stringify([ACL_ACCOUNT_ID]));
const client = makeClient(
[sub('push-old', THIS_DEVICE, { emailPush: EXPECTED_EMAIL_PUSH })],
{ emailPushCapability: true, accounts: SESSION_0_16 },
);
await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(client.updatePushSubscription).not.toHaveBeenCalled();
});
it('falls back to the primary account alone when the group is refused too on Stalwart 0.16', async () => {
localStorage.setItem(SUB_KEY, 'push-old');
const client = makeClient([healthy()], {
emailPushCapability: true,
accounts: SESSION_0_16,
members: [ACCOUNT_ID],
});
const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(result.subscriptionId).toBe('push-old');
const updates = updateCalls(client);
expect(updates).toHaveLength(3);
expect(mapKeys(updates[2].emailPush)).toEqual([ACCOUNT_ID]);
expect(JSON.parse(localStorage.getItem(REFUSED_KEY)!).sort()).toEqual([ACL_ACCOUNT_ID, GROUP_ACCOUNT_ID].sort());
expect(client.destroyPushSubscription).not.toHaveBeenCalled();
});
it('narrows a refused map when creating a subscription on Stalwart 0.16', async () => {
const client = makeClient([], { emailPushCapability: true, accounts: SESSION_0_16 });
const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(result.subscriptionId).toBe('push-new');
const creates = createCalls(client);
expect(creates).toHaveLength(2);
expect(mapKeys(creates[1].emailPush)).toEqual([ACCOUNT_ID, GROUP_ACCOUNT_ID].sort());
expect(localStorage.getItem(SUB_KEY)).toBe('push-new');
expect(JSON.parse(localStorage.getItem(REFUSED_KEY)!)).toEqual([ACL_ACCOUNT_ID]);
});
it('leaves the ACL share out without a refused call on Stalwart 1.0', async () => {
const client = makeClient([], { emailPushCapability: true, accounts: SESSION_1_0 });
const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(result.subscriptionId).toBe('push-new');
const creates = createCalls(client);
expect(creates).toHaveLength(1);
expect(creates[0].emailPush).toEqual(EXPECTED_EMAIL_PUSH);
expect(localStorage.getItem(REFUSED_KEY)).toBeNull();
});
it('sends no map and never narrows on Stalwart 0.16.15, which has no emailpush', async () => {
const client = makeClient([], { emailPushCapability: false, accounts: SESSION_0_16_15 });
await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
const creates = createCalls(client);
expect(creates).toHaveLength(1);
expect(creates[0]).not.toHaveProperty('emailPush');
expect(localStorage.getItem(REFUSED_KEY)).toBeNull();
});
it('does not narrow the map on overQuota', async () => {
const client = makeClient([], { emailPushCapability: true, accounts: SESSION_0_16 });
client.createPushSubscription = vi.fn(async () => {
throw jmapSetError(`PushSubscription/set create failed: ${OVER_QUOTA.description}`, OVER_QUOTA);
}) as unknown as IJMAPClient['createPushSubscription'];
const err = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).catch((e) => e);
expect(jmapErrorType(err)).toBe('overQuota');
expect(client.createPushSubscription).toHaveBeenCalledTimes(1);
expect(localStorage.getItem(REFUSED_KEY)).toBeNull();
});
it('does not narrow the map when the whole method call is refused', async () => {
// A method-level `forbidden` (the account may not use push at all) is
// not about the map: a narrower one fails the same way.
localStorage.setItem(SUB_KEY, 'push-old');
const client = makeClient([healthy()], { emailPushCapability: true, accounts: SESSION_0_16 });
client.updatePushSubscription = vi.fn(async () => {
throw jmapError('PushSubscription/set failed: forbidden', { type: 'forbidden' });
}) as unknown as IJMAPClient['updatePushSubscription'];
await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toThrow();
expect(client.updatePushSubscription).toHaveBeenCalledTimes(1);
expect(client.destroyPushSubscription).not.toHaveBeenCalled();
expect(localStorage.getItem(SUB_KEY)).toBe('push-old');
});
it('forgets remembered refusals on forceRecreate and learns them again', async () => {
localStorage.setItem(SUB_KEY, 'push-old');
localStorage.setItem(REFUSED_KEY, JSON.stringify([ACL_ACCOUNT_ID, GROUP_ACCOUNT_ID]));
const client = makeClient([healthy()], { emailPushCapability: true, accounts: SESSION_0_16 });
await enableWebPush({ client, relayBaseUrl: RELAY, forceRecreate: true, inboxOnly: false });
const creates = createCalls(client);
expect(mapKeys(creates[0].emailPush)).toEqual([ACCOUNT_ID, ACL_ACCOUNT_ID, GROUP_ACCOUNT_ID].sort());
expect(mapKeys(creates[1].emailPush)).toEqual([ACCOUNT_ID, GROUP_ACCOUNT_ID].sort());
expect(JSON.parse(localStorage.getItem(REFUSED_KEY)!)).toEqual([ACL_ACCOUNT_ID]);
});
it('keeps the working subscription and its key when the replacement is refused', async () => {
localStorage.setItem(SUB_KEY, 'push-old');
const client = makeClient([healthy()], {
emailPushCapability: true,
accounts: SESSION_0_16,
members: [],
});
const err = await enableWebPush({ client, relayBaseUrl: RELAY, forceRecreate: true, inboxOnly: false })
.catch((e) => e);
expect(jmapErrorType(err)).toBe('forbidden');
expect(client.destroyed).not.toContain('push-old');
expect(localStorage.getItem(SUB_KEY)).toBe('push-old');
});
it('replaces a subscription whose refresh the server refuses, and destroys the old one after verification', async () => {
// `notFound`: the server dropped the record between the listing and the
// update.
localStorage.setItem(SUB_KEY, 'push-old');
const client = makeClient([healthy()], { emailPushCapability: true, accounts: SESSION_1_0 });
client.updatePushSubscription = vi.fn(async () => {
throw jmapSetError('PushSubscription/set update failed: notFound', { type: 'notFound' });
}) as unknown as IJMAPClient['updatePushSubscription'];
const result = await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(result.subscriptionId).toBe('push-new');
expect(localStorage.getItem(SUB_KEY)).toBe('push-new');
const destroy = client.destroyPushSubscription as ReturnType<typeof vi.fn>;
const verify = client.verifyPushSubscription as ReturnType<typeof vi.fn>;
expect(destroy).toHaveBeenCalledWith('push-old');
expect(destroy.mock.invocationCallOrder[0]).toBeGreaterThan(verify.mock.invocationCallOrder[0]);
});
});
// Only a verified replacement, the user turning push off, or the server no
// longer holding the subscription may end it. A failure that says nothing
// about the subscription must leave it and its key alone.
describe('enableWebPush when the server cannot be asked', () => {
beforeEach(() => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
installFetch({});
});
it.each([
['a network error', () => new TypeError('Failed to fetch')],
['a timeout', () => new RequestTimeoutError(30_000)],
['rate limiting', () => new RateLimitError(60_000)],
])('destroys nothing when the refresh fails with %s', async (_label, failure) => {
const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true, accounts: SESSION_1_0 });
client.updatePushSubscription = vi.fn(async () => {
throw failure();
}) as unknown as IJMAPClient['updatePushSubscription'];
await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toThrow();
expect(client.updatePushSubscription).toHaveBeenCalledTimes(1);
expect(client.destroyPushSubscription).not.toHaveBeenCalled();
expect(client.createPushSubscription).not.toHaveBeenCalled();
expect(localStorage.getItem(SUB_KEY)).toBe('push-old');
});
it('stops without touching local state when the subscriptions cannot be listed', async () => {
const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true });
client.listPushSubscriptions = vi.fn(async () => {
throw new TypeError('Failed to fetch');
}) as unknown as IJMAPClient['listPushSubscriptions'];
await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toThrow();
expect(client.destroyPushSubscription).not.toHaveBeenCalled();
expect(client.createPushSubscription).not.toHaveBeenCalled();
expect(localStorage.getItem(SUB_KEY)).toBe('push-old');
});
it('keeps push enabled after a failed re-sync', async () => {
// The worst case: the server refuses every map and the replacement too.
const client = makeClient([sub('push-old', THIS_DEVICE)], {
emailPushCapability: true,
accounts: SESSION_0_16,
members: [],
});
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false);
expect(client.destroyed).not.toContain('push-old');
expect(await isWebPushEnabled(ACCOUNT_ID)).toBe(true);
});
it('keeps push enabled after a re-sync that could not reach the server', async () => {
const client = makeClient([sub('push-old', THIS_DEVICE)], { emailPushCapability: true, accounts: SESSION_1_0 });
client.updatePushSubscription = vi.fn(async () => {
throw new TypeError('Failed to fetch');
}) as unknown as IJMAPClient['updatePushSubscription'];
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false);
expect(client.destroyPushSubscription).not.toHaveBeenCalled();
expect(await isWebPushEnabled(ACCOUNT_ID)).toBe(true);
});
});
// The settings pane tells users that mailboxes shared with them stay silent
// in the background (Stalwart never fans push out to them).
describe('mailAccountsWithoutPush', () => {
it('names the account without emailpush on Stalwart 1.0, and nothing else', () => {
// Groups count as owned even without the create flags: on 1.0 the
// capability list is exact.
const mailOnly = { ...SESSION_1_0, [GROUP_ACCOUNT_ID]: account(false, false, { [MAIL_CAP]: {}, [EMAIL_PUSH_CAP]: {} }) };
const client = makeClient([], { emailPushCapability: true, accounts: mailOnly });
expect(mailAccountsWithoutPush(client, [GROUP_ACCOUNT_ID, ACL_ACCOUNT_ID])).toEqual([ACL_ACCOUNT_ID]);
});
it('names the account that does not look owned on Stalwart 0.16', () => {
const client = makeClient([], { emailPushCapability: true, accounts: SESSION_0_16 });
expect(mailAccountsWithoutPush(client, [GROUP_ACCOUNT_ID, ACL_ACCOUNT_ID])).toEqual([ACL_ACCOUNT_ID]);
});
it('names the accounts the server refused on Stalwart 0.16', () => {
localStorage.setItem(REFUSED_KEY, JSON.stringify([GROUP_ACCOUNT_ID]));
const client = makeClient([], { emailPushCapability: true, accounts: SESSION_0_16 });
expect(mailAccountsWithoutPush(client, [GROUP_ACCOUNT_ID, ACL_ACCOUNT_ID])).toEqual([
GROUP_ACCOUNT_ID,
ACL_ACCOUNT_ID,
]);
});
it('names the account that does not look owned on Stalwart 0.16.15', () => {
const client = makeClient([], { emailPushCapability: false, accounts: SESSION_0_16_15 });
expect(mailAccountsWithoutPush(client, [GROUP_ACCOUNT_ID, ACL_ACCOUNT_ID])).toEqual([ACL_ACCOUNT_ID]);
});
it('is empty when every mailbox is the user\'s own or a group\'s', () => {
const client = makeClient([], { emailPushCapability: true, accounts: SESSION_1_0 });
expect(mailAccountsWithoutPush(client, [ACCOUNT_ID, GROUP_ACCOUNT_ID, GROUP_ACCOUNT_ID])).toEqual([]);
expect(mailAccountsWithoutPush(client, [])).toEqual([]);
});
});
describe('disableWebPush', () => {
it('destroys every subscription this device owns, not just the recorded id', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
@@ -463,6 +887,18 @@ describe('disableWebPush', () => {
expect(localStorage.getItem(DEVICE_KEY)).toBe(THIS_DEVICE);
});
it('forgets the accounts the server refused, so enabling again starts from the full map', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-tracked');
localStorage.setItem(REFUSED_KEY, JSON.stringify([ACL_ACCOUNT_ID]));
const client = makeClient([sub('push-tracked', THIS_DEVICE)]);
installFetch({});
await disableWebPush({ client, relayBaseUrl: RELAY });
expect(localStorage.getItem(REFUSED_KEY)).toBeNull();
});
it('drops the relay registration for this device', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
const client = makeClient([]);
+13 -2
View File
@@ -1,5 +1,5 @@
import type { IJMAPClient, KeywordDiscoveryResult, KeywordMigration } from '@/lib/jmap/client-interface';
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult, SharedAccount } from '@/lib/jmap/types';
import type { AccountInfo, Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult, SharedAccount } from '@/lib/jmap/types';
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
import { getDemoData, type DemoData } from './demo-data';
import { generateDemoId } from './demo-utils';
@@ -71,6 +71,15 @@ export class DemoJMAPClient implements IJMAPClient {
return capability === 'urn:ietf:params:jmap:submission';
}
getAccountInfo(accountId: string): AccountInfo | null {
if (accountId !== this.getAccountId()) return null;
return {
isPersonal: true,
isReadOnly: false,
accountCapabilities: { 'urn:ietf:params:jmap:submission': {} },
};
}
getCapabilities(): Record<string, unknown> {
return {
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500 },
@@ -125,7 +134,9 @@ export class DemoJMAPClient implements IJMAPClient {
async verifyPushSubscription(): Promise<void> {
throw new Error('Push subscriptions are not available in demo mode');
}
async updatePushSubscription(): Promise<boolean> { return false; }
async updatePushSubscription(): Promise<void> {
throw new Error('Push subscriptions are not available in demo mode');
}
async destroyPushSubscription(): Promise<void> { /* no-op */ }
// ── Quota ─────────────────────────────────────────────────────
+8 -1
View File
@@ -2,6 +2,7 @@ import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountSta
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { FileNameRules } from "@/lib/file-name-rules";
import type { SortLevel } from "@/lib/message-list-order";
import type { AccountInfo } from "./types";
/** What `migrateKeyword` managed to do. */
export interface KeywordMigration {
@@ -68,6 +69,8 @@ export interface IJMAPClient {
// ── Capabilities ──────────────────────────────────────────────
getCapabilities(): Record<string, unknown>;
hasAccountCapability(capability: string, accountId?: string): boolean;
/** How the session describes an account; null when it doesn't list the id. */
getAccountInfo(accountId: string): AccountInfo | null;
/** FileNode naming rules the server publishes (Stalwart 0.16.6+), if any. */
getFileNameRules?(accountId?: string): FileNameRules | null;
getMaxSizeUpload(): number;
@@ -101,6 +104,10 @@ export interface IJMAPClient {
// ── PushSubscription (RFC 8620 §7.2) ───────────────────────────
// Browser-driven Web Push setup: register a relay URL the JMAP server can
// forward StateChange events to. Mobile uses the same primitives.
// A refused object throws a jmapSetError carrying the SetError type
// (`forbidden`, `overQuota`, `notFound`...); a refused method call throws a
// plain jmapError, so callers can tell the two apart (isJmapSetError).
/** Throws when the server can't be asked - never an empty list instead. */
listPushSubscriptions(): Promise<PushSubscription[]>;
createPushSubscription(params: {
deviceClientId: string;
@@ -115,7 +122,7 @@ export interface IJMAPClient {
updatePushSubscription(
id: string,
patch: { expires?: string; types?: string[]; emailPush?: Record<string, EmailPushConfig> | null },
): Promise<boolean>;
): Promise<void>;
destroyPushSubscription(id: string): Promise<void>;
// ── Quota ─────────────────────────────────────────────────────
+70 -20
View File
@@ -15,12 +15,13 @@ import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox";
import { decodeFileNodeName } from "./filenode-name";
import { contactFromWire, contactToWire } from "./contact-wire";
import { SchedulingDeniedError } from "./scheduling-error";
import { jmapError, jmapErrorType, type JmapErrorInfo } from "./jmap-error";
import { jmapError, jmapErrorType, jmapSetError, type JmapErrorInfo } from "./jmap-error";
export { SchedulingDeniedError };
import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/file-name-rules";
import { getEffectiveTimeZone, toLocalDateTime } from "@/lib/timezone";
import { buildEmailSort, compareEmails, hasKeywordLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order";
import type { AccountInfo } from "./types";
// Cap for the follow-up Email/get issued when a displayed body part comes
// back truncated at the normal 256000-byte limit (see refetchTruncatedBodyValues
@@ -5022,6 +5023,16 @@ export class JMAPClient implements IJMAPClient {
return !!caps && capability in caps;
}
getAccountInfo(accountId: string): AccountInfo | null {
const account = this.accounts[accountId];
if (!account) return null;
return {
isPersonal: account.isPersonal === true,
isReadOnly: account.isReadOnly === true,
accountCapabilities: account.accountCapabilities ?? {},
};
}
getMaxSizeAttachmentsPerEmail(): number {
const mail = this.getAccountCapability("urn:ietf:params:jmap:mail") as { maxSizeAttachmentsPerEmail?: number } | undefined;
return mail?.maxSizeAttachmentsPerEmail || 0;
@@ -9850,6 +9861,18 @@ export class JMAPClient implements IJMAPClient {
: ['urn:ietf:params:jmap:core'];
}
// The body of the one PushSubscription call in a response. A method-level
// error throws a plain jmapError: the server refused the whole call, which
// says nothing about any one subscription.
private pushMethodResult<T>(response: JMAPResponse, method: string): T {
const [name, body] = response.methodResponses?.[0] ?? [];
if (name !== method) {
const error = body as JmapErrorInfo | undefined;
throw jmapError(`${method} failed: ${error?.description || error?.type || 'no response'}`, error);
}
return (body ?? {}) as T;
}
async listPushSubscriptions(): Promise<PushSubscription[]> {
const emailPush = this.hasEmailPushCapability();
// `emailPush` is not in the server's default property set, so ask for it
@@ -9862,8 +9885,9 @@ export class JMAPClient implements IJMAPClient {
[['PushSubscription/get', args, '0']],
this.pushUsing(emailPush),
);
const [, body] = response.methodResponses[0] ?? [];
return ((body as { list?: PushSubscription[] } | undefined)?.list) ?? [];
// Throws rather than answering []: to web-push an empty list means "the
// server dropped our subscription", which forgets a working registration.
return this.pushMethodResult<{ list?: PushSubscription[] }>(response, 'PushSubscription/get').list ?? [];
}
async createPushSubscription(params: {
@@ -9886,14 +9910,22 @@ export class JMAPClient implements IJMAPClient {
[['PushSubscription/set', { create: { new: created } }, '0']],
this.pushUsing(withEmailPush),
);
const [, body] = response.methodResponses[0] ?? [];
const result = (body as { created?: { new?: { id?: string } }; notCreated?: { new?: unknown } } | undefined);
const id = result?.created?.new?.id;
if (!id) {
throw new Error(
`PushSubscription/set create failed: ${JSON.stringify(result?.notCreated?.new ?? body)}`,
const result = this.pushMethodResult<{
created?: { new?: { id?: string } };
notCreated?: { new?: JmapErrorInfo };
}>(response, 'PushSubscription/set');
// Stalwart refuses the whole emailPush map as `forbidden` when it names an
// account the user doesn't own, and answers `overQuota` past its
// subscription limit (15 per account by default).
const refused = result.notCreated?.new;
if (refused) {
throw jmapSetError(
`PushSubscription/set create failed: ${refused.description || refused.type || 'unknown error'}`,
refused,
);
}
const id = result.created?.new?.id;
if (!id) throw new Error('PushSubscription/set create returned no id');
return id;
}
@@ -9902,19 +9934,27 @@ export class JMAPClient implements IJMAPClient {
[['PushSubscription/set', { update: { [id]: { verificationCode } } }, '0']],
['urn:ietf:params:jmap:core'],
);
const [, body] = response.methodResponses[0] ?? [];
const notUpdated = (body as { notUpdated?: Record<string, unknown> } | undefined)?.notUpdated?.[id];
if (notUpdated) {
throw new Error(`PushSubscription verification failed: ${JSON.stringify(notUpdated)}`);
const result = this.pushMethodResult<{ notUpdated?: Record<string, JmapErrorInfo> }>(
response,
'PushSubscription/set',
);
const refused = result.notUpdated?.[id];
if (refused) {
throw jmapSetError(
`PushSubscription verification failed: ${refused.description || refused.type || 'unknown error'}`,
refused,
);
}
}
// Returns false when the server rejects the update (e.g. the subscription
// was already destroyed) - the caller treats that as a signal to recreate.
// Throws when the server refuses the update: a jmapSetError with the
// SetError type (`notFound` once the subscription is gone, `forbidden` for
// an emailPush map naming an account the user may not subscribe to), a
// plain error for anything that says nothing about the subscription.
async updatePushSubscription(
id: string,
patch: { expires?: string; types?: string[]; emailPush?: Record<string, EmailPushConfig> | null },
): Promise<boolean> {
): Promise<void> {
const withEmailPush = patch.emailPush !== undefined && this.hasEmailPushCapability();
const update: Record<string, unknown> = { ...patch };
if (!withEmailPush) delete update.emailPush;
@@ -9922,10 +9962,20 @@ export class JMAPClient implements IJMAPClient {
[['PushSubscription/set', { update: { [id]: update } }, '0']],
this.pushUsing(withEmailPush),
);
const [, body] = response.methodResponses[0] ?? [];
const r = body as { updated?: Record<string, unknown>; notUpdated?: Record<string, unknown> } | undefined;
if (r?.notUpdated?.[id]) return false;
return r?.updated?.[id] !== undefined;
const result = this.pushMethodResult<{
updated?: Record<string, unknown> | null;
notUpdated?: Record<string, JmapErrorInfo>;
}>(response, 'PushSubscription/set');
const refused = result.notUpdated?.[id];
if (refused) {
throw jmapSetError(
`PushSubscription/set update failed: ${refused.description || refused.type || 'unknown error'}`,
refused,
);
}
if (!result.updated || !(id in result.updated)) {
throw new Error(`PushSubscription/set did not report ${id} as updated`);
}
}
async destroyPushSubscription(id: string): Promise<void> {
+19 -1
View File
@@ -15,7 +15,7 @@ export interface JmapErrorInfo {
properties?: string[];
}
export type JmapError = Error & { jmapType?: string; jmapProperties?: string[] };
export type JmapError = Error & { jmapType?: string; jmapProperties?: string[]; jmapSetError?: boolean };
/** An `Error` with `message` that keeps the server's error type and properties. */
export function jmapError(message: string, info?: JmapErrorInfo | null): JmapError {
@@ -25,6 +25,24 @@ export function jmapError(message: string, info?: JmapErrorInfo | null): JmapErr
return error;
}
/**
* {@link jmapError} for a SetError: the server looked at one object of a /set
* call and refused it (`notCreated`, `notUpdated`, `notDestroyed`, RFC 8620
* §5.3), as opposed to refusing the whole method call. The same type can come
* either way (`forbidden` is both), and only the SetError says something about
* that object.
*/
export function jmapSetError(message: string, info?: JmapErrorInfo | null): JmapError {
const error = jmapError(message, info);
error.jmapSetError = true;
return error;
}
/** Whether a thrown value is a SetError (see {@link jmapSetError}). */
export function isJmapSetError(error: unknown): boolean {
return (error as JmapError | null | undefined)?.jmapSetError === true;
}
/** The JMAP error type a thrown value carries, if any. */
export function jmapErrorType(error: unknown): string | undefined {
return (error as JmapError | null | undefined)?.jmapType;
+11
View File
@@ -22,6 +22,17 @@ export interface SharedAccount {
};
}
/**
* One account as the JMAP session describes it (RFC 8620 §2), unflattened:
* the raw per-account capability objects rather than SharedAccount's
* booleans, for callers that need a capability's fields.
*/
export interface AccountInfo {
isPersonal: boolean;
isReadOnly: boolean;
accountCapabilities: Record<string, unknown>;
}
export interface Email {
id: string;
threadId: string;
+223 -36
View File
@@ -5,7 +5,8 @@
// register endpoint we hit on the relay.
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { EmailPushConfig, Mailbox } from '@/lib/jmap/types';
import type { AccountInfo, EmailPushConfig, Mailbox } from '@/lib/jmap/types';
import { isJmapSetError, jmapErrorType } from '@/lib/jmap/jmap-error';
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
import { IS_LITE } from '@/lib/lite';
@@ -16,6 +17,10 @@ import { IS_LITE } from '@/lib/lite';
// so a globally-shared key meant re-registering account B overwrote A.
const DEVICE_CLIENT_ID_PREFIX = 'bulwark.push.deviceClientId.v1.';
const SUBSCRIPTION_ID_PREFIX = 'bulwark.push.subscriptionId.v1.';
// JMAP account ids the server refused in this account's emailPush map (see
// emailPushFallbacks). Left out of the map from then on, so a refusal isn't
// provoked again on every page load. Re-register and disable clear it.
const EMAIL_PUSH_REFUSED_PREFIX = 'bulwark.push.emailPushRefused.v1.';
function deviceClientIdKey(accountId: string): string {
return DEVICE_CLIENT_ID_PREFIX + accountId;
@@ -25,6 +30,10 @@ function subscriptionIdKey(accountId: string): string {
return SUBSCRIPTION_ID_PREFIX + accountId;
}
function emailPushRefusedKey(accountId: string): string {
return EMAIL_PUSH_REFUSED_PREFIX + accountId;
}
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '');
const SW_SCOPE = `${BASE_PATH}/`;
const SW_URL = `${BASE_PATH}/sw.js`;
@@ -82,17 +91,24 @@ export function serverSupportsEmailPush(client: IJMAPClient): boolean {
*
* Inbox-only mode: same `$junk` keyword guard, but the mailbox condition
* requires the message to be IN the account's Inbox rather than merely NOT
* in Junk - mail a Sieve rule files into any other folder stays silent. An
* account with no Inbox we can see (someone shared a single folder with us)
* gets a filter that never matches: leaving it out of the map would make the
* server fall back to unfiltered pushes for that account.
* in Junk - mail a Sieve rule files into any other folder stays silent. A
* group account with no Inbox we can see gets a filter that never matches:
* leaving it out of the map would make the server fall back to unfiltered
* pushes for that account.
*
* Only the primary account and the accounts that list emailpush in their
* accountCapabilities go in the map. Stalwart fans a subscription out to the
* user's own account and their groups only, and refuses the whole map as
* `forbidden` when it names a mailbox shared by ACL. 1.0 lists emailpush on
* exactly those accounts; 0.16.16 to 0.16.23 list it on every account, and
* enableWebPush narrows the map when the server refuses it.
*/
export async function buildEmailPushConfig(
client: IJMAPClient,
inboxOnly: boolean,
): Promise<Record<string, EmailPushConfig>> {
const primary = client.getAccountId();
// Every account that has any mailbox gets a filter entry - a secondary/shared
// Every such account that has any mailbox gets a filter entry - a group
// account without a Junk-role mailbox still needs one so its subscription
// isn't left unfiltered.
const accountIds = new Set<string>([primary]);
@@ -106,6 +122,7 @@ export async function buildEmailPushConfig(
: await client.getAllMailboxes().catch(() => [] as Mailbox[]);
for (const m of mailboxes) {
const accountId = m.accountId || primary;
if (accountId !== primary && !client.hasAccountCapability(EMAIL_PUSH_CAPABILITY, accountId)) continue;
accountIds.add(accountId);
// Shared-account mailboxes carry a client-side "<account>:<id>" id;
// the server only knows the original.
@@ -165,6 +182,134 @@ function sameEmailPush(
return normalizeEmailPush(a) === normalizeEmailPush(b);
}
// Stalwart marks the accounts a user owns - their own and the groups they
// belong to - through the create flags of its per-account collection
// capabilities: true there, false on accounts shared with them by ACL. A user
// without calendars, contacts or files has none of the flags, so their groups
// don't look owned either.
const OWNERSHIP_FLAGS: ReadonlyArray<readonly [string, string]> = [
['urn:ietf:params:jmap:calendars', 'mayCreateCalendar'],
['urn:ietf:params:jmap:contacts', 'mayCreateAddressBook'],
['urn:ietf:params:jmap:filenode', 'mayCreateTopLevelFileNode'],
];
function looksOwned(info: AccountInfo | null): boolean {
if (!info) return false;
if (info.isPersonal) return true;
if (info.isReadOnly) return false;
return OWNERSHIP_FLAGS.some(([urn, flag]) => {
const capability = info.accountCapabilities[urn] as Record<string, unknown> | undefined;
return capability?.[flag] === true;
});
}
/**
* The emailPush maps to try, widest first. Stalwart refuses the whole map as
* `forbidden` when it names an account the user neither owns nor belongs to
* as a group member - a mailbox shared by ACL - and only fans a subscription
* out to owned accounts anyway, so dropping the others loses nothing. 0.16
* lists both kinds as isPersonal:false, isReadOnly:false, so the first
* fallback keeps the accounts that look owned (see OWNERSHIP_FLAGS) and the
* last keeps only the primary account, which is always accepted.
*/
function emailPushFallbacks(
desired: Record<string, EmailPushConfig>,
primaryAccountId: string,
accountInfo: (accountId: string) => AccountInfo | null,
): Record<string, EmailPushConfig>[] {
const keep = (predicate: (id: string) => boolean) =>
Object.fromEntries(
Object.entries(desired).filter(([id]) => id === primaryAccountId || predicate(id)),
);
const out = [desired];
for (const next of [keep((id) => looksOwned(accountInfo(id))), keep(() => false)]) {
// Only steps that actually drop an account are worth another round-trip.
if (Object.keys(next).length < Object.keys(out[out.length - 1]).length) out.push(next);
}
return out;
}
/**
* Run a subscription write that carries `emailPush`, narrowing the map along
* emailPushFallbacks while the server refuses it with a `forbidden` SetError.
* Anything else - a method-level error, overQuota, a failed request - would
* fail the same way with a narrower map, so it is thrown as is. Resolves to
* the write's result and the map the server took.
*/
async function writeWithEmailPush<T>(
client: IJMAPClient,
emailPush: Record<string, EmailPushConfig> | null,
write: (emailPush: Record<string, EmailPushConfig> | null) => Promise<T>,
): Promise<{ result: T; emailPush: Record<string, EmailPushConfig> | null }> {
const attempts = emailPush
? emailPushFallbacks(emailPush, client.getAccountId(), (id) => client.getAccountInfo(id))
: [null];
for (let i = 0; ; i++) {
try {
return { result: await write(attempts[i]), emailPush: attempts[i] };
} catch (err) {
const mapRefused = isJmapSetError(err) && jmapErrorType(err) === 'forbidden';
if (!mapRefused || i === attempts.length - 1) throw err;
}
}
}
function readRefusedEmailPushAccounts(accountId: string): string[] {
try {
const parsed: unknown = JSON.parse(localStorage.getItem(emailPushRefusedKey(accountId)) ?? '[]');
return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === 'string') : [];
} catch {
return [];
}
}
/** Add the accounts the server dropped from `wanted` to reach `accepted`. */
function rememberRefusedEmailPushAccounts(
accountId: string,
refusedBefore: string[],
wanted: Record<string, EmailPushConfig> | null,
accepted: Record<string, EmailPushConfig> | null,
): void {
if (!wanted || !accepted) return;
const refusedNow = Object.keys(wanted).filter((id) => !(id in accepted));
if (refusedNow.length === 0) return;
localStorage.setItem(
emailPushRefusedKey(accountId),
JSON.stringify(Array.from(new Set([...refusedBefore, ...refusedNow]))),
);
}
function withoutAccounts(
config: Record<string, EmailPushConfig>,
accountIds: string[],
): Record<string, EmailPushConfig> {
return Object.fromEntries(Object.entries(config).filter(([id]) => !accountIds.includes(id)));
}
/**
* Which of `accountIds` (accounts the user sees mailboxes in) background
* notifications don't reach: mailboxes others share with the user by ACL.
* Stalwart only fans a subscription out to the user's own account and their
* groups, whatever the emailPush map says, on every version.
*
* 1.0 leaves emailpush out of exactly those accounts' capabilities, so an
* account without it settles the question for every account. 0.16.16 to
* 0.16.23 list emailpush everywhere (and 0.16.6 to 0.16.15 nowhere), so there
* it is the accounts the server refused in the map, and the ones that don't
* look owned.
*/
export function mailAccountsWithoutPush(client: IJMAPClient, accountIds: Iterable<string>): string[] {
const primary = client.getAccountId();
const others = Array.from(new Set(accountIds)).filter((id) => id && id !== primary);
if (others.length === 0) return [];
if (serverSupportsEmailPush(client)) {
const withoutEmailPush = others.filter((id) => !client.hasAccountCapability(EMAIL_PUSH_CAPABILITY, id));
if (withoutEmailPush.length > 0) return withoutEmailPush;
}
const refused = readRefusedEmailPushAccounts(primary);
return others.filter((id) => refused.includes(id) || !looksOwned(client.getAccountInfo(id)));
}
export interface EnableWebPushParams {
client: IJMAPClient;
// Optional - falls back to DEFAULT_RELAY_BASE_URL.
@@ -398,6 +543,13 @@ async function pollVerificationCode(
throw new Error('Timed out waiting for PushVerification from the JMAP server');
}
// Push the subscription's expiry forward and re-sync `types` / the delivery
// filter when they drift. Resolves to false when the server refused the
// update with a SetError (the subscription is gone, or no narrower map is
// accepted), which the caller answers with a replacement; otherwise to the
// emailPush map the update installed (null when it left the filter alone).
// Every other failure - the network, a timeout, rate limiting, a method-level
// error - says nothing about the subscription and is thrown.
async function refreshSubscriptionExpires(
client: IJMAPClient,
sub: {
@@ -408,7 +560,7 @@ async function refreshSubscriptionExpires(
},
// null when the server has no emailPush support - leave the property alone.
desiredEmailPush: Record<string, EmailPushConfig> | null,
): Promise<boolean> {
): Promise<false | { emailPush: Record<string, EmailPushConfig> | null }> {
const typesNeedUpdate = !sameTypes(sub.types, PUSH_TYPES);
// Also re-sync the delivery filter: a subscription created before this
// client learned about emailPush has none, and the Junk mailbox id can
@@ -417,17 +569,22 @@ async function refreshSubscriptionExpires(
if (!typesNeedUpdate && !emailPushNeedsUpdate && sub.expires) {
const remainingMs = new Date(sub.expires).getTime() - Date.now();
const thresholdMs = SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000;
if (Number.isFinite(remainingMs) && remainingMs > thresholdMs) return true;
if (Number.isFinite(remainingMs) && remainingMs > thresholdMs) return { emailPush: null };
}
const patch: { expires?: string; types?: string[] } = {
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
};
if (typesNeedUpdate) patch.types = [...PUSH_TYPES];
try {
const patch: { expires?: string; types?: string[]; emailPush?: Record<string, EmailPushConfig> } = {
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
};
if (typesNeedUpdate) patch.types = [...PUSH_TYPES];
if (emailPushNeedsUpdate && desiredEmailPush) patch.emailPush = desiredEmailPush;
return await client.updatePushSubscription(sub.id, patch);
} catch {
return false;
const { emailPush } = await writeWithEmailPush(
client,
emailPushNeedsUpdate ? desiredEmailPush : null,
(filter) => client.updatePushSubscription(sub.id, filter ? { ...patch, emailPush: filter } : patch),
);
return { emailPush };
} catch (err) {
if (isJmapSetError(err)) return false;
throw err;
}
}
@@ -484,33 +641,53 @@ export async function enableWebPush(
// Reuse the JMAP-side PushSubscription if the server still has it, just
// refreshing the expiry so it doesn't time out between sessions. With
// forceRecreate we skip the reuse and destroy it instead: the account set a
// Stalwart subscription fans out to is fixed at creation time, so refreshing
// forceRecreate we replace it instead: the account set a Stalwart
// subscription fans out to is fixed at creation time, so refreshing
// `expires` carries stale permissions forward and only a new record picks up
// revoked shared-mailbox access (#841).
const existingSubs = await params.client.listPushSubscriptions().catch(() => []);
// revoked shared-mailbox access (#841). Either way the old record keeps
// delivering until its replacement is verified, so a replacement the server
// refuses never leaves the browser without push.
//
// A failed listing ends the run here: taken for "the server has no
// subscription", it would drop the key of a working registration and push
// would read as off from then on.
const existingSubs = await params.client.listPushSubscriptions();
// A forced re-registration re-learns which accounts the server accepts in
// the emailPush map.
if (params.forceRecreate) localStorage.removeItem(emailPushRefusedKey(accountId));
const refusedBefore = readRefusedEmailPushAccounts(accountId);
const emailPush = serverSupportsEmailPush(params.client)
? await buildEmailPushConfig(params.client, params.inboxOnly)
? withoutAccounts(await buildEmailPushConfig(params.client, params.inboxOnly), refusedBefore)
: null;
const subIdKey = subscriptionIdKey(accountId);
const storedServerId = localStorage.getItem(subIdKey);
let replacedServerId: string | null = null;
if (storedServerId) {
const match = existingSubs.find((s) => s.id === storedServerId);
if (match) {
if (!params.forceRecreate) {
const refreshed = await refreshSubscriptionExpires(params.client, match, emailPush);
if (refreshed) return { subscriptionId: storedServerId };
if (refreshed) {
rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush);
return { subscriptionId: storedServerId };
}
}
await params.client.destroyPushSubscription(storedServerId).catch(() => undefined);
// The server refused the refresh, or the caller asked for a fresh
// record: create a replacement below and destroy this one once the
// replacement is verified.
replacedServerId = storedServerId;
} else {
// The server no longer has it (expired, or revoked elsewhere).
localStorage.removeItem(subIdKey);
}
localStorage.removeItem(subIdKey);
}
// Reap leftover 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 subscription's
// verification. Stalwart emits only one PushVerification per account per
// ~60s; up to 0.16.13 it picks the first unverified subscription it stores,
// usually the oldest, so a single stale straggler blocks every fresh attempt
// - the symptom is the confusing "Timed out waiting for PushVerification"
// error (0.16.14 and later pick the newest). 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 browser,
@@ -531,17 +708,24 @@ export async function enableWebPush(
}
}
const serverAssignedId = await params.client.createPushSubscription({
deviceClientId,
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
types: [...PUSH_TYPES],
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
...(emailPush ? { emailPush } : {}),
});
const created = await writeWithEmailPush(params.client, emailPush, (filter) =>
params.client.createPushSubscription({
deviceClientId,
url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`),
types: [...PUSH_TYPES],
expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS),
...(filter ? { emailPush: filter } : {}),
}),
);
const serverAssignedId = created.result;
rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush);
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
await params.client.verifyPushSubscription(serverAssignedId, verificationCode);
localStorage.setItem(subIdKey, serverAssignedId);
if (replacedServerId) {
await params.client.destroyPushSubscription(replacedServerId).catch(() => undefined);
}
return { subscriptionId: serverAssignedId };
}
@@ -582,6 +766,8 @@ export async function disableWebPush(params: DisableWebPushParams): Promise<void
await params.client.destroyPushSubscription(id).catch(() => undefined);
}
localStorage.removeItem(subIdKey);
// Enabling again starts from the full emailPush map.
localStorage.removeItem(emailPushRefusedKey(accountId));
if (deviceClientId && relayBaseUrl) {
await fetch(
@@ -711,7 +897,8 @@ export interface ResyncWebPushParams {
* action: refresh its expiry and install/repair the delivery filter. Nothing
* here can prompt - it only runs when push is already on for the account -
* and every failure is swallowed because the app must not care whether the
* background touch-up worked. Returns true when a re-sync actually ran.
* background touch-up worked. A failed re-sync leaves the registration and
* its key as they were. Returns true when a re-sync actually ran.
*/
export async function resyncWebPush(params: ResyncWebPushParams): Promise<boolean> {
let accountId: string;
+4 -1
View File
@@ -1192,7 +1192,10 @@
"revoke": "إلغاء التسجيل",
"confirm_revoke_title": "إلغاء تسجيل هذا الجهاز؟",
"confirm_revoke_message": "سيتوقّف فورًا عن تلقّي الإشعارات في الخلفية. تفعيل الدفع مجددًا على ذلك الجهاز سيسجّله من جديد.",
"confirm_revoke_message_this": "سيتوقّف هذا الجهاز فورًا عن تلقّي الإشعارات في الخلفية."
"confirm_revoke_message_this": "سيتوقّف هذا الجهاز فورًا عن تلقّي الإشعارات في الخلفية.",
"shared_mailboxes_no_push": "صناديق البريد التي يشاركها الآخرون معك لا ترسل إشعارات في الخلفية.",
"error_forbidden": "لا يسمح خادم البريد بالإشعارات في الخلفية لهذا الحساب.",
"error_over_quota": "لدى هذا الحساب عدد كبير جدًا من تسجيلات الإشعارات الفورية على خادم البريد (الحد عادةً 15). ألغِ تسجيل الأجهزة التي لم تعد تستخدمها من إعدادات الإشعارات، ثم حاول مرة أخرى."
},
"sound_selection": {
"title": "صوت الإشعار",
+4 -1
View File
@@ -1192,7 +1192,10 @@
"revoke": "Revoca",
"confirm_revoke_title": "Voleu revocar aquest dispositiu?",
"confirm_revoke_message": "Deixarà de rebre notificacions en segon pla immediatament. Si hi tornes a activar les notificacions push, es registrarà de nou.",
"confirm_revoke_message_this": "Aquest dispositiu deixarà de rebre notificacions en segon pla immediatament."
"confirm_revoke_message_this": "Aquest dispositiu deixarà de rebre notificacions en segon pla immediatament.",
"shared_mailboxes_no_push": "Les bústies que altres usuaris comparteixen amb vós no envien notificacions en segon pla.",
"error_forbidden": "El servidor de correu no permet notificacions en segon pla per a aquest compte.",
"error_over_quota": "Aquest compte té massa registres push al servidor de correu (el límit sol ser 15). Revoqueu els dispositius que ja no feu servir a la configuració de notificacions i torneu-ho a provar."
},
"sound_selection": {
"title": "So de notificació",
+4 -1
View File
@@ -1215,7 +1215,10 @@
"revoke": "Odvolat",
"confirm_revoke_title": "Odvolat toto zařízení?",
"confirm_revoke_message": "Okamžitě přestane přijímat oznámení na pozadí. Opětovné zapnutí push na tom zařízení jej zaregistruje znovu.",
"confirm_revoke_message_this": "Toto zařízení okamžitě přestane přijímat oznámení na pozadí."
"confirm_revoke_message_this": "Toto zařízení okamžitě přestane přijímat oznámení na pozadí.",
"shared_mailboxes_no_push": "Poštovní schránky, které s vámi sdílejí ostatní, neodesílají oznámení na pozadí.",
"error_forbidden": "Poštovní server pro tento účet nepovoluje oznámení na pozadí.",
"error_over_quota": "Tento účet má na poštovním serveru příliš mnoho registrací push (limit je obvykle 15). V nastavení oznámení odvolejte zařízení, která už nepoužíváte, a zkuste to znovu."
}
},
"language_region": {
+4 -1
View File
@@ -1192,7 +1192,10 @@
"revoke": "Tilbagekald",
"confirm_revoke_title": "Tilbagekald denne enhed?",
"confirm_revoke_message": "Den stopper med at modtage baggrundsnotifikationer med det samme. Slår du push til igen på enheden, registreres den på ny.",
"confirm_revoke_message_this": "Denne enhed stopper med at modtage baggrundsnotifikationer med det samme."
"confirm_revoke_message_this": "Denne enhed stopper med at modtage baggrundsnotifikationer med det samme.",
"shared_mailboxes_no_push": "Postkasser, som andre deler med dig, sender ikke baggrundsnotifikationer.",
"error_forbidden": "Mailserveren tillader ikke baggrundsnotifikationer for denne konto.",
"error_over_quota": "Denne konto har for mange push-registreringer på mailserveren (grænsen er normalt 15). Tilbagekald enheder, du ikke længere bruger, i notifikationsindstillingerne, og prøv igen."
},
"sound_selection": {
"title": "Notifikationslyd",
+4 -1
View File
@@ -1215,7 +1215,10 @@
"revoke": "Widerrufen",
"confirm_revoke_title": "Dieses Gerät widerrufen?",
"confirm_revoke_message": "Es empfängt sofort keine Hintergrundbenachrichtigungen mehr. Wird Push auf diesem Gerät erneut aktiviert, wird es neu registriert.",
"confirm_revoke_message_this": "Dieses Gerät empfängt sofort keine Hintergrundbenachrichtigungen mehr."
"confirm_revoke_message_this": "Dieses Gerät empfängt sofort keine Hintergrundbenachrichtigungen mehr.",
"shared_mailboxes_no_push": "Postfächer, die andere mit Ihnen teilen, senden keine Hintergrundbenachrichtigungen.",
"error_forbidden": "Der Mailserver erlaubt für dieses Konto keine Hintergrundbenachrichtigungen.",
"error_over_quota": "Für dieses Konto gibt es auf dem Mailserver zu viele Push-Registrierungen (meist sind 15 erlaubt). Widerrufen Sie Geräte, die Sie nicht mehr nutzen, in den Benachrichtigungseinstellungen und versuchen Sie es erneut."
}
},
"language_region": {
+4 -1
View File
@@ -1192,7 +1192,10 @@
"revoke": "Revoke",
"confirm_revoke_title": "Revoke this device?",
"confirm_revoke_message": "It will stop receiving background notifications immediately. Enabling push again on that device registers it anew.",
"confirm_revoke_message_this": "This device will stop receiving background notifications immediately."
"confirm_revoke_message_this": "This device will stop receiving background notifications immediately.",
"shared_mailboxes_no_push": "Mailboxes others share with you don't send background notifications.",
"error_forbidden": "The mail server doesn't allow background notifications for this account.",
"error_over_quota": "This account has too many push registrations on the mail server (the limit is usually 15). Revoke devices you no longer use in the notification settings, then try again."
},
"sound_selection": {
"title": "Notification Sound",
+4 -1
View File
@@ -1215,7 +1215,10 @@
"revoke": "Revocar",
"confirm_revoke_title": "¿Revocar este dispositivo?",
"confirm_revoke_message": "Dejará de recibir notificaciones en segundo plano de inmediato. Si vuelves a activar las notificaciones push en ese dispositivo, se registrará de nuevo.",
"confirm_revoke_message_this": "Este dispositivo dejará de recibir notificaciones en segundo plano de inmediato."
"confirm_revoke_message_this": "Este dispositivo dejará de recibir notificaciones en segundo plano de inmediato.",
"shared_mailboxes_no_push": "Los buzones que otras personas comparten contigo no envían notificaciones en segundo plano.",
"error_forbidden": "El servidor de correo no permite notificaciones en segundo plano para esta cuenta.",
"error_over_quota": "Esta cuenta tiene demasiados registros push en el servidor de correo (el límite suele ser 15). Revoca los dispositivos que ya no uses en la configuración de notificaciones e inténtalo de nuevo."
}
},
"language_region": {
+4 -1
View File
@@ -1192,7 +1192,10 @@
"revoke": "لغو",
"confirm_revoke_title": "این دستگاه لغو شود؟",
"confirm_revoke_message": "بی‌درنگ دریافت اعلان‌های پس‌زمینه را متوقف می‌کند. با روشن‌کردن دوبارهٔ پوش روی آن دستگاه، دوباره ثبت می‌شود.",
"confirm_revoke_message_this": "این دستگاه بی‌درنگ دریافت اعلان‌های پس‌زمینه را متوقف می‌کند."
"confirm_revoke_message_this": "این دستگاه بی‌درنگ دریافت اعلان‌های پس‌زمینه را متوقف می‌کند.",
"shared_mailboxes_no_push": "صندوق‌های پستی که دیگران با شما به اشتراک می‌گذارند اعلان پس‌زمینه ارسال نمی‌کنند.",
"error_forbidden": "سرور ایمیل اعلان‌های پس‌زمینه را برای این حساب مجاز نمی‌داند.",
"error_over_quota": "این حساب روی سرور ایمیل ثبت‌های پوش بیش از حد دارد (سقف معمولاً 15 است). دستگاه‌هایی را که دیگر استفاده نمی‌کنید در تنظیمات اعلان‌ها لغو کنید و دوباره امتحان کنید."
},
"sound_selection": {
"title": "صدای اعلان",
+4 -1
View File
@@ -1215,7 +1215,10 @@
"revoke": "Révoquer",
"confirm_revoke_title": "Révoquer cet appareil ?",
"confirm_revoke_message": "Il cessera immédiatement de recevoir les notifications en arrière-plan. Réactiver les notifications push sur cet appareil l’enregistrera à nouveau.",
"confirm_revoke_message_this": "Cet appareil cessera immédiatement de recevoir les notifications en arrière-plan."
"confirm_revoke_message_this": "Cet appareil cessera immédiatement de recevoir les notifications en arrière-plan.",
"shared_mailboxes_no_push": "Les boîtes aux lettres que d'autres personnes partagent avec vous n'envoient pas de notifications en arrière-plan.",
"error_forbidden": "Le serveur de messagerie n'autorise pas les notifications en arrière-plan pour ce compte.",
"error_over_quota": "Ce compte a trop d'enregistrements push sur le serveur de messagerie (la limite est généralement de 15). Révoquez les appareils que vous n'utilisez plus dans les paramètres de notification, puis réessayez."
}
},
"language_region": {
+4 -1
View File
@@ -1180,7 +1180,10 @@
"revoke": "ביטול",
"confirm_revoke_title": "לבטל את המכשיר הזה?",
"confirm_revoke_message": "הוא יפסיק לקבל התראות ברקע מיד. הפעלה מחדש של פוש באותו מכשיר תרשום אותו מחדש.",
"confirm_revoke_message_this": "מכשיר זה יפסיק לקבל התראות ברקע מיד."
"confirm_revoke_message_this": "מכשיר זה יפסיק לקבל התראות ברקע מיד.",
"shared_mailboxes_no_push": "תיבות דואר שאחרים משתפים איתך אינן שולחות התראות ברקע.",
"error_forbidden": "שרת הדואר אינו מאפשר התראות ברקע עבור חשבון זה.",
"error_over_quota": "לחשבון זה יש יותר מדי רישומי פוש בשרת הדואר (המגבלה היא בדרך כלל 15). בטל בהגדרות ההתראות מכשירים שאינך משתמש בהם עוד, ונסה שוב."
}
},
"language_region": {
+4 -1
View File
@@ -1192,7 +1192,10 @@
"revoke": "Visszavonás",
"confirm_revoke_title": "Visszavonja ezt az eszközt?",
"confirm_revoke_message": "Azonnal nem kap több háttérértesítést. Ha az eszközön újra bekapcsolja a push értesítéseket, újra regisztrálódik.",
"confirm_revoke_message_this": "Ez az eszköz azonnal nem kap több háttérértesítést."
"confirm_revoke_message_this": "Ez az eszköz azonnal nem kap több háttérértesítést.",
"shared_mailboxes_no_push": "A mások által Önnel megosztott postafiókok nem küldenek háttérértesítéseket.",
"error_forbidden": "A levelezőszerver ennél a fióknál nem engedélyezi a háttérértesítéseket.",
"error_over_quota": "Ennek a fióknak túl sok push-regisztrációja van a levelezőszerveren (a korlát általában 15). Az értesítési beállításokban vonja vissza a már nem használt eszközöket, majd próbálja újra."
},
"sound_selection": {
"title": "Értesítési hang",
+4 -1
View File
@@ -1215,7 +1215,10 @@
"revoke": "Revoca",
"confirm_revoke_title": "Revocare questo dispositivo?",
"confirm_revoke_message": "Smetterà subito di ricevere le notifiche in background. Riattivando il push su quel dispositivo verrà registrato di nuovo.",
"confirm_revoke_message_this": "Questo dispositivo smetterà subito di ricevere le notifiche in background."
"confirm_revoke_message_this": "Questo dispositivo smetterà subito di ricevere le notifiche in background.",
"shared_mailboxes_no_push": "Le caselle di posta che altri condividono con te non inviano notifiche in background.",
"error_forbidden": "Il server di posta non consente le notifiche in background per questo account.",
"error_over_quota": "Questo account ha troppe registrazioni push sul server di posta (il limite di solito è 15). Revoca i dispositivi che non usi più nelle impostazioni delle notifiche, poi riprova."
}
},
"language_region": {
+4 -1
View File
@@ -1215,7 +1215,10 @@
"revoke": "取り消す",
"confirm_revoke_title": "このデバイスを取り消しますか?",
"confirm_revoke_message": "すぐにバックグラウンド通知の受信を停止します。そのデバイスでプッシュを再度有効にすると、あらためて登録されます。",
"confirm_revoke_message_this": "このデバイスはすぐにバックグラウンド通知の受信を停止します。"
"confirm_revoke_message_this": "このデバイスはすぐにバックグラウンド通知の受信を停止します。",
"shared_mailboxes_no_push": "他のユーザーから共有されたメールボックスでは、バックグラウンド通知は送信されません。",
"error_forbidden": "メールサーバーは、このアカウントのバックグラウンド通知を許可していません。",
"error_over_quota": "このアカウントはメールサーバー上のプッシュ登録が多すぎます(上限は通常 15 件)。通知設定で使用していないデバイスを取り消してから、もう一度お試しください。"
}
},
"language_region": {
+4 -1
View File
@@ -1215,7 +1215,10 @@
"revoke": "해지",
"confirm_revoke_title": "이 기기를 해지할까요?",
"confirm_revoke_message": "즉시 백그라운드 알림 수신을 멈추게 됩니다. 해당 기기에서 푸시를 다시 켜면 새로 등록됩니다.",
"confirm_revoke_message_this": "이 기기는 즉시 백그라운드 알림 수신을 멈추게 됩니다."
"confirm_revoke_message_this": "이 기기는 즉시 백그라운드 알림 수신을 멈추게 됩니다.",
"shared_mailboxes_no_push": "다른 사람이 공유한 메일함은 백그라운드 알림을 보내지 않습니다.",
"error_forbidden": "메일 서버에서 이 계정의 백그라운드 알림을 허용하지 않습니다.",
"error_over_quota": "이 계정은 메일 서버에 푸시 등록이 너무 많습니다(한도는 보통 15개). 알림 설정에서 더 이상 사용하지 않는 기기를 해지한 후 다시 시도하세요."
}
},
"language_region": {
+4 -1
View File
@@ -1215,7 +1215,10 @@
"revoke": "Atsaukt",
"confirm_revoke_title": "Atsaukt šo ierīci?",
"confirm_revoke_message": "Tā nekavējoties pārstās saņemt fona paziņojumus. Atkārtoti ieslēdzot push šajā ierīcē, tā tiks reģistrēta no jauna.",
"confirm_revoke_message_this": "Šī ierīce nekavējoties pārstās saņemt fona paziņojumus."
"confirm_revoke_message_this": "Šī ierīce nekavējoties pārstās saņemt fona paziņojumus.",
"shared_mailboxes_no_push": "Pastkastes, ko ar jums kopīgo citi, nesūta fona paziņojumus.",
"error_forbidden": "Pasta serveris šim kontam neatļauj fona paziņojumus.",
"error_over_quota": "Šim kontam pasta serverī ir pārāk daudz push reģistrāciju (ierobežojums parasti ir 15). Paziņojumu iestatījumos atsauciet ierīces, kuras vairs neizmantojat, un mēģiniet vēlreiz."
}
},
"language_region": {
+4 -1
View File
@@ -1192,7 +1192,10 @@
"revoke": "Цуцлах",
"confirm_revoke_title": "Энэ төхөөрөмжийг цуцлах уу?",
"confirm_revoke_message": "Тэр даруй араарх мэдэгдэл хүлээн авахаа болино. Тухайн төхөөрөмж дээр push-ыг дахин асаавал дахин бүртгэгднэ.",
"confirm_revoke_message_this": "Энэ төхөөрөмж тэр даруй араарх мэдэгдэл хүлээн авахаа болино."
"confirm_revoke_message_this": "Энэ төхөөрөмж тэр даруй араарх мэдэгдэл хүлээн авахаа болино.",
"shared_mailboxes_no_push": "Бусдын тантай хуваалцсан шуудангийн хайрцгууд арын мэдэгдэл илгээдэггүй.",
"error_forbidden": "Шуудангийн сервер энэ бүртгэлд арын мэдэгдэл зөвшөөрдөггүй.",
"error_over_quota": "Энэ бүртгэл шуудангийн сервер дээр хэт олон push бүртгэлтэй байна (хязгаар нь ихэвчлэн 15). Мэдэгдлийн тохиргооноос ашиглахаа больсон төхөөрөмжүүдээ цуцлаад дахин оролдоно уу."
},
"sound_selection": {
"title": "Мэдэгдэлийн дуу",
+4 -1
View File
@@ -1192,7 +1192,10 @@
"revoke": "Tilbakekall",
"confirm_revoke_title": "Vil du tilbakekalle tilgangen for denne enheten?",
"confirm_revoke_message": "Enheten slutter umiddelbart å motta bakgrunnsvarsler. Hvis pushvarsler aktiveres på nytt på enheten, registreres den på nytt.",
"confirm_revoke_message_this": "Denne enheten slutter umiddelbart å motta bakgrunnsvarsler."
"confirm_revoke_message_this": "Denne enheten slutter umiddelbart å motta bakgrunnsvarsler.",
"shared_mailboxes_no_push": "Postbokser som andre deler med deg, sender ikke bakgrunnsvarsler.",
"error_forbidden": "E-postserveren tillater ikke bakgrunnsvarsler for denne kontoen.",
"error_over_quota": "Denne kontoen har for mange push-registreringer på e-postserveren (grensen er vanligvis 15). Tilbakekall enheter du ikke lenger bruker i varslingsinnstillingene, og prøv igjen."
},
"sound_selection": {
"title": "Varslingslyd",
+4 -1
View File
@@ -1215,7 +1215,10 @@
"revoke": "Intrekken",
"confirm_revoke_title": "Dit apparaat intrekken?",
"confirm_revoke_message": "Het ontvangt meteen geen achtergrondmeldingen meer. Push opnieuw inschakelen op dat apparaat registreert het opnieuw.",
"confirm_revoke_message_this": "Dit apparaat ontvangt meteen geen achtergrondmeldingen meer."
"confirm_revoke_message_this": "Dit apparaat ontvangt meteen geen achtergrondmeldingen meer.",
"shared_mailboxes_no_push": "Mailboxen die anderen met u delen, sturen geen achtergrondmeldingen.",
"error_forbidden": "De mailserver staat voor dit account geen achtergrondmeldingen toe.",
"error_over_quota": "Dit account heeft te veel pushregistraties op de mailserver (de limiet is meestal 15). Trek in de meldingsinstellingen apparaten in die u niet meer gebruikt en probeer het opnieuw."
}
},
"language_region": {
+4 -1
View File
@@ -1215,7 +1215,10 @@
"revoke": "Unieważnij",
"confirm_revoke_title": "Unieważnić to urządzenie?",
"confirm_revoke_message": "Natychmiast przestanie odbierać powiadomienia w tle. Ponowne włączenie push na tym urządzeniu zarejestruje je od nowa.",
"confirm_revoke_message_this": "To urządzenie natychmiast przestanie odbierać powiadomienia w tle."
"confirm_revoke_message_this": "To urządzenie natychmiast przestanie odbierać powiadomienia w tle.",
"shared_mailboxes_no_push": "Skrzynki pocztowe udostępnione ci przez innych nie wysyłają powiadomień w tle.",
"error_forbidden": "Serwer poczty nie zezwala na powiadomienia w tle dla tego konta.",
"error_over_quota": "To konto ma zbyt wiele rejestracji push na serwerze poczty (limit wynosi zwykle 15). Unieważnij nieużywane urządzenia w ustawieniach powiadomień i spróbuj ponownie."
}
},
"language_region": {
+4 -1
View File
@@ -1215,7 +1215,10 @@
"revoke": "Revogar",
"confirm_revoke_title": "Revogar este dispositivo?",
"confirm_revoke_message": "Ele deixará de receber notificações em segundo plano imediatamente. Ativar o push novamente nesse dispositivo o registrará de novo.",
"confirm_revoke_message_this": "Este dispositivo deixará de receber notificações em segundo plano imediatamente."
"confirm_revoke_message_this": "Este dispositivo deixará de receber notificações em segundo plano imediatamente.",
"shared_mailboxes_no_push": "Caixas de correio que outras pessoas compartilham com você não enviam notificações em segundo plano.",
"error_forbidden": "O servidor de e-mail não permite notificações em segundo plano para esta conta.",
"error_over_quota": "Esta conta tem registros push demais no servidor de e-mail (o limite costuma ser 15). Revogue os dispositivos que você não usa mais nas configurações de notificação e tente novamente."
}
},
"language_region": {
+4 -1
View File
@@ -1192,7 +1192,10 @@
"revoke": "Revocă",
"confirm_revoke_title": "Revocați acest dispozitiv?",
"confirm_revoke_message": "Va înceta imediat să primească notificări în fundal. Reactivarea push pe acel dispozitiv îl va înregistra din nou.",
"confirm_revoke_message_this": "Acest dispozitiv va înceta imediat să primească notificări în fundal."
"confirm_revoke_message_this": "Acest dispozitiv va înceta imediat să primească notificări în fundal.",
"shared_mailboxes_no_push": "Căsuțele poștale pe care alții le partajează cu dvs. nu trimit notificări în fundal.",
"error_forbidden": "Serverul de e-mail nu permite notificări în fundal pentru acest cont.",
"error_over_quota": "Acest cont are prea multe înregistrări push pe serverul de e-mail (limita este de obicei 15). Revocați din setările de notificări dispozitivele pe care nu le mai folosiți, apoi încercați din nou."
},
"sound_selection": {
"title": "Sunet de notificare",
+4 -1
View File
@@ -1215,7 +1215,10 @@
"revoke": "Отозвать",
"confirm_revoke_title": "Отозвать это устройство?",
"confirm_revoke_message": "Оно сразу перестанет получать фоновые уведомления. Если снова включить push на этом устройстве, оно зарегистрируется заново.",
"confirm_revoke_message_this": "Это устройство сразу перестанет получать фоновые уведомления."
"confirm_revoke_message_this": "Это устройство сразу перестанет получать фоновые уведомления.",
"shared_mailboxes_no_push": "Почтовые ящики, к которым вам открыли доступ другие пользователи, не присылают фоновые уведомления.",
"error_forbidden": "Почтовый сервер не разрешает фоновые уведомления для этой учётной записи.",
"error_over_quota": "У этой учётной записи слишком много push-регистраций на почтовом сервере (обычно разрешено 15). Отзовите неиспользуемые устройства в настройках уведомлений и повторите попытку."
}
},
"language_region": {
+4 -1
View File
@@ -1218,7 +1218,10 @@
"revoke": "Odvolať",
"confirm_revoke_title": "Odvolať toto zariadenie?",
"confirm_revoke_message": "Okamžite prestane prijímať oznámenia na pozadí. Opätovné zapnutie push na tom zariadení ho zaregistruje nanovo.",
"confirm_revoke_message_this": "Toto zariadenie okamžite prestane prijímať oznámenia na pozadí."
"confirm_revoke_message_this": "Toto zariadenie okamžite prestane prijímať oznámenia na pozadí.",
"shared_mailboxes_no_push": "Poštové schránky, ktoré s vami zdieľajú iní, neposielajú oznámenia na pozadí.",
"error_forbidden": "Poštový server pre tento účet nepovoľuje oznámenia na pozadí.",
"error_over_quota": "Tento účet má na poštovom serveri príliš veľa registrácií push (limit je zvyčajne 15). V nastaveniach oznámení odvolajte zariadenia, ktoré už nepoužívate, a skúste to znova."
}
},
"language_region": {
+4 -1
View File
@@ -1215,7 +1215,10 @@
"revoke": "İptal et",
"confirm_revoke_title": "Bu cihaz iptal edilsin mi?",
"confirm_revoke_message": "Arka plan bildirimlerini almayı hemen bırakacak. O cihazda push’u yeniden açarsanız yeniden kaydedilir.",
"confirm_revoke_message_this": "Bu cihaz arka plan bildirimlerini almayı hemen bırakacak."
"confirm_revoke_message_this": "Bu cihaz arka plan bildirimlerini almayı hemen bırakacak.",
"shared_mailboxes_no_push": "Başkalarının sizinle paylaştığı posta kutuları arka plan bildirimi göndermez.",
"error_forbidden": "Posta sunucusu bu hesap için arka plan bildirimlerine izin vermiyor.",
"error_over_quota": "Bu hesabın posta sunucusunda çok fazla push kaydı var (sınır genellikle 15'tir). Artık kullanmadığınız cihazları bildirim ayarlarından iptal edip yeniden deneyin."
}
},
"language_region": {
+4 -1
View File
@@ -1215,7 +1215,10 @@
"revoke": "Відкликати",
"confirm_revoke_title": "Відкликати цей пристрій?",
"confirm_revoke_message": "Він одразу перестане отримувати фонові сповіщення. Якщо знову увімкнути push на цьому пристрої, він зареєструється заново.",
"confirm_revoke_message_this": "Цей пристрій одразу перестане отримувати фонові сповіщення."
"confirm_revoke_message_this": "Цей пристрій одразу перестане отримувати фонові сповіщення.",
"shared_mailboxes_no_push": "Поштові скриньки, до яких вам надали доступ інші користувачі, не надсилають фонових сповіщень.",
"error_forbidden": "Поштовий сервер не дозволяє фонові сповіщення для цього облікового запису.",
"error_over_quota": "Цей обліковий запис має забагато push-реєстрацій на поштовому сервері (зазвичай дозволено 15). Відкличте в налаштуваннях сповіщень пристрої, якими ви більше не користуєтеся, і спробуйте ще раз."
}
},
"language_region": {
+4 -1
View File
@@ -1192,7 +1192,10 @@
"revoke": "撤銷",
"confirm_revoke_title": "要撤銷此裝置的註冊嗎?",
"confirm_revoke_message": "此裝置會立即停止接收背景通知。若在該裝置上再次啟用推送,將重新註冊。",
"confirm_revoke_message_this": "此裝置會立即停止接收背景通知。"
"confirm_revoke_message_this": "此裝置會立即停止接收背景通知。",
"shared_mailboxes_no_push": "其他人與您共用的信箱不會傳送背景通知。",
"error_forbidden": "郵件伺服器不允許此帳號使用背景通知。",
"error_over_quota": "此帳號在郵件伺服器上的推送註冊過多(上限通常為 15 個)。請在通知設定中撤銷不再使用的裝置,然後再試一次。"
},
"sound_selection": {
"title": "通知聲音",
+4 -1
View File
@@ -1215,7 +1215,10 @@
"revoke": "撤销",
"confirm_revoke_title": "要撤销此设备吗?",
"confirm_revoke_message": "它将立即停止接收后台通知。在该设备上重新启用推送会重新注册。",
"confirm_revoke_message_this": "此设备将立即停止接收后台通知。"
"confirm_revoke_message_this": "此设备将立即停止接收后台通知。",
"shared_mailboxes_no_push": "他人与您共享的邮箱不会发送后台通知。",
"error_forbidden": "邮件服务器不允许此账户使用后台通知。",
"error_over_quota": "此账户在邮件服务器上的推送注册过多(上限通常为 15 个)。请在通知设置中撤销不再使用的设备,然后重试。"
}
},
"language_region": {