mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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:
@@ -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 />);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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": "صوت الإشعار",
|
||||
|
||||
@@ -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ó",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "صدای اعلان",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "Мэдэгдэлийн дуу",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "通知聲音",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user