mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
Merge branch 'feat/desktop-offline' into feat/do-keystore-fixes
This commit is contained in:
@@ -16,8 +16,16 @@ vi.mock('@/lib/stalwart/credentials', () => ({
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: { ensureLoaded: async () => {}, get: () => undefined, getPolicy: () => ({ features: { pluginsEnabled: true } }) },
|
||||
}));
|
||||
vi.mock('@/lib/plugin-secrets/known-plugins', () => ({
|
||||
declaredUserCredential: async () => ({ inject: { type: 'bearer' } }),
|
||||
}));
|
||||
vi.mock('@/lib/plugin-secrets/proxy', () => ({
|
||||
adminCredentialOrigins: async () => [],
|
||||
adminCredentialStatus: async () => [],
|
||||
}));
|
||||
vi.mock('@/lib/plugin-secrets/vault', () => ({
|
||||
VaultFullError: class extends Error {},
|
||||
userKey: (user: { username: string; serverUrl: string }) => `${Buffer.from(`${user.username}:${user.serverUrl}`).toString('hex')}0000000000000000`.slice(0, 64),
|
||||
deleteAllUserCredentials: vi.fn(async (user: unknown) => { deleted.push(user); }),
|
||||
deleteUserCredentials: vi.fn(async () => {}),
|
||||
listUserCredentials: vi.fn(async () => []),
|
||||
@@ -31,10 +39,23 @@ async function remove(query: string, headers: Record<string, string> = { 'sec-fe
|
||||
}
|
||||
|
||||
const EVICTED = 'all=true&username=alice&server=https%3A%2F%2Fmail.example.org';
|
||||
const SAME_ORIGIN = { 'sec-fetch-site': 'same-origin' };
|
||||
|
||||
/** The proof cookie an account gets while signed in, as `name=value`. */
|
||||
async function proofFor(username: string): Promise<string> {
|
||||
vi.resetModules();
|
||||
const { setWipeProof } = await import('@/lib/plugin-secrets/wipe-proof');
|
||||
const { NextResponse } = await import('next/server');
|
||||
const answer = NextResponse.json({});
|
||||
setWipeProof(answer, { username, serverUrl: 'https://mail.example.org' });
|
||||
const cookie = answer.cookies.getAll()[0];
|
||||
return `${cookie.name}=${cookie.value}`;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
deleted.length = 0;
|
||||
session.signedIn = false;
|
||||
vi.stubEnv('SESSION_SECRET', 'wipe-test-session-secret-wipe-test-session-secret');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -42,23 +63,58 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('DELETE /api/plugin-secrets?all=true', () => {
|
||||
it('in the desktop app takes a dropped account by who it was, with no cookie', async () => {
|
||||
it('in the desktop app takes a dropped account by who it was, with the proof it got while signed in', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
|
||||
expect((await remove(EVICTED)).status).toBe(200);
|
||||
const answer = await remove(EVICTED, { ...SAME_ORIGIN, cookie: await proofFor('alice') });
|
||||
expect(answer.status).toBe(200);
|
||||
expect(deleted).toEqual([{ username: 'alice', serverUrl: 'https://mail.example.org' }]);
|
||||
expect(answer.headers.get('set-cookie')).toMatch(/Max-Age=0/i);
|
||||
});
|
||||
|
||||
it('in the desktop app won\'t take any name on its own', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
|
||||
expect((await remove(EVICTED)).status).toBe(401);
|
||||
expect(deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it('in the desktop app won\'t take another account\'s proof, or the signed-in account\'s session, for that name', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
|
||||
session.signedIn = true;
|
||||
const bob = await proofFor('bob');
|
||||
const aliceName = (await proofFor('alice')).split('=')[0];
|
||||
expect((await remove(EVICTED, { ...SAME_ORIGIN, cookie: `${aliceName}=${bob.split('=').slice(1).join('=')}` })).status).toBe(401);
|
||||
expect((await remove(EVICTED, { ...SAME_ORIGIN, cookie: `${aliceName}=forged` })).status).toBe(401);
|
||||
expect(deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it('won\'t take it from another site', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
|
||||
expect((await remove(EVICTED, { 'sec-fetch-site': 'cross-site' })).status).toBe(403);
|
||||
const cookie = await proofFor('alice');
|
||||
expect((await remove(EVICTED, { 'sec-fetch-site': 'cross-site', cookie })).status).toBe(403);
|
||||
expect(deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it('gives the proof when an account lists what it has, in the desktop app only', async () => {
|
||||
session.signedIn = true;
|
||||
const list = async () => {
|
||||
vi.resetModules();
|
||||
const vault = await import('@/lib/plugin-secrets/vault');
|
||||
vi.mocked(vault.listUserCredentials).mockResolvedValueOnce([{ pluginId: 'p' } as never]);
|
||||
const { GET } = await import('../route');
|
||||
return GET(new NextRequest('https://app.local/api/plugin-secrets', { headers: SAME_ORIGIN }));
|
||||
};
|
||||
expect((await list()).headers.get('set-cookie')).toBeNull();
|
||||
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
|
||||
expect((await list()).headers.get('set-cookie')).toMatch(/^jmap_psp_/);
|
||||
});
|
||||
|
||||
it('on an instance deletes nobody\'s, with a cookie or without', async () => {
|
||||
expect((await remove(EVICTED)).status).toBe(401);
|
||||
session.signedIn = true;
|
||||
await remove(EVICTED);
|
||||
expect(deleted).toEqual([]);
|
||||
await remove(EVICTED, { ...SAME_ORIGIN, cookie: await proofFor('alice') });
|
||||
expect(deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it('in the desktop app with a cookie and no name takes the signed-in account', async () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@/lib/plugin-secrets/vault';
|
||||
import { storedDataUnavailableResponse } from '@/lib/stored-data-errors';
|
||||
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
|
||||
import { clearWipeProof, hasWipeProof, setWipeProof } from '@/lib/plugin-secrets/wipe-proof';
|
||||
|
||||
const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
||||
const MAX_SECRET_LENGTH = 4096;
|
||||
@@ -52,7 +53,9 @@ export async function GET(request: NextRequest) {
|
||||
const [admin, adminOrigins] = pluginId
|
||||
? await Promise.all([adminCredentialStatus(pluginId), adminCredentialOrigins(pluginId)])
|
||||
: [[], []];
|
||||
return NextResponse.json({ credentials, admin, adminOrigins }, { headers: NO_STORE });
|
||||
const answer = NextResponse.json({ credentials, admin, adminOrigins }, { headers: NO_STORE });
|
||||
if (IS_DESKTOP_BUILD && credentials.length > 0) setWipeProof(answer, user);
|
||||
return answer;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,21 +110,28 @@ export async function POST(request: NextRequest) {
|
||||
if (unavailable) return unavailable;
|
||||
throw error;
|
||||
}
|
||||
return NextResponse.json({ ok: true }, { headers: NO_STORE });
|
||||
const answer = NextResponse.json({ ok: true }, { headers: NO_STORE });
|
||||
if (IS_DESKTOP_BUILD) setWipeProof(answer, user);
|
||||
return answer;
|
||||
}
|
||||
|
||||
/** DELETE /api/plugin-secrets?plugin=<id>[&credential=<id>] - disconnect. */
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const crossOrigin = rejectCrossOriginRequest(request);
|
||||
if (crossOrigin) return crossOrigin;
|
||||
// An account dropped earlier, in the desktop app: no cookie left, and this server serves this device alone.
|
||||
// An account dropped earlier, in the desktop app: no session cookie left, so it
|
||||
// takes the proof that account got while it had one (lib/plugin-secrets/wipe-proof.ts).
|
||||
const { searchParams } = request.nextUrl;
|
||||
const owner = searchParams.get('username');
|
||||
const server = searchParams.get('server');
|
||||
if (IS_DESKTOP_BUILD && searchParams.get('all') === 'true' && owner && server) {
|
||||
const named = { username: owner, serverUrl: server };
|
||||
if (!hasWipeProof(request, named)) return NextResponse.json({ error: 'Not authenticated' }, { status: 401, headers: NO_STORE });
|
||||
try {
|
||||
await deleteAllUserCredentials({ username: owner, serverUrl: server });
|
||||
return NextResponse.json({ ok: true }, { headers: NO_STORE });
|
||||
await deleteAllUserCredentials(named);
|
||||
const answer = NextResponse.json({ ok: true }, { headers: NO_STORE });
|
||||
clearWipeProof(answer, named);
|
||||
return answer;
|
||||
} catch (error) {
|
||||
const unavailable = storedDataUnavailableResponse(error, NO_STORE);
|
||||
if (unavailable) return unavailable;
|
||||
@@ -135,7 +145,9 @@ export async function DELETE(request: NextRequest) {
|
||||
if (IS_DESKTOP_BUILD && request.nextUrl.searchParams.get('all') === 'true') {
|
||||
try {
|
||||
await deleteAllUserCredentials(user);
|
||||
return NextResponse.json({ ok: true }, { headers: NO_STORE });
|
||||
const answer = NextResponse.json({ ok: true }, { headers: NO_STORE });
|
||||
clearWipeProof(answer, user);
|
||||
return answer;
|
||||
} catch (error) {
|
||||
const unavailable = storedDataUnavailableResponse(error, NO_STORE);
|
||||
if (unavailable) return unavailable;
|
||||
|
||||
@@ -10,7 +10,8 @@ import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
|
||||
import { UPSTREAM_JSON_MAX_BYTES, UpstreamBodyTooLargeError, readBodyCapped, readJsonCapped } from '@/lib/security/capped-body';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { DisallowedUrlError } from '@/lib/security/url-guard';
|
||||
import { readStalwartAuthContextFromStore, type StalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||
import type { StalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||
import { readPushAuthContext } from '@/lib/stalwart/push-auth-context';
|
||||
import { rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import { fetchJmapServer, isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch';
|
||||
@@ -98,7 +99,7 @@ async function loginsNamed(login: string): Promise<StalwartAuthContext[]> {
|
||||
const cookieStore = await cookies();
|
||||
const found: StalwartAuthContext[] = [];
|
||||
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
|
||||
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
const ctx = await readPushAuthContext(cookieStore, slot);
|
||||
if (ctx && (await loginFingerprint(ctx.serverUrl, ctx.username)) === login) found.push(ctx);
|
||||
}
|
||||
return found;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { cleanPreview } from '@/lib/utils';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
|
||||
import { readPushAuthContext } from '@/lib/stalwart/push-auth-context';
|
||||
import {
|
||||
getStalwartCredentials,
|
||||
type StalwartCredentials,
|
||||
@@ -45,15 +45,18 @@ interface ResolvedTarget {
|
||||
// session of everyone it is shared with, who sees only that folder - not its
|
||||
// Inbox, nor the message that just arrived there.
|
||||
//
|
||||
// The push names nothing but the account id, and ids repeat across servers
|
||||
// (and a group account is listed by each member). When that leaves more than
|
||||
// one login, answer 'ambiguous' rather than guess: a wrong guess previews
|
||||
// another login's mail and opens it there.
|
||||
async function resolveTargetForAccount(accountId: string, forwarded: Record<string, string>): Promise<ResolvedTarget | 'ambiguous' | null> {
|
||||
// Ids repeat across servers (and a group account is listed by each member), so
|
||||
// the account id alone can leave several logins. On one server, the push's label
|
||||
// names the login that registered it and settles it. Not across servers: logins
|
||||
// with the same account id share one relay record in this browser (lib/web-push.ts
|
||||
// keys it by account id), so the label is only whoever registered last. Whatever
|
||||
// doesn't fit answers 'ambiguous' rather than guess: a wrong guess previews another
|
||||
// login's mail and opens it there.
|
||||
async function resolveTargetForAccount(accountId: string, login: string | null, forwarded: Record<string, string>): Promise<ResolvedTarget | 'ambiguous' | null> {
|
||||
const cookieStore = await cookies();
|
||||
const probes: Promise<{ target: ResolvedTarget; own: boolean } | null>[] = [];
|
||||
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
|
||||
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
const ctx = await readPushAuthContext(cookieStore, slot);
|
||||
if (!ctx) continue;
|
||||
const serverUrl = ctx.serverUrl.replace(/\/+$/, '');
|
||||
probes.push(
|
||||
@@ -91,6 +94,13 @@ async function resolveTargetForAccount(accountId: string, forwarded: Record<stri
|
||||
);
|
||||
}
|
||||
const results = (await Promise.all(probes)).filter((r) => r !== null);
|
||||
if (login && results.length > 0) {
|
||||
const name = login.toLowerCase();
|
||||
const named = results.filter((r) => r.target.username.toLowerCase() === name || r.target.accountName?.toLowerCase() === name);
|
||||
const servers = new Set(results.map((r) => r.target.serverUrl));
|
||||
if (named.length !== 1 || servers.size > 1) return 'ambiguous';
|
||||
return named[0].target;
|
||||
}
|
||||
const owners = results.filter((r) => r.own);
|
||||
if (owners.length === 1) return owners[0].target;
|
||||
if (results.length === 1) return results[0].target;
|
||||
@@ -141,7 +151,7 @@ export async function GET(request: NextRequest) {
|
||||
let target: ResolvedTarget | null = null;
|
||||
let authHeader: string;
|
||||
if (requestedAccountId) {
|
||||
const resolved = await resolveTargetForAccount(requestedAccountId, forwarded);
|
||||
const resolved = await resolveTargetForAccount(requestedAccountId, request.nextUrl.searchParams.get('login'), forwarded);
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NotificationActionsHost } from '../notification-actions-host';
|
||||
|
||||
// The worker shows nothing for a login with email notifications off or do not
|
||||
// disturb on. Each login has its own settings, so the answer goes per login.
|
||||
|
||||
const writeLoginQuiet = vi.fn(async (..._args: unknown[]) => {});
|
||||
let state = { isAuthenticated: true, username: 'me@example.org' as string | null, emailNotificationsEnabled: true, doNotDisturb: false };
|
||||
|
||||
vi.mock('@/lib/push-preferences', () => ({ writeLoginQuiet: (...args: unknown[]) => writeLoginQuiet(...args), PUSH_STATE_CACHE: 'x' }));
|
||||
vi.mock('@/lib/pwa-notify/worker-state', () => ({ writeNotificationActionPrefs: async () => {} }));
|
||||
vi.mock('@/lib/pwa-notify/page-actions', () => ({ isNotificationActionRequest: () => false, runNotificationAction: async () => {} }));
|
||||
vi.mock('@/lib/pwa-notify/plugin-veto', () => ({ pluginsMayVeto: () => false }));
|
||||
vi.mock('@/lib/offline/offline-mode', () => ({ scheduleFlushAll: () => {} }));
|
||||
vi.mock('@/lib/archive-gate', () => ({ useArchiveActionEnabled: () => false }));
|
||||
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
||||
vi.mock('@/stores/auth-store', () => ({ useAuthStore: (select: (s: typeof state) => unknown) => select(state) }));
|
||||
vi.mock('@/stores/settings-store', () => ({
|
||||
useSettingsStore: (select: (s: typeof state & { archiveMode: string }) => unknown) => select({ ...state, archiveMode: 'single' }),
|
||||
}));
|
||||
vi.mock('@/stores/plugin-store', () => ({ usePluginStore: (select: (s: { plugins: unknown[] }) => unknown) => select({ plugins: [] }) }));
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const useAccountStore = (select: (s: { accounts: unknown[] }) => unknown) => select({ accounts: [] });
|
||||
useAccountStore.getState = () => ({ accounts: [] });
|
||||
return { useAccountStore };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
writeLoginQuiet.mockClear();
|
||||
state = { isAuthenticated: true, username: 'me@example.org', emailNotificationsEnabled: true, doNotDisturb: false };
|
||||
});
|
||||
|
||||
describe('what the page tells the worker about a login', () => {
|
||||
it('not quiet with everything on', () => {
|
||||
render(<NotificationActionsHost />);
|
||||
expect(writeLoginQuiet).toHaveBeenCalledWith('me@example.org', false);
|
||||
});
|
||||
|
||||
it('quiet with email notifications off', () => {
|
||||
state.emailNotificationsEnabled = false;
|
||||
render(<NotificationActionsHost />);
|
||||
expect(writeLoginQuiet).toHaveBeenCalledWith('me@example.org', true);
|
||||
});
|
||||
|
||||
it('quiet with do not disturb on', () => {
|
||||
state.doNotDisturb = true;
|
||||
render(<NotificationActionsHost />);
|
||||
expect(writeLoginQuiet).toHaveBeenCalledWith('me@example.org', true);
|
||||
});
|
||||
|
||||
it('nothing while signed out', () => {
|
||||
state.isAuthenticated = false;
|
||||
state.username = null;
|
||||
render(<NotificationActionsHost />);
|
||||
expect(writeLoginQuiet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
|
||||
import { useArchiveActionEnabled } from '@/lib/archive-gate';
|
||||
import { scheduleFlushAll } from '@/lib/offline/offline-mode';
|
||||
import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
|
||||
import { writeLoginQuiet } from '@/lib/push-preferences';
|
||||
import { isNotificationActionRequest, runNotificationAction } from '@/lib/pwa-notify/page-actions';
|
||||
import { pluginsMayVeto } from '@/lib/pwa-notify/plugin-veto';
|
||||
import { writeNotificationActionPrefs } from '@/lib/pwa-notify/worker-state';
|
||||
@@ -42,6 +43,16 @@ export function NotificationActionsHost() {
|
||||
});
|
||||
}, [isAuthenticated, archiveEnabled, archiveMode, mayVeto, archive, markRead, reply, replyPlaceholder]);
|
||||
|
||||
// Email notifications off or do not disturb on, for the login on screen: the
|
||||
// worker shows nothing for that login's mail. The label a push carries is this
|
||||
// name (lib/web-push.ts), and each login has its own settings.
|
||||
const username = useAuthStore((s) => s.username);
|
||||
const quiet = useSettingsStore((s) => !s.emailNotificationsEnabled || s.doNotDisturb);
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !username) return;
|
||||
void writeLoginQuiet(username, quiet);
|
||||
}, [isAuthenticated, username, quiet]);
|
||||
|
||||
// Which logins this tab holds: if it stays silent on a click for one of them,
|
||||
// the worker opens the message rather than go round its plugins.
|
||||
const loginKey = useMemo(() => accounts.map((a) => `${a.serverUrl}\n${a.username}`).join('\n\n'), [accounts]);
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
resyncWebPush,
|
||||
waitForWebPushResync,
|
||||
} from "@/lib/web-push";
|
||||
import { webPushErrorKey } from "@/lib/web-push-errors";
|
||||
import { resolveActiveRelayUrl } from "@/lib/push-relays";
|
||||
import { jmapErrorType } from "@/lib/jmap/jmap-error";
|
||||
import {
|
||||
isPWAInstallPromptVisible,
|
||||
PWA_INSTALL_PROMPT_VISIBILITY_EVENT,
|
||||
@@ -310,11 +310,9 @@ export function PushNotificationPrompt() {
|
||||
});
|
||||
setShowPrompt(false);
|
||||
} catch (err) {
|
||||
// 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");
|
||||
// Readable text for what a user can act on.
|
||||
const key = webPushErrorKey(err);
|
||||
setError(key ? tPush(key) : err instanceof Error ? err.message : "Failed to enable push");
|
||||
} finally {
|
||||
setIsEnabling(false);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import { act } from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { NotificationSettings } from '../notification-settings';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
@@ -137,6 +137,25 @@ describe('NotificationSettings - pushNotifyInboxOnly', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationSettings - demo mode', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ client: fakeClient, username: 'me@example.com', isDemoMode: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ isDemoMode: false });
|
||||
});
|
||||
|
||||
it('says push is not available and offers no switch that would call the relay', async () => {
|
||||
const { findByText, getAllByRole } = render(<NotificationSettings />);
|
||||
await findByText('push.status_demo');
|
||||
const pushSwitch = getAllByRole('switch').find((el) => (el as HTMLButtonElement).disabled);
|
||||
expect(pushSwitch).toBeTruthy();
|
||||
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;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useAccountStore } from '@/stores/account-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { disableWebPush, enableWebPushForAccounts, isWebPushEnabled, isWebPushSupported } from '@/lib/web-push';
|
||||
import { webPushErrorKey } from '@/lib/web-push-errors';
|
||||
|
||||
interface FailedAccount {
|
||||
id: string;
|
||||
@@ -17,11 +18,6 @@ interface FailedAccount {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
function failureReason(error: unknown): string {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn push on or off for several accounts at once.
|
||||
*
|
||||
@@ -32,9 +28,22 @@ function failureReason(error: unknown): string {
|
||||
*/
|
||||
export function MultiAccountPushSettings({ relayBaseUrl }: { relayBaseUrl?: string }) {
|
||||
const t = useTranslations('settings.notifications.push_all');
|
||||
const tPush = useTranslations('settings.notifications.push');
|
||||
const failureReason = (error: unknown): string => {
|
||||
const key = webPushErrorKey(error);
|
||||
if (key) return tPush(key);
|
||||
return error instanceof Error && error.message ? error.message : String(error);
|
||||
};
|
||||
const failureOf = (account: (typeof accounts)[number], error: unknown): FailedAccount => ({
|
||||
id: account.id,
|
||||
name: account.displayName || account.label || account.username,
|
||||
email: account.email || account.username,
|
||||
reason: failureReason(error),
|
||||
});
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
// Re-read the client map whenever a connection appears or drops.
|
||||
const revision = useAuthStore(s => s.connectedAccountsRevision);
|
||||
const isDemoMode = useAuthStore(s => s.isDemoMode);
|
||||
const [enabled, setEnabled] = useState<Record<string, boolean>>({});
|
||||
const [chosen, setChosen] = useState<Set<string>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -59,7 +68,7 @@ export function MultiAccountPushSettings({ relayBaseUrl }: { relayBaseUrl?: stri
|
||||
|
||||
useEffect(() => { void refresh(); }, [refresh, revision]);
|
||||
|
||||
if (!isWebPushSupported() || connected.length < 2) return null;
|
||||
if (!isWebPushSupported() || isDemoMode || connected.length < 2) return null;
|
||||
|
||||
const toggle = (id: string, on: boolean) => setChosen(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -95,11 +104,15 @@ export function MultiAccountPushSettings({ relayBaseUrl }: { relayBaseUrl?: stri
|
||||
};
|
||||
}));
|
||||
} else {
|
||||
// Teardown swallows its own failures, so every account ends up local-off.
|
||||
// Every account ends up off here; one the server or relay couldn't be
|
||||
// told about is named, since it may keep getting notifications for days.
|
||||
const failed: FailedAccount[] = [];
|
||||
for (const account of targets) {
|
||||
await disableWebPush({ client: auth.getClientForAccount(account.id)!, relayBaseUrl });
|
||||
await disableWebPush({ client: auth.getClientForAccount(account.id)!, relayBaseUrl })
|
||||
.catch((error: unknown) => { failed.push(failureOf(account, error)); });
|
||||
}
|
||||
setNotice(t('result_disabled', { count: targets.length }));
|
||||
setFailures(failed);
|
||||
}
|
||||
setChosen(new Set());
|
||||
} finally {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useIdentityStore } from '@/stores/identity-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';
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
revokePushDevice,
|
||||
serverSupportsEmailPush,
|
||||
} from '@/lib/web-push';
|
||||
import { webPushErrorKey } from '@/lib/web-push-errors';
|
||||
import type { PushDevice } from '@/lib/web-push';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode';
|
||||
@@ -72,7 +72,10 @@ export function NotificationSettings() {
|
||||
const username = useAuthStore((s) => s.username);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
|
||||
const supported = typeof window !== 'undefined' && isWebPushSupported();
|
||||
// The demo client has no mail server to subscribe with. Enabling would get as
|
||||
// far as a relay record and a browser subscription, then fail.
|
||||
const isDemoMode = useAuthStore((s) => s.isDemoMode);
|
||||
const supported = typeof window !== 'undefined' && isWebPushSupported() && !isDemoMode;
|
||||
// "Inbox only" is enforced by the server's emailPush filter; without that
|
||||
// capability (Stalwart < 0.16.16) the toggle would do nothing.
|
||||
const inboxOnlyAvailable = supported && !!client && serverSupportsEmailPush(client);
|
||||
@@ -145,7 +148,7 @@ export function NotificationSettings() {
|
||||
// In the static Lite build push is off by design (no service worker, see
|
||||
// components/service-worker-registration.tsx), not a browser limitation.
|
||||
const statusDescription = pushStatus.kind === 'unsupported'
|
||||
? (IS_LITE ? t('push.status_lite') : `${t('push.status_unsupported')} ${t('push.ios_hint')}`)
|
||||
? (IS_LITE ? t('push.status_lite') : isDemoMode ? t('push.status_demo') : `${t('push.status_unsupported')} ${t('push.ios_hint')}`)
|
||||
: noRelay && !pushEnabled
|
||||
? tThirdParty('push_needs_relay')
|
||||
: busy
|
||||
@@ -171,14 +174,9 @@ export function NotificationSettings() {
|
||||
// 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;
|
||||
}
|
||||
const key = webPushErrorKey(err);
|
||||
if (key) return t(`push.${key}`);
|
||||
return err instanceof Error ? err.message : fallback;
|
||||
};
|
||||
|
||||
// `forceRecreate` is what the Re-register button is for: without it the enable
|
||||
@@ -265,10 +263,7 @@ export function NotificationSettings() {
|
||||
await disableWebPush({ client, relayBaseUrl: activeRelayUrl });
|
||||
setPushStatus({ kind: 'idle' });
|
||||
} catch (err) {
|
||||
setPushStatus({
|
||||
kind: 'error',
|
||||
message: err instanceof Error ? err.message : 'Failed to disable push',
|
||||
});
|
||||
setPushStatus({ kind: 'error', message: pushErrorMessage(err, 'Failed to disable push') });
|
||||
} finally {
|
||||
await refreshDevices();
|
||||
}
|
||||
@@ -290,10 +285,7 @@ export function NotificationSettings() {
|
||||
await revokePushDevice({ client, device, relayBaseUrl: activeRelayUrl });
|
||||
if (device.isThisDevice) setPushStatus({ kind: 'idle' });
|
||||
} catch (err) {
|
||||
setPushStatus({
|
||||
kind: 'error',
|
||||
message: err instanceof Error ? err.message : 'Failed to revoke device',
|
||||
});
|
||||
setPushStatus({ kind: 'error', message: pushErrorMessage(err, 'Failed to revoke device') });
|
||||
} finally {
|
||||
setRevokingId(null);
|
||||
await refreshDevices();
|
||||
|
||||
@@ -5,7 +5,7 @@ import vm from 'node:vm';
|
||||
export async function pushClick(
|
||||
accountId: string,
|
||||
preview: (url: string) => Promise<unknown>,
|
||||
options: { warm?: boolean; basePath?: string; legacy?: boolean; emailId?: string; noParamsSize?: boolean } = {},
|
||||
options: { warm?: boolean; basePath?: string; legacy?: boolean; emailId?: string; noParamsSize?: boolean; onScreen?: string[] } = {},
|
||||
): Promise<string> {
|
||||
const source = readFileSync(resolve('public/sw.js'), 'utf8');
|
||||
let notification: { data: unknown } | undefined;
|
||||
@@ -33,7 +33,7 @@ export async function pushClick(
|
||||
},
|
||||
registration: {
|
||||
showNotification: async (_title: string, data: { data: unknown }) => { notification = data; },
|
||||
getNotifications: async () => [],
|
||||
getNotifications: async () => (options.onScreen?.length ? [{ data: { ids: options.onScreen } }] : []),
|
||||
},
|
||||
},
|
||||
caches: { open: async () => ({
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// The context cookie lasts as long as the browser session. With every window
|
||||
// closed and the browser started again by a push, only the remembered session
|
||||
// is left, and the worker has to make do with that or show "New mail".
|
||||
vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() } }));
|
||||
const maxHours = vi.hoisted(() => ({ value: 0 }));
|
||||
vi.mock('@/lib/auth/session-age', () => ({
|
||||
isPastMaxAge: (signedInAt: number, hours: number) => hours > 0 && Date.now() - signedInAt > hours * 3600_000,
|
||||
limitsEnforcedSince: async () => Date.now(),
|
||||
maxSessionHoursFor: async () => maxHours.value,
|
||||
}));
|
||||
const impersonation = vi.hoisted(() => ({ master: null as string | null }));
|
||||
const policy = vi.hoisted(() => ({ value: {} as Record<string, unknown> }));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: { ensureLoaded: async () => {}, getPolicy: () => policy.value },
|
||||
}));
|
||||
vi.mock('@/lib/impersonation/master-config', () => ({
|
||||
readImpersonationConfig: () => (impersonation.master ? { masterPassword: impersonation.master } : null),
|
||||
}));
|
||||
|
||||
import { encryptSession } from '@/lib/auth/crypto';
|
||||
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { readPushAuthContext } from '@/lib/stalwart/push-auth-context';
|
||||
import { stalwartAuthContextCookieName } from '@/lib/stalwart/auth-context';
|
||||
import { encryptPayload } from '@/lib/auth/crypto';
|
||||
|
||||
function store(cookies: Record<string, string>) {
|
||||
return { get: (name: string) => (name in cookies ? { value: cookies[name] } : undefined) } as never;
|
||||
}
|
||||
|
||||
const remembered = (signedInAt = Date.now(), password = 'pw') => encryptSession('https://mail.example.org', 'me@example.org', password, signedInAt);
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.SESSION_SECRET = 'a-test-secret-that-is-long-enough-for-this-0123456789';
|
||||
maxHours.value = 0;
|
||||
impersonation.master = null;
|
||||
policy.value = {};
|
||||
});
|
||||
|
||||
describe('readPushAuthContext', () => {
|
||||
it('prefers the context cookie', async () => {
|
||||
const context = encryptPayload({ serverUrl: 'https://ctx.example.org', username: 'ctx@example.org', authHeader: 'Bearer t' }, 'session-context');
|
||||
const found = await readPushAuthContext(store({ [stalwartAuthContextCookieName(0)]: context, [sessionCookieName(0)]: remembered() }), 0);
|
||||
expect(found?.username).toBe('ctx@example.org');
|
||||
});
|
||||
|
||||
it('falls back to the remembered session', async () => {
|
||||
const found = await readPushAuthContext(store({ [sessionCookieName(1)]: remembered() }), 1);
|
||||
expect(found).toMatchObject({ serverUrl: 'https://mail.example.org', username: 'me@example.org' });
|
||||
expect(found?.authHeader).toMatch(/^Basic /);
|
||||
});
|
||||
|
||||
it('finds nothing in another slot, or without either cookie', async () => {
|
||||
expect(await readPushAuthContext(store({ [sessionCookieName(1)]: remembered() }), 0)).toBeNull();
|
||||
expect(await readPushAuthContext(store({}), 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a session past the organisation\'s maximum length', async () => {
|
||||
maxHours.value = 1;
|
||||
expect(await readPushAuthContext(store({ [sessionCookieName(0)]: remembered(Date.now() - 2 * 3600_000) }), 0)).toBeNull();
|
||||
expect(await readPushAuthContext(store({ [sessionCookieName(0)]: remembered() }), 0)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('stays out when the organisation has limits only a page enforces', async () => {
|
||||
const cookies = store({ [sessionCookieName(0)]: remembered() });
|
||||
policy.value = { organisation: { session: { idleMinutes: 30 } } };
|
||||
expect(await readPushAuthContext(cookies, 0)).toBeNull();
|
||||
policy.value = { scopes: [{ match: { domains: ['example.org'] }, policy: { organisation: { session: { idleMinutes: 5 } } } }] };
|
||||
expect(await readPushAuthContext(cookies, 0)).toBeNull();
|
||||
policy.value = { scopes: [{ match: { groups: ['staff@example.org'] }, policy: { organisation: { session: { maxHours: 8 } } } }] };
|
||||
expect(await readPushAuthContext(cookies, 0)).toBeNull();
|
||||
// A maximum by domain the server checks itself, above.
|
||||
policy.value = { scopes: [{ match: { domains: ['example.org'] }, policy: { organisation: { session: { maxHours: 8 } } } }] };
|
||||
expect(await readPushAuthContext(cookies, 0)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('stays out for a bare user name, which policies for a domain cannot match', async () => {
|
||||
const token = encryptSession('https://mail.example.org', 'john', 'pw', Date.now());
|
||||
expect(await readPushAuthContext(store({ [sessionCookieName(0)]: token }), 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses garbage and a cookie holding the impersonation master password', async () => {
|
||||
expect(await readPushAuthContext(store({ [sessionCookieName(0)]: 'garbage' }), 0)).toBeNull();
|
||||
impersonation.master = 'master-secret';
|
||||
expect(await readPushAuthContext(store({ [sessionCookieName(0)]: remembered(Date.now(), 'master-secret') }), 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
// The page mirrors the notification sound setting into the service worker's
|
||||
// Cache store, where sw.js reads it before showing a push notification (#1289).
|
||||
|
||||
describe('writeLoginQuiet', () => {
|
||||
it('keeps one answer per login', async () => {
|
||||
const stored = new Map<string, string>();
|
||||
vi.stubGlobal('caches', {
|
||||
open: async () => ({
|
||||
match: async (key: string) => (stored.has(key) ? new Response(stored.get(key)) : undefined),
|
||||
put: async (key: string, res: Response) => { stored.set(key, await res.text()); },
|
||||
}),
|
||||
});
|
||||
const { writeLoginQuiet } = await import('../push-preferences');
|
||||
await writeLoginQuiet('a@example.org', true);
|
||||
await writeLoginQuiet('b@example.org', false);
|
||||
await writeLoginQuiet('a@example.org', true);
|
||||
const value = JSON.parse(stored.get(`${window.location.origin}/__push-state/quiet-logins`) ?? 'null');
|
||||
expect(value).toEqual({ 'a@example.org': true, 'b@example.org': false });
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe('writePushPreferences', () => {
|
||||
const put = vi.fn();
|
||||
|
||||
@@ -65,7 +84,7 @@ describe('settings store push preference mirror', () => {
|
||||
useSettingsStore.getState().updateSetting('emailNotificationsEnabled', true);
|
||||
expect(writePushPreferences).not.toHaveBeenCalled();
|
||||
|
||||
// Email notifications off means no sound for push either.
|
||||
// Email notifications off means no notification, so no sound either.
|
||||
useSettingsStore.getState().updateSetting('emailNotificationSound', true);
|
||||
useSettingsStore.getState().updateSetting('emailNotificationsEnabled', false);
|
||||
expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { beforeAll, describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// The push names only the JMAP account id, and ids repeat: Stalwart numbers
|
||||
// accounts alike on every install, and a group account is listed in the
|
||||
@@ -26,13 +26,18 @@ vi.mock('@/lib/account-utils', () => ({ MAX_ACCOUNT_SLOTS: 3 }));
|
||||
|
||||
// slot 0: alice on one.example, slot 1: bob on two.example, slot 2: carol on one.example
|
||||
const LOGINS = [
|
||||
{ serverUrl: 'https://one.example', authHeader: 'Basic alice' },
|
||||
{ serverUrl: 'https://two.example', authHeader: 'Basic bob' },
|
||||
{ serverUrl: 'https://one.example', authHeader: 'Basic carol' },
|
||||
{ serverUrl: 'https://one.example', authHeader: 'Basic alice', username: 'alice@one.example' },
|
||||
{ serverUrl: 'https://two.example', authHeader: 'Basic bob', username: 'bob@two.example' },
|
||||
{ serverUrl: 'https://one.example', authHeader: 'Basic carol', username: 'carol@one.example' },
|
||||
];
|
||||
const ORIGINAL_LOGINS = [...LOGINS];
|
||||
vi.mock('@/lib/stalwart/auth-context', () => ({
|
||||
readStalwartAuthContextFromStore: (_store: unknown, slot: number) => LOGINS[slot] ?? null,
|
||||
}));
|
||||
const remembered = vi.hoisted(() => ({ value: null as null | { serverUrl: string; authHeader: string; username: string } }));
|
||||
vi.mock('@/lib/stalwart/push-auth-context', () => ({
|
||||
readPushAuthContext: async (_store: unknown, slot: number) => LOGINS[slot] ?? (slot === 0 ? remembered.value : null),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/stalwart/credentials', () => ({
|
||||
getStalwartCredentials: vi.fn(),
|
||||
@@ -73,10 +78,10 @@ vi.mock('@/lib/admin/config-manager', async () => {
|
||||
return { configManager: { ensureLoaded: async () => {}, getPolicy: () => DEFAULT_POLICY } };
|
||||
});
|
||||
|
||||
async function callRoute(accountId: string) {
|
||||
async function callRoute(accountId: string, login?: string) {
|
||||
const { GET } = await import('@/app/api/push/preview/route');
|
||||
const request = {
|
||||
nextUrl: { searchParams: { get: (k: string) => (k === 'accountId' ? accountId : k === 'emailId' ? 'e1' : null) } },
|
||||
nextUrl: { searchParams: { get: (k: string) => (k === 'accountId' ? accountId : k === 'emailId' ? 'e1' : k === 'login' ? login ?? null : null) } },
|
||||
headers: new Headers(),
|
||||
};
|
||||
const res = (await GET(request as unknown as Parameters<typeof GET>[0])) as unknown as {
|
||||
@@ -88,11 +93,86 @@ async function callRoute(accountId: string) {
|
||||
|
||||
const mailCalls = () => fetchJmapServer.mock.calls.filter(([url]) => !url.endsWith('/.well-known/jmap'));
|
||||
|
||||
// The route's first import can take longer than a test's limit on a busy machine,
|
||||
// and a test that times out keeps running into the next one's state.
|
||||
beforeAll(async () => {
|
||||
await import('@/app/api/push/preview/route');
|
||||
}, 60_000);
|
||||
|
||||
describe('push preview after the browser was closed', () => {
|
||||
it('reads the login from the remembered session when the context cookie is gone', async () => {
|
||||
remembered.value = { serverUrl: 'https://one.example', authHeader: 'Basic remembered', username: 'alice@one.example' };
|
||||
LOGINS.length = 0;
|
||||
try {
|
||||
SESSIONS = { 'Basic remembered': session('https://one.example', 'a') };
|
||||
const { status, body } = await callRoute('a');
|
||||
expect(status).toBe(200);
|
||||
expect((body.email as { subject: string }).subject).toBe('mail of Basic remembered');
|
||||
} finally {
|
||||
remembered.value = null;
|
||||
LOGINS.push(...ORIGINAL_LOGINS);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('push preview when the account id is in more than one login', () => {
|
||||
beforeEach(() => {
|
||||
fetchJmapServer.mockClear();
|
||||
});
|
||||
|
||||
it('does not go by the name when two servers use the same id: those logins share one relay record', async () => {
|
||||
SESSIONS = {
|
||||
'Basic alice': session('https://one.example', 'a'),
|
||||
'Basic bob': session('https://two.example', 'z'),
|
||||
'Basic carol': session('https://one.example', 'z'),
|
||||
};
|
||||
const { status, body } = await callRoute('z', 'BOB@two.example');
|
||||
expect(status).toBe(409);
|
||||
expect(body.slot).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not answer for another login when the one the push names is gone', async () => {
|
||||
SESSIONS = {
|
||||
'Basic alice': session('https://one.example', 'a'),
|
||||
'Basic bob': session('https://two.example', 'z'),
|
||||
'Basic carol': session('https://one.example', 'c'),
|
||||
};
|
||||
const { status } = await callRoute('z', 'somebody-gone@two.example');
|
||||
expect(status).toBe(409);
|
||||
});
|
||||
|
||||
it('takes the one login with the account when the push names it', async () => {
|
||||
SESSIONS = {
|
||||
'Basic alice': session('https://one.example', 'a'),
|
||||
'Basic bob': session('https://two.example', 'z'),
|
||||
'Basic carol': session('https://one.example', 'c'),
|
||||
};
|
||||
const { status, body } = await callRoute('z', 'bob@two.example');
|
||||
expect(status).toBe(200);
|
||||
expect(body.slot).toBe(1);
|
||||
});
|
||||
|
||||
it('takes the login the push names for a group two logins belong to', async () => {
|
||||
SESSIONS = {
|
||||
'Basic alice': session('https://one.example', 'a', ['team']),
|
||||
'Basic bob': session('https://two.example', 'b'),
|
||||
'Basic carol': session('https://one.example', 'c', ['team']),
|
||||
};
|
||||
const { status, body } = await callRoute('team', 'carol@one.example');
|
||||
expect(status).toBe(200);
|
||||
expect(body.slot).toBe(2);
|
||||
});
|
||||
|
||||
it('does not take a login that does not have the account', async () => {
|
||||
SESSIONS = {
|
||||
'Basic alice': session('https://one.example', 'a', ['team']),
|
||||
'Basic bob': session('https://two.example', 'b'),
|
||||
'Basic carol': session('https://one.example', 'c', ['team']),
|
||||
};
|
||||
const { status } = await callRoute('team', 'bob@two.example');
|
||||
expect(status).toBe(409);
|
||||
});
|
||||
|
||||
it('shows no preview and names no login when two servers use the same id', async () => {
|
||||
SESSIONS = {
|
||||
'Basic alice': session('https://one.example', 'a'),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { forgetRelayRegistration, keepRelayRegistration, relayRegistrationsKey } from '@/lib/pwa-notify/worker-state';
|
||||
|
||||
const store = new Map<string, string>();
|
||||
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
vi.stubGlobal('caches', {
|
||||
open: async () => ({
|
||||
match: async (key: string) => (store.has(key) ? new Response(store.get(key)) : undefined),
|
||||
put: async (key: string, res: Response) => { store.set(key, await res.text()); },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const stored = () => JSON.parse(store.get(relayRegistrationsKey(window.location.origin)) ?? 'null');
|
||||
|
||||
describe('what the worker keeps to re-register without a tab', () => {
|
||||
it('keeps one entry per account and the key', async () => {
|
||||
await keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1', accountLabel: 'a@x' }, 'KEY');
|
||||
await keepRelayRegistration('b', { relay: 'https://r', subscriptionId: 'd2' }, 'KEY');
|
||||
expect(stored()).toEqual({
|
||||
applicationServerKey: 'KEY',
|
||||
entries: { a: { relay: 'https://r', subscriptionId: 'd1', accountLabel: 'a@x' }, b: { relay: 'https://r', subscriptionId: 'd2' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('forgets an account, and leaves the others', async () => {
|
||||
await keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1' }, 'KEY');
|
||||
await keepRelayRegistration('b', { relay: 'https://r', subscriptionId: 'd2' }, 'KEY');
|
||||
await forgetRelayRegistration('a');
|
||||
expect(Object.keys(stored().entries)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('stays out of the way without a Cache API', async () => {
|
||||
vi.stubGlobal('caches', undefined);
|
||||
await expect(keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1' }, 'KEY')).resolves.toBeUndefined();
|
||||
await expect(forgetRelayRegistration('a')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -120,6 +120,36 @@ describe('standalone guard check', () => {
|
||||
expect(findForbidden(dir)).toEqual(['worktrees/webmail/data/']);
|
||||
});
|
||||
|
||||
it('goes by the name in any case, as a case-insensitive disk and Docker context would', () => {
|
||||
const dir = tempDir();
|
||||
put(dir, 'server.js');
|
||||
put(dir, '.ENV', 'SECRET=1');
|
||||
put(dir, 'Plugin-Signing.KEY', 'SECRET=1');
|
||||
put(dir, 'Data/admin.json');
|
||||
put(dir, 'LOCAL-DATA/a');
|
||||
put(dir, 'Repos/a');
|
||||
expect(findForbidden(dir)).toEqual(['.ENV', 'Data/', 'LOCAL-DATA/', 'Plugin-Signing.KEY', 'Repos/']);
|
||||
});
|
||||
|
||||
it('refuses a link: it would ship whatever it points at', () => {
|
||||
const dir = tempDir();
|
||||
const outside = tempDir();
|
||||
put(outside, 'admin.json');
|
||||
put(dir, 'server.js');
|
||||
symlinkSync(outside, path.join(dir, 'state'), 'junction');
|
||||
expect(findForbidden(dir)).toEqual(['state (link)']);
|
||||
});
|
||||
|
||||
it('leaves the links of .next and node_modules alone: Turbopack makes those on purpose', () => {
|
||||
const dir = tempDir();
|
||||
const outside = tempDir();
|
||||
put(dir, 'node_modules/a/index.js');
|
||||
mkdirSync(path.join(dir, '.next', 'node_modules'), { recursive: true });
|
||||
symlinkSync(path.join(dir, 'node_modules', 'a'), path.join(dir, '.next', 'node_modules', 'a-1234'), 'junction');
|
||||
symlinkSync(outside, path.join(dir, 'node_modules', 'b'), 'junction');
|
||||
expect(findForbidden(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes the .env files Next copies next to server.js, then passes', () => {
|
||||
const dir = tempDir();
|
||||
put(dir, 'worktrees/webmail/server.js');
|
||||
|
||||
@@ -90,12 +90,13 @@ const previewOf = (unreadTotal: number) => JSON.stringify({
|
||||
});
|
||||
|
||||
describe('the buttons a notification gets', () => {
|
||||
const buttonsFor = async (opts: { prefs?: unknown; max?: number | null; inline?: boolean; unreadTotal?: number }) => {
|
||||
const buttonsFor = async (opts: { prefs?: unknown; max?: number | null; inline?: boolean; unreadTotal?: number; onScreen?: string[] }) => {
|
||||
const w = worker({
|
||||
caches: await cachesWith(opts.prefs),
|
||||
max: opts.max,
|
||||
inline: opts.inline,
|
||||
fetch: async () => new Response(previewOf(opts.unreadTotal ?? 1), { status: 200 }),
|
||||
...(opts.onScreen ? { notifications: [{ data: { ids: opts.onScreen } }] } : {}),
|
||||
});
|
||||
await pushFor({})(w);
|
||||
expect(w.shown).toHaveLength(1);
|
||||
@@ -133,7 +134,7 @@ describe('the buttons a notification gets', () => {
|
||||
});
|
||||
|
||||
it('gives the group notification none: it names no message', async () => {
|
||||
expect(await buttonsFor({ prefs: PREFS, unreadTotal: 4 })).toBeUndefined();
|
||||
expect(await buttonsFor({ prefs: PREFS, onScreen: ['e0'] })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gives a message without its login none', async () => {
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// What one new message, several at once and several accounts turn into on screen.
|
||||
const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8');
|
||||
|
||||
type Preview = Record<string, unknown> | null;
|
||||
type Shown = { title: string; body?: unknown; data?: Record<string, unknown>; tag: string; options: Record<string, unknown>; close: () => void };
|
||||
|
||||
function loadWorker(preview: (url: URL) => Preview, extra: { clients?: unknown[]; globals?: Record<string, unknown>; prefs?: unknown; quiet?: Record<string, boolean>; openWindow?: (url: string) => Promise<unknown>; registration?: Record<string, unknown> } = {}) {
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
const shade = new Map<string, Shown>();
|
||||
const previews: URL[] = [];
|
||||
const cacheStore = new Map<string, string>();
|
||||
if (extra.prefs) cacheStore.set('https://mail.example.com/__push-state/prefs', JSON.stringify(extra.prefs));
|
||||
if (extra.quiet) cacheStore.set('https://mail.example.com/__push-state/quiet-logins', JSON.stringify(extra.quiet));
|
||||
const self = {
|
||||
location: { href: 'https://mail.example.com/sw.js', origin: 'https://mail.example.com' },
|
||||
addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn),
|
||||
skipWaiting: () => {},
|
||||
clients: { claim: () => {}, matchAll: async () => extra.clients ?? [], openWindow: extra.openWindow },
|
||||
registration: {
|
||||
showNotification: async (title: string, options: Record<string, unknown>) => {
|
||||
const tag = String(options.tag ?? '');
|
||||
shade.set(tag, { title, body: options.body, data: options.data as Record<string, unknown>, tag, options, close: () => shade.delete(tag) });
|
||||
},
|
||||
getNotifications: async (filter?: { tag?: string }) => [...shade.values()].filter((n) => !filter?.tag || n.tag === filter.tag),
|
||||
...extra.registration,
|
||||
},
|
||||
};
|
||||
const caches = {
|
||||
open: async () => ({
|
||||
match: async (key: string) => {
|
||||
const value = cacheStore.get(key);
|
||||
return value === undefined ? undefined : { json: async () => JSON.parse(value) };
|
||||
},
|
||||
put: async (key: string, res: { text: () => Promise<string> }) => { cacheStore.set(key, await res.text()); },
|
||||
}),
|
||||
};
|
||||
const fetchMock = vi.fn(async (input: string) => {
|
||||
const url = new URL(input, 'https://mail.example.com');
|
||||
previews.push(url);
|
||||
const body = preview(url);
|
||||
return body ? { ok: true, json: async () => body } : { ok: false, status: 401, json: async () => ({}) };
|
||||
});
|
||||
const context = vm.createContext({ self, caches, fetch: fetchMock, URL, URLSearchParams, Response, Date, console, AbortController, setTimeout, clearTimeout, ...extra.globals });
|
||||
vm.runInContext(SW_SOURCE, context);
|
||||
async function dispatch(type: string, event: Record<string, unknown>) {
|
||||
const waits: Promise<unknown>[] = [];
|
||||
listeners.get(type)?.({ ...event, waitUntil: (p: Promise<unknown>) => waits.push(p) });
|
||||
await Promise.all(waits);
|
||||
}
|
||||
const push = (payload: unknown) => dispatch('push', { data: { json: () => payload } });
|
||||
return { push, dispatch, shade, previews };
|
||||
}
|
||||
|
||||
const mail = (id: string, subject = 'Hello') => ({ id, threadId: `t-${id}`, from: [{ name: 'Alice' }], subject, preview: '' });
|
||||
const delivery = (accountId: string, ...emailIds: string[]) => ({ kind: 'jmap-email-push', accountLabel: 'me@example.org', accountId, emailIds });
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('a state-change push naming several accounts', () => {
|
||||
const stateChange = (changed: Record<string, Record<string, string>>, accountId = Object.keys(changed)[0]) => ({
|
||||
kind: 'jmap-state-change', accountLabel: 'me@example.org', accountId, emailIds: [], changed,
|
||||
});
|
||||
|
||||
it('notifies for each account with new mail', async () => {
|
||||
const { push, shade, previews } = loadWorker((url) => ({ unreadTotal: 1, slot: 0, email: mail(`m-${url.searchParams.get('accountId')}`) }));
|
||||
await push(stateChange({ me: { EmailDelivery: 's1', Email: 's1' }, team: { EmailDelivery: 's2' } }));
|
||||
expect(previews.map((url) => url.searchParams.get('accountId'))).toEqual(['me', 'team']);
|
||||
expect([...shade.keys()].sort()).toEqual(['bulwark-mail:me', 'bulwark-mail:team']);
|
||||
expect(shade.get('bulwark-mail:team')?.data).toMatchObject({ accountId: 'team', emailId: 'm-team' });
|
||||
});
|
||||
|
||||
it('leaves out an account that only changed some other way', async () => {
|
||||
const { push, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }));
|
||||
await push(stateChange({ quiet: { Mailbox: 's1' }, me: { EmailDelivery: 's2' } }));
|
||||
expect(previews.map((url) => url.searchParams.get('accountId'))).toEqual(['me']);
|
||||
});
|
||||
|
||||
it('still shows something for a ping without new mail in it', async () => {
|
||||
const { push, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }));
|
||||
await push(stateChange({ me: { Mailbox: 's1' } }));
|
||||
expect(previews.map((url) => url.searchParams.get('accountId'))).toEqual(['me']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a notification counts', () => {
|
||||
it('opens the one new message, however much older mail is unread', async () => {
|
||||
const { push, shade } = loadWorker(() => ({ unreadTotal: 40, slot: 0, login: 'fp', email: mail('e1') }));
|
||||
await push(delivery('a', 'e1'));
|
||||
const shown = shade.get('bulwark-mail:a')!;
|
||||
expect(shown.body).toBe('Hello');
|
||||
expect(shown.data).toMatchObject({ kind: 'email', emailId: 'e1', accountId: 'a', slot: 0, login: 'fp' });
|
||||
});
|
||||
|
||||
it('counts the messages it announced, not the unread mail, and opens the inbox for a group', async () => {
|
||||
const { push, shade } = loadWorker((url) => ({ unreadTotal: 40, slot: 0, email: mail(String(url.searchParams.get('emailId')), `About ${url.searchParams.get('emailId')}`) }));
|
||||
await push(delivery('a', 'e1'));
|
||||
await push(delivery('a', 'e2'));
|
||||
let shown = shade.get('bulwark-mail:a')!;
|
||||
expect(shown.body).toBe('About e2\n+1 more message');
|
||||
expect(shown.data).toMatchObject({ kind: 'mail-list', accountId: 'a', slot: 0 });
|
||||
await push(delivery('a', 'e3', 'e4'));
|
||||
shown = shade.get('bulwark-mail:a')!;
|
||||
expect(shown.body).toBe('About e4\n+3 more messages');
|
||||
});
|
||||
|
||||
it('starts over once the notification was dismissed', async () => {
|
||||
const { push, shade } = loadWorker((url) => ({ unreadTotal: 2, slot: 0, email: mail(String(url.searchParams.get('emailId'))) }));
|
||||
await push(delivery('a', 'e1'));
|
||||
shade.get('bulwark-mail:a')!.close();
|
||||
await push(delivery('a', 'e2'));
|
||||
expect(shade.get('bulwark-mail:a')!.data).toMatchObject({ kind: 'email', emailId: 'e2' });
|
||||
});
|
||||
|
||||
it('does not count a redelivered message twice', async () => {
|
||||
const { push, shade } = loadWorker((url) => ({ unreadTotal: 2, slot: 0, email: mail(String(url.searchParams.get('emailId'))) }));
|
||||
await push(delivery('a', 'e1'));
|
||||
await push(delivery('a', 'e1'));
|
||||
expect(shade.get('bulwark-mail:a')!.data).toMatchObject({ kind: 'email', emailId: 'e1' });
|
||||
expect(shade.get('bulwark-mail:a')!.options).toMatchObject({ silent: true });
|
||||
});
|
||||
|
||||
it('counts on the state-change path too', async () => {
|
||||
let id = 'e1';
|
||||
const { push, shade } = loadWorker(() => ({ unreadTotal: 5, slot: 0, email: mail(id) }));
|
||||
await push({ kind: 'jmap-state-change', accountId: 'a', changed: { a: { EmailDelivery: 's' } } });
|
||||
expect(shade.get('bulwark-mail:a')!.data).toMatchObject({ kind: 'email', emailId: 'e1' });
|
||||
id = 'e2';
|
||||
await push({ kind: 'jmap-state-change', accountId: 'a', changed: { a: { EmailDelivery: 's2' } } });
|
||||
expect(shade.get('bulwark-mail:a')!.body).toBe('Hello\n+1 more message');
|
||||
});
|
||||
|
||||
it('says how many new messages without naming any when details are hidden', async () => {
|
||||
const { push, shade } = loadWorker((url) => ({ unreadTotal: 9, slot: 0, redacted: true, email: { id: url.searchParams.get('emailId'), threadId: 't' } }));
|
||||
await push(delivery('a', 'e1'));
|
||||
expect(shade.get('bulwark-mail:a')!.body).toBe('You have new mail');
|
||||
expect(shade.get('bulwark-mail:a')!.data).toMatchObject({ kind: 'email', emailId: 'e1' });
|
||||
await push(delivery('a', 'e2'));
|
||||
expect(shade.get('bulwark-mail:a')!.title).toBe('New mail (me@example.org)');
|
||||
expect(shade.get('bulwark-mail:a')!.body).toBe('2 new messages');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with notifications off or do not disturb on', () => {
|
||||
const quiet = { 'me@example.org': true };
|
||||
|
||||
it('announces nothing for that login: shows and removes at once, without asking for the message', async () => {
|
||||
const { push, shade, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1', 'Secret subject') }), { quiet });
|
||||
await push(delivery('a', 'e1'));
|
||||
expect(shade.size).toBe(0);
|
||||
expect(previews).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still announces mail for another login', async () => {
|
||||
const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { quiet: { 'someone-else@example.org': true } });
|
||||
await push(delivery('a', 'e1'));
|
||||
expect(shade.size).toBe(1);
|
||||
});
|
||||
|
||||
it('does not bring the message up later', async () => {
|
||||
const off = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { quiet });
|
||||
await off.push(delivery('a', 'e1'));
|
||||
await off.push(delivery('a', 'e1'));
|
||||
expect(off.shade.size).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps showing with only the sound off', async () => {
|
||||
const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { prefs: { sound: false } });
|
||||
await push(delivery('a', 'e1'));
|
||||
expect(shade.get('bulwark-mail:a')!.options).toMatchObject({ silent: true, renotify: false });
|
||||
});
|
||||
|
||||
it('shows for a push without a label, or with nothing stored', async () => {
|
||||
const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { quiet });
|
||||
await push({ kind: 'jmap-email-push', accountId: 'a', emailIds: ['e1'] });
|
||||
expect(shade.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('which login a push is for', () => {
|
||||
it('names the login in the preview request', async () => {
|
||||
const { push, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }));
|
||||
await push(delivery('a', 'e1'));
|
||||
expect(previews[0].searchParams.get('login')).toBe('me@example.org');
|
||||
});
|
||||
|
||||
it('leaves it out when the push has no label', async () => {
|
||||
const { push, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }));
|
||||
await push({ kind: 'jmap-email-push', accountId: 'a', emailIds: ['e1'] });
|
||||
expect(previews[0].searchParams.has('login')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when building the notification goes wrong', () => {
|
||||
it('still shows one', async () => {
|
||||
const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, login: 'fp', email: mail('e1') }), {
|
||||
globals: { Notification: { get maxActions() { throw new Error('no such thing'); } } },
|
||||
});
|
||||
await push(delivery('a', 'e1'));
|
||||
expect(shade.size).toBe(1);
|
||||
});
|
||||
|
||||
it('shows one for a push that is no JSON at all', async () => {
|
||||
const { dispatch, shade } = loadWorker(() => null);
|
||||
await dispatch('push', { data: { json: () => { throw new SyntaxError('not JSON'); } } });
|
||||
expect(shade.size).toBe(1);
|
||||
});
|
||||
|
||||
it('shows one for a push without any data', async () => {
|
||||
const { dispatch, shade } = loadWorker(() => null);
|
||||
await dispatch('push', {});
|
||||
expect(shade.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('which window a click uses', () => {
|
||||
const win = (id: string, url: string, extra: Record<string, unknown> = {}) => {
|
||||
const client = {
|
||||
id, url: `https://mail.example.com${url}`, frameType: 'top-level', visibilityState: 'hidden', focused: false,
|
||||
postMessage: vi.fn(),
|
||||
focus: vi.fn(async () => client),
|
||||
navigate: vi.fn(async () => client),
|
||||
...extra,
|
||||
};
|
||||
return client;
|
||||
};
|
||||
const clickOn = (w: ReturnType<typeof loadWorker>) => w.dispatch('notificationclick', {
|
||||
notification: { tag: 'bulwark-mail:a', close() {}, data: { kind: 'email', emailId: 'e1', accountId: 'a', slot: 0 } },
|
||||
});
|
||||
|
||||
it('leaves a pop-out and a frame alone and takes the app window', async () => {
|
||||
const popout = win('p', '/popout/message/x');
|
||||
const frame = win('f', '/mail/folder/inbox', { frameType: 'nested' });
|
||||
const main = win('m', '/mail/folder/inbox');
|
||||
const w = loadWorker(() => null, { clients: [popout, frame, main] });
|
||||
await clickOn(w);
|
||||
expect(popout.navigate).not.toHaveBeenCalled();
|
||||
expect(frame.navigate).not.toHaveBeenCalled();
|
||||
expect(main.navigate).toHaveBeenCalledWith('/mail/message/e1?slot=0&owner=a');
|
||||
});
|
||||
|
||||
it('takes the window in front when there are several', async () => {
|
||||
const back = win('b', '/mail/folder/inbox');
|
||||
const front = win('f', '/mail/folder/inbox', { focused: true, visibilityState: 'visible' });
|
||||
const w = loadWorker(() => null, { clients: [back, front] });
|
||||
await clickOn(w);
|
||||
expect(front.navigate).toHaveBeenCalled();
|
||||
expect(back.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens a new window when only a pop-out is open', async () => {
|
||||
const popout = win('p', '/popout/compose');
|
||||
const openWindow = vi.fn(async () => null);
|
||||
const w = loadWorker(() => null, { clients: [popout], openWindow });
|
||||
await clickOn(w);
|
||||
expect(popout.navigate).not.toHaveBeenCalled();
|
||||
expect(openWindow).toHaveBeenCalledWith('/mail/message/e1?slot=0&owner=a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nothing new to say', () => {
|
||||
it('removes only what it showed itself', async () => {
|
||||
const { push, shade } = loadWorker(() => ({ unreadTotal: 0, email: null }));
|
||||
await push({ kind: 'jmap-state-change', accountId: 'a', changed: { a: { EmailDelivery: 's' } } });
|
||||
expect(shade.size).toBe(0);
|
||||
});
|
||||
|
||||
it('still shows something when the browser cannot list its notifications', async () => {
|
||||
const shown: string[] = [];
|
||||
const w = loadWorker(() => ({ unreadTotal: 0, email: null }), {
|
||||
registration: {
|
||||
showNotification: async (title: string) => { shown.push(title); },
|
||||
getNotifications: async () => { throw new Error('nope'); },
|
||||
},
|
||||
});
|
||||
await w.push({ kind: 'jmap-state-change', accountId: 'a', changed: { a: { EmailDelivery: 's' } } });
|
||||
expect(shown).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('uses its own tag, so it can never replace a real notification', async () => {
|
||||
const tags: string[] = [];
|
||||
const w = loadWorker(() => ({ unreadTotal: 0, email: null }), {
|
||||
registration: {
|
||||
showNotification: async (_title: string, options: { tag: string }) => { tags.push(options.tag); },
|
||||
getNotifications: async () => [],
|
||||
},
|
||||
});
|
||||
await w.push({ kind: 'jmap-state-change', accountId: 'a', changed: { a: { EmailDelivery: 's' } } });
|
||||
expect(tags).toHaveLength(1);
|
||||
expect(tags[0]).not.toBe('bulwark-mail:a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a push naming several accounts, one server slow', () => {
|
||||
it('does not make the others wait out its deadline', async () => {
|
||||
const started: string[] = [];
|
||||
let release = () => {};
|
||||
const slow = new Promise<void>((resolve) => { release = resolve; });
|
||||
const fetchSlow = vi.fn(async (input: string) => {
|
||||
const url = new URL(input, 'https://mail.example.com');
|
||||
const account = url.searchParams.get('accountId') ?? '';
|
||||
started.push(account);
|
||||
if (account === 'slow') await slow;
|
||||
return { ok: true, json: async () => ({ unreadTotal: 1, slot: 0, email: mail(`m-${account}`) }) };
|
||||
});
|
||||
const w = loadWorker(() => null, { globals: { fetch: fetchSlow } });
|
||||
const done = w.push({ kind: 'jmap-state-change', accountId: 'slow', changed: { slow: { EmailDelivery: 's' }, fast: { EmailDelivery: 's' } } });
|
||||
await vi.waitFor(() => expect(started).toEqual(['slow', 'fast']));
|
||||
await vi.waitFor(() => expect(w.shade.has('bulwark-mail:fast')).toBe(true));
|
||||
release();
|
||||
await done;
|
||||
expect(w.shade.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// -- end of cases --
|
||||
@@ -15,16 +15,16 @@ function parseTarget(target: string) {
|
||||
|
||||
describe('push click keeps the owning mailbox (#1491)', () => {
|
||||
for (const owner of ['personal', 'team']) {
|
||||
for (const unreadTotal of [1, 3]) {
|
||||
for (const onScreen of [0, 2]) {
|
||||
for (const warm of [false, true]) {
|
||||
it(`${owner}, ${unreadTotal} unread, ${warm ? 'existing' : 'new'} window`, async () => {
|
||||
it(`${owner}, ${onScreen} announced already, ${warm ? 'existing' : 'new'} window`, async () => {
|
||||
const target = await pushClick(owner, async (url) => {
|
||||
expect(new URL(url, 'https://mail.example.test').searchParams.get('accountId')).toBe(owner);
|
||||
return { slot: 0, unreadTotal, email: { id: 'same-id', threadId: 't', subject: 'Mail' } };
|
||||
}, { warm });
|
||||
return { slot: 0, unreadTotal: 3, email: { id: 'same-id', threadId: 't', subject: 'Mail' } };
|
||||
}, { warm, onScreen: Array.from({ length: onScreen }, (_, i) => `earlier-${i}`) });
|
||||
const link = parseTarget(target);
|
||||
expect(link?.slot).toBe(0);
|
||||
if (unreadTotal === 1) {
|
||||
if (onScreen === 0) {
|
||||
expect(link).toMatchObject({ kind: 'message', id: 'same-id', ownerAccountId: owner });
|
||||
} else {
|
||||
expect(link).toMatchObject({ kind: 'folder', ref: 'inbox', ownerAccountId: owner });
|
||||
@@ -42,7 +42,7 @@ describe('push click keeps the owning mailbox (#1491)', () => {
|
||||
});
|
||||
|
||||
it('keeps the login slot and owner with a mount prefix and legacy payload', async () => {
|
||||
const target = await pushClick('team', async () => ({ slot: 2, unreadTotal: 3, email: { id: 'same-id' } }), { basePath: '/webmail', legacy: true });
|
||||
const target = await pushClick('team', async () => ({ slot: 2, unreadTotal: 3, email: { id: 'same-id' } }), { basePath: '/webmail', legacy: true, onScreen: ['earlier'] });
|
||||
expect(target).toMatch(/^\/webmail\/mail\/folder\/inbox\?/);
|
||||
expect(parseTarget(target)).toMatchObject({ kind: 'folder', ownerAccountId: 'team', slot: 2 });
|
||||
});
|
||||
|
||||
@@ -45,10 +45,10 @@ describe('service worker with a redacted preview', () => {
|
||||
expect(calls[0].options.data).toMatchObject({ kind: 'email', emailId: 'e1' });
|
||||
});
|
||||
|
||||
it('counts several unread messages without naming any', async () => {
|
||||
it('opens the one new message however much else is unread', async () => {
|
||||
const { push, calls } = loadWorker({ unreadTotal: 3, redacted: true, email: { id: 'e1', threadId: 't1' } });
|
||||
await push(delivery);
|
||||
expect(calls[0].title).toBe('New mail');
|
||||
expect(calls[0].options.body).toBe('3 unread messages');
|
||||
expect(calls[0].options.data).toMatchObject({ kind: 'email', emailId: 'e1' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,9 +7,11 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
// in a VM with a minimal ServiceWorkerGlobalScope and fire the event.
|
||||
const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8');
|
||||
|
||||
function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) {
|
||||
function loadWorker(tabs: { postMessage: (msg: unknown) => void }[], registrations?: unknown, config: { noThirdPartyTraffic?: boolean } | null = {}) {
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
const subscribe = vi.fn(async () => ({}));
|
||||
const newSubscription = { toJSON: () => ({ endpoint: 'https://push.example/new', keys: { p256dh: 'p', auth: 'a' } }) };
|
||||
const subscribe = vi.fn(async (_options: { userVisibleOnly?: boolean; applicationServerKey?: Uint8Array }) => newSubscription);
|
||||
const relayCalls: Array<{ url: string; body: unknown }> = [];
|
||||
const self = {
|
||||
location: { href: 'https://mail.example.com/sw.js', origin: 'https://mail.example.com' },
|
||||
addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn),
|
||||
@@ -18,7 +20,18 @@ function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) {
|
||||
registration: { pushManager: { subscribe } },
|
||||
};
|
||||
const context = vm.createContext({
|
||||
self, caches: {}, fetch: vi.fn(), URL, URLSearchParams, Response, Date, console,
|
||||
self,
|
||||
atob,
|
||||
caches: { open: async () => ({ match: async () => (registrations ? { json: async () => registrations } : undefined) }) },
|
||||
fetch: vi.fn(async (url: string, init?: { body: string }) => {
|
||||
if (url.endsWith('/api/config')) {
|
||||
if (!config) throw new TypeError('offline');
|
||||
return { ok: true, json: async () => config };
|
||||
}
|
||||
relayCalls.push({ url, body: JSON.parse(init!.body) });
|
||||
return { ok: true };
|
||||
}),
|
||||
URL, URLSearchParams, Response, Date, console, Uint8Array,
|
||||
});
|
||||
vm.runInContext(SW_SOURCE, context);
|
||||
|
||||
@@ -27,13 +40,57 @@ function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) {
|
||||
listeners.get(type)?.({ ...event, waitUntil: (p: Promise<unknown>) => waits.push(p) });
|
||||
await Promise.all(waits);
|
||||
}
|
||||
return { fire, subscribe };
|
||||
return { fire, subscribe, relayCalls, newSubscription };
|
||||
}
|
||||
|
||||
// The push service dropped or replaced the subscription while no page was
|
||||
// involved (#1321). The worker puts a subscription back and tells the open
|
||||
// tabs, which hold the login needed to register the new endpoint.
|
||||
describe('service worker pushsubscriptionchange', () => {
|
||||
const REGISTRATIONS = {
|
||||
applicationServerKey: 'QUJD',
|
||||
entries: {
|
||||
a: { relay: 'https://relay.example', subscriptionId: 'device-a', accountLabel: 'a@example.org' },
|
||||
b: { relay: 'https://relay.example', subscriptionId: 'device-b', accountLabel: 'b@example.org' },
|
||||
},
|
||||
};
|
||||
|
||||
it('tells the relay the new endpoint itself, for every account, with no tab open', async () => {
|
||||
const { fire, relayCalls, newSubscription } = loadWorker([], REGISTRATIONS);
|
||||
await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription });
|
||||
expect(relayCalls).toEqual([
|
||||
{ url: 'https://relay.example/api/push/register/web', body: { subscriptionId: 'device-a', subscription: newSubscription.toJSON(), accountLabel: 'a@example.org' } },
|
||||
{ url: 'https://relay.example/api/push/register/web', body: { subscriptionId: 'device-b', subscription: newSubscription.toJSON(), accountLabel: 'b@example.org' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('subscribes with the kept key when the browser gives no old subscription', async () => {
|
||||
const { fire, subscribe, relayCalls } = loadWorker([], REGISTRATIONS);
|
||||
await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription: null });
|
||||
expect(subscribe).toHaveBeenCalledTimes(1);
|
||||
expect(subscribe.mock.calls[0][0]).toMatchObject({ userVisibleOnly: true });
|
||||
expect(Array.from(subscribe.mock.calls[0][0].applicationServerKey!)).toEqual([65, 66, 67]);
|
||||
expect(relayCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('calls no relay while third-party traffic is off: the page moves it', async () => {
|
||||
const { fire, relayCalls, newSubscription } = loadWorker([], REGISTRATIONS, { noThirdPartyTraffic: true });
|
||||
await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription });
|
||||
expect(relayCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('calls no relay when it cannot find out', async () => {
|
||||
const { fire, relayCalls, newSubscription } = loadWorker([], REGISTRATIONS, null);
|
||||
await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription });
|
||||
expect(relayCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('calls no relay for a browser where push was never on', async () => {
|
||||
const { fire, relayCalls, newSubscription } = loadWorker([]);
|
||||
await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription });
|
||||
expect(relayCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('re-subscribes with the old key and tells the open tabs to re-register', async () => {
|
||||
const tab = { postMessage: vi.fn() };
|
||||
const { fire, subscribe } = loadWorker([tab]);
|
||||
|
||||
@@ -6,7 +6,9 @@ import { RateLimitError, RequestTimeoutError } from '@/lib/jmap/client';
|
||||
import {
|
||||
buildEmailPushConfig,
|
||||
disableWebPush,
|
||||
dropAllWebPushRegistrations,
|
||||
enableWebPush,
|
||||
endWebPushForSignOut,
|
||||
isWebPushEnabled,
|
||||
listPushDevices,
|
||||
mailAccountsWithoutPush,
|
||||
@@ -16,8 +18,10 @@ import {
|
||||
revokePushDevice,
|
||||
waitForWebPushResync,
|
||||
} from '@/lib/web-push';
|
||||
import { webPushErrorKey } from '@/lib/web-push-errors';
|
||||
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
|
||||
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
|
||||
import { keepRelayRegistration } from '@/lib/pwa-notify/worker-state';
|
||||
|
||||
const RELAY = 'https://relay.example';
|
||||
const RELAY_BASE_KEY = `bulwark.push.relayBase.v1.${'acct-1'}`;
|
||||
@@ -275,7 +279,7 @@ function installFetch(activeByDevice: Record<string, boolean | 'unknown'>) {
|
||||
if (url.includes('/api/push/active/')) {
|
||||
const device = url.split('/api/push/active/')[1];
|
||||
const state = activeByDevice[device];
|
||||
if (state === undefined || state === 'unknown') return json({ error: 'Unknown' }, 404);
|
||||
if (state === undefined || state === 'unknown') return json({ error: 'Unknown subscription' }, 404);
|
||||
return json({ active: state });
|
||||
}
|
||||
return json({});
|
||||
@@ -472,6 +476,532 @@ describe('enableWebPush', () => {
|
||||
// Registrations made before the client learned about the delivery filter are
|
||||
// repaired in the background on app start - nobody should have to find the
|
||||
// settings toggle to stop the spam pushes.
|
||||
describe('enableWebPush when the verification does not come', () => {
|
||||
// The relay's poll stays empty until `arrivesAfterTouch` calls come in, like a
|
||||
// Stalwart that skipped the verification until the subscription is touched.
|
||||
function installSilentRelay(codeOnceTouched: () => boolean) {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response;
|
||||
if (url.endsWith('/api/push/vapid-public-key')) return json({ publicKey: 'QUJD' });
|
||||
if (url.includes('/api/push/verify/')) return json({ verificationCode: codeOnceTouched() ? 'late-code' : null });
|
||||
return json({});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
}
|
||||
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('keeps polling through a network blip', async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = makeClient([]);
|
||||
let polls = 0;
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response;
|
||||
if (url.endsWith('/api/push/vapid-public-key')) return json({ publicKey: 'QUJD' });
|
||||
if (url.includes('/api/push/verify/')) {
|
||||
polls += 1;
|
||||
if (polls === 1) throw new TypeError('Failed to fetch');
|
||||
return json({ verificationCode: 'code' });
|
||||
}
|
||||
return json({});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const settled = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).then(() => 'done', (error: Error) => error.message);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
expect(await settled).toBe('done');
|
||||
expect(client.verifyPushSubscription).toHaveBeenCalledWith('push-new', 'code');
|
||||
});
|
||||
|
||||
it('removes its unverified subscription and reads as off when the code never comes', async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = makeClient([]);
|
||||
installSilentRelay(() => false);
|
||||
|
||||
const settled = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).then(() => 'done', (error: Error) => error.message);
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
|
||||
expect(await settled).toMatch(/verif/i);
|
||||
expect(client.destroyed).toEqual(['push-new']);
|
||||
expect(localStorage.getItem(SUB_KEY)).toBeNull();
|
||||
expect(client.verifyPushSubscription).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes its subscription when the server refuses the code', async () => {
|
||||
const client = makeClient([]);
|
||||
installFetch({});
|
||||
(client.verifyPushSubscription as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Verification code does not match.'));
|
||||
|
||||
await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toThrow('does not match');
|
||||
|
||||
expect(client.destroyed).toEqual(['push-new']);
|
||||
expect(localStorage.getItem(SUB_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the old subscription working when its replacement fails verification', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-old');
|
||||
const client = makeClient([sub('push-old', THIS_DEVICE)]);
|
||||
installFetch({});
|
||||
(client.verifyPushSubscription as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('nope'));
|
||||
|
||||
await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false, forceRecreate: true })).rejects.toThrow('nope');
|
||||
|
||||
expect(client.destroyed).toEqual(['push-new']);
|
||||
expect(localStorage.getItem(SUB_KEY)).toBe('push-old');
|
||||
});
|
||||
|
||||
it('touches the subscription after the server\'s minute, which makes it send the code', async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = makeClient([]);
|
||||
installSilentRelay(() => (client.updatePushSubscription as ReturnType<typeof vi.fn>).mock.calls.length > 0);
|
||||
|
||||
const run = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
|
||||
const settled = run.then(() => 'done', (error: Error) => error.message);
|
||||
await vi.advanceTimersByTimeAsync(58_000);
|
||||
expect(client.updatePushSubscription).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
expect(client.updatePushSubscription).toHaveBeenCalledTimes(1);
|
||||
expect((client.updatePushSubscription as ReturnType<typeof vi.fn>).mock.calls[0][0]).toBe('push-new');
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(await settled).toBe('done');
|
||||
expect(client.verifyPushSubscription).toHaveBeenCalledWith('push-new', 'late-code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('two tabs at once', () => {
|
||||
// What the browser's Web Locks do: one holder at a time, in order.
|
||||
function installLocks() {
|
||||
let tail: Promise<unknown> = Promise.resolve();
|
||||
const request = vi.fn(async (_name: string, run: () => Promise<unknown>) => {
|
||||
const next = tail.then(run, run);
|
||||
tail = next.catch(() => undefined);
|
||||
return next;
|
||||
});
|
||||
Object.defineProperty(navigator, 'locks', { configurable: true, value: { request } });
|
||||
return request;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete (navigator as unknown as { locks?: unknown }).locks;
|
||||
});
|
||||
|
||||
// A server that knows what was created and destroyed, as the race needs.
|
||||
function statefulClient() {
|
||||
const subs: JmapPushSubscription[] = [];
|
||||
const client = makeClient(subs);
|
||||
let next = 0;
|
||||
(client.createPushSubscription as ReturnType<typeof vi.fn>).mockImplementation(async (params: { deviceClientId: string }) => {
|
||||
const id = `push-${++next}`;
|
||||
subs.push(sub(id, params.deviceClientId));
|
||||
return id;
|
||||
});
|
||||
(client.verifyPushSubscription as ReturnType<typeof vi.fn>).mockImplementation(async (id: string) => {
|
||||
if (client.destroyed.includes(id)) throw new Error('notFound');
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
it('makes one subscription, and neither tab fails', async () => {
|
||||
installLocks();
|
||||
const client = statefulClient();
|
||||
installFetch({});
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }),
|
||||
enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }),
|
||||
]);
|
||||
|
||||
expect(results.map((r) => r.status)).toEqual(['fulfilled', 'fulfilled']);
|
||||
expect(client.createPushSubscription).toHaveBeenCalledTimes(1);
|
||||
expect(client.destroyed).toEqual([]);
|
||||
expect(await client.listPushSubscriptions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('waits for a running enable before turning off', async () => {
|
||||
const request = installLocks();
|
||||
const client = statefulClient();
|
||||
installFetch({});
|
||||
|
||||
const enabling = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1));
|
||||
await Promise.all([enabling, disableWebPush({ client, relayBaseUrl: RELAY })]);
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(await client.listPushSubscriptions()).toEqual([]);
|
||||
expect(localStorage.getItem(SUB_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('still works in a browser without Web Locks', async () => {
|
||||
const client = statefulClient();
|
||||
installFetch({});
|
||||
await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
|
||||
expect(client.createPushSubscription).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what enabling and disabling say when they fail', () => {
|
||||
it('tells a blocked browser from a question not answered', async () => {
|
||||
const client = makeClient([]);
|
||||
installFetch({});
|
||||
(globalThis as unknown as { Notification: unknown }).Notification = { permission: 'denied' };
|
||||
(window as unknown as { Notification: unknown }).Notification = { permission: 'denied' };
|
||||
await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_blocked');
|
||||
|
||||
const unanswered = { permission: 'default', requestPermission: async () => 'default' };
|
||||
(globalThis as unknown as { Notification: unknown }).Notification = unanswered;
|
||||
(window as unknown as { Notification: unknown }).Notification = unanswered;
|
||||
await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_not_granted');
|
||||
expect(client.createPushSubscription).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('names an unreachable relay, and a relay that refuses', async () => {
|
||||
const client = makeClient([]);
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch'); }));
|
||||
await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_relay');
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const json = (body: unknown, status = 200) => ({ ok: status < 300, status, json: async () => body }) as Response;
|
||||
return String(input).endsWith('/vapid-public-key') ? json({ publicKey: 'QUJD' }) : json({}, 500);
|
||||
}));
|
||||
await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_relay');
|
||||
expect(client.createPushSubscription).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('names a verification that never came', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = makeClient([]);
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response;
|
||||
if (url.endsWith('/vapid-public-key')) return json({ publicKey: 'QUJD' });
|
||||
return json({ verificationCode: null });
|
||||
}));
|
||||
const settled = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).catch((e: unknown) => webPushErrorKey(e));
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect(await settled).toBe('error_verification_timeout');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps naming the server refusals, and nothing for an unknown error', () => {
|
||||
expect(webPushErrorKey(refusedMap())).toBe('error_forbidden');
|
||||
expect(webPushErrorKey(jmapSetError('x', OVER_QUOTA))).toBe('error_over_quota');
|
||||
expect(webPushErrorKey(new Error('boom'))).toBeNull();
|
||||
});
|
||||
|
||||
it('says so when turning off could not reach the server, and still ends up off here', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-mine');
|
||||
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
|
||||
installFetch({});
|
||||
(client.destroyPushSubscription as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('offline'));
|
||||
|
||||
await expect(disableWebPush({ client, relayBaseUrl: RELAY })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_teardown');
|
||||
|
||||
expect(localStorage.getItem(SUB_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('says so when turning off could not reach the relay', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-mine');
|
||||
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch'); }));
|
||||
|
||||
await expect(disableWebPush({ client, relayBaseUrl: RELAY })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_teardown');
|
||||
|
||||
expect(client.destroyed).toEqual(['push-mine']);
|
||||
expect(localStorage.getItem(SUB_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not count a record the relay no longer has as a failure', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-mine');
|
||||
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}) })));
|
||||
|
||||
await expect(disableWebPush({ client, relayBaseUrl: RELAY })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the browser subscription for the other account, and says nothing when all went well', async () => {
|
||||
const { browserSub } = installPushBrowser();
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-mine');
|
||||
localStorage.setItem('bulwark.push.subscriptionId.v1.other-account', 'push-other');
|
||||
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
|
||||
installFetch({});
|
||||
|
||||
await expect(disableWebPush({ client, relayBaseUrl: RELAY })).resolves.toBeUndefined();
|
||||
|
||||
expect(browserSub.unsubscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('an endpoint the push service gave up on', () => {
|
||||
const ENDPOINT_KEY = `bulwark.push.endpoint.v1.${ACCOUNT_ID}`;
|
||||
|
||||
function registered() {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-mine');
|
||||
localStorage.setItem(RELAY_BASE_KEY, RELAY);
|
||||
localStorage.setItem(ENDPOINT_KEY, 'https://fcm.example/endpoint');
|
||||
return makeClient([sub('push-mine', THIS_DEVICE)]);
|
||||
}
|
||||
|
||||
it('gets a new subscription when the relay dropped the record for the very endpoint this browser still holds', async () => {
|
||||
const { browserSub, registration } = installPushBrowser();
|
||||
const client = registered();
|
||||
installFetch({}); // The relay answers 404 for this device.
|
||||
|
||||
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
|
||||
|
||||
expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not take a relay that lacks the route for one that lost the record', async () => {
|
||||
const { browserSub } = installPushBrowser();
|
||||
const client = registered();
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const json = (body: unknown, status = 200) => ({ ok: status < 300, status, json: async () => body }) as Response;
|
||||
if (url.includes('/api/push/active/')) return json({ error: 'Not found' }, 404);
|
||||
return url.endsWith('/vapid-public-key') ? json({ publicKey: 'QUJD' }) : json({});
|
||||
}));
|
||||
|
||||
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
|
||||
|
||||
expect(browserSub.unsubscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moves the other logins to the new endpoint, connected or not', async () => {
|
||||
const { registration } = installPushBrowser();
|
||||
const moved = { endpoint: 'https://fcm.example/moved', options: { applicationServerKey: new Uint8Array([65, 66, 67]).buffer }, getKey: () => new Uint8Array([1, 2, 3]).buffer, unsubscribe: vi.fn(async () => true) };
|
||||
(registration.pushManager.subscribe as ReturnType<typeof vi.fn>).mockResolvedValue(moved);
|
||||
const stored = new Map<string, string>();
|
||||
vi.stubGlobal('caches', {
|
||||
open: async () => ({
|
||||
match: async (key: string) => (stored.has(key) ? new Response(stored.get(key)) : undefined),
|
||||
put: async (key: string, res: Response) => { stored.set(key, await res.text()); },
|
||||
}),
|
||||
});
|
||||
await keepRelayRegistration('other-login', { relay: 'https://relay-of-other.example', subscriptionId: 'device-other', accountLabel: 'o@example.org' }, 'QUJD');
|
||||
const client = registered();
|
||||
const bodies: Array<{ url: string; body: { subscriptionId: string; subscription: { endpoint: string }; accountLabel?: string } }> = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const json = (body: unknown, status = 200) => ({ ok: status < 300, status, json: async () => body }) as Response;
|
||||
if (url.endsWith('/vapid-public-key')) return json({ publicKey: 'QUJD' });
|
||||
if (url.includes('/api/push/active/')) return json({ error: 'Unknown subscription' }, 404);
|
||||
if (url.endsWith('/api/push/register/web')) bodies.push({ url, body: JSON.parse(String(init?.body)) });
|
||||
return json({});
|
||||
}));
|
||||
|
||||
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
|
||||
|
||||
const other = bodies.find((b) => b.body.subscriptionId === 'device-other');
|
||||
expect(other?.url).toBe('https://relay-of-other.example/api/push/register/web');
|
||||
expect(other?.body.subscription.endpoint).toBe('https://fcm.example/moved');
|
||||
expect(other?.body.accountLabel).toBe('o@example.org');
|
||||
expect(bodies.filter((b) => b.body.subscriptionId === THIS_DEVICE)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps the subscription while the relay knows the record', async () => {
|
||||
const { browserSub } = installPushBrowser();
|
||||
const client = registered();
|
||||
installFetch({ [THIS_DEVICE]: true });
|
||||
|
||||
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
|
||||
|
||||
expect(browserSub.unsubscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps it when the relay cannot be asked', async () => {
|
||||
const { browserSub } = installPushBrowser();
|
||||
const client = registered();
|
||||
const calls: string[] = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
calls.push(url);
|
||||
if (url.includes('/api/push/active/')) throw new TypeError('blip');
|
||||
const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response;
|
||||
return url.endsWith('/vapid-public-key') ? json({ publicKey: 'QUJD' }) : json({});
|
||||
}));
|
||||
|
||||
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
|
||||
|
||||
expect(calls.some((url) => url.includes('/api/push/active/'))).toBe(true);
|
||||
expect(browserSub.unsubscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('only registers when another account already moved the browser to a new endpoint', async () => {
|
||||
const { browserSub } = installPushBrowser();
|
||||
const client = registered();
|
||||
localStorage.setItem(ENDPOINT_KEY, 'https://fcm.example/older');
|
||||
installFetch({});
|
||||
|
||||
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
|
||||
|
||||
expect(browserSub.unsubscribe).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem(ENDPOINT_KEY)).toBe('https://fcm.example/endpoint');
|
||||
});
|
||||
|
||||
it('does not mistake a move to another relay for a dead endpoint', async () => {
|
||||
const { browserSub } = installPushBrowser();
|
||||
const client = registered();
|
||||
localStorage.setItem(RELAY_BASE_KEY, 'https://old-relay.example');
|
||||
installFetch({});
|
||||
|
||||
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
|
||||
|
||||
expect(browserSub.unsubscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a subscription id the server gave to another device', () => {
|
||||
// Stalwart hands out the lowest free id: this browser's subscription expired,
|
||||
// and the phone that registered next got the same id.
|
||||
it('creates its own instead of taking over the other device\'s', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'b');
|
||||
const client = makeClient([sub('b', OTHER_DEVICE)]);
|
||||
installFetch({ [OTHER_DEVICE]: true });
|
||||
|
||||
await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
|
||||
|
||||
expect(client.updatePushSubscription).not.toHaveBeenCalled();
|
||||
expect(client.createPushSubscription).toHaveBeenCalledTimes(1);
|
||||
expect(client.destroyed).toEqual([]);
|
||||
expect(localStorage.getItem(SUB_KEY)).toBe('push-new');
|
||||
});
|
||||
|
||||
it('leaves it alone when turning off', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'b');
|
||||
const client = makeClient([sub('b', OTHER_DEVICE)]);
|
||||
installFetch({});
|
||||
|
||||
await disableWebPush({ client, relayBaseUrl: RELAY });
|
||||
|
||||
expect(client.destroyed).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves it alone on Re-register', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'b');
|
||||
const client = makeClient([sub('b', OTHER_DEVICE)]);
|
||||
installFetch({ [OTHER_DEVICE]: true });
|
||||
|
||||
await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false, forceRecreate: true });
|
||||
|
||||
expect(client.destroyed).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a setup that gets stuck', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
delete (navigator as unknown as { locks?: unknown }).locks;
|
||||
});
|
||||
|
||||
it('fails in the end and lets the next one through', async () => {
|
||||
vi.useFakeTimers();
|
||||
let tail: Promise<unknown> = Promise.resolve();
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
configurable: true,
|
||||
value: { request: (_n: string, run: () => Promise<unknown>) => { const next = tail.then(run, run); tail = next.catch(() => undefined); return next; } },
|
||||
});
|
||||
const { registration } = installPushBrowser();
|
||||
(registration.pushManager.getSubscription as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
||||
(registration.pushManager.subscribe as ReturnType<typeof vi.fn>).mockReturnValueOnce(new Promise(() => {}));
|
||||
const client = makeClient([]);
|
||||
installFetch({});
|
||||
|
||||
const stuck = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).then(() => 'done', () => 'failed');
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
const second = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).then(() => 'done', () => 'failed');
|
||||
await vi.advanceTimersByTimeAsync(200_000);
|
||||
|
||||
expect(await stuck).toBe('failed');
|
||||
expect(await second).toBe('done');
|
||||
});
|
||||
|
||||
it('turns off for the account it was asked about, even if the client disconnected while waiting', async () => {
|
||||
let tail: Promise<unknown> = Promise.resolve();
|
||||
let release = () => {};
|
||||
tail = new Promise<void>((resolve) => { release = resolve; });
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
configurable: true,
|
||||
value: { request: (_n: string, run: () => Promise<unknown>) => { const next = tail.then(run, run); tail = next.catch(() => undefined); return next; } },
|
||||
});
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-mine');
|
||||
localStorage.setItem(RELAY_BASE_KEY, RELAY);
|
||||
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
|
||||
let accountId = ACCOUNT_ID;
|
||||
(client as unknown as { getAccountId: () => string }).getAccountId = () => accountId;
|
||||
const calls = installFetch({});
|
||||
|
||||
const off = disableWebPush({ client, relayBaseUrl: RELAY }).catch(() => undefined);
|
||||
accountId = '';
|
||||
release();
|
||||
await off;
|
||||
|
||||
expect(localStorage.getItem(SUB_KEY)).toBeNull();
|
||||
expect(calls).toContainEqual({ url: `${RELAY}/api/push/register/${THIS_DEVICE}`, method: 'DELETE' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a re-sync waiting its turn', () => {
|
||||
afterEach(() => { delete (navigator as unknown as { locks?: unknown }).locks; });
|
||||
|
||||
it('does not bring back what another tab turned off meanwhile', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-mine');
|
||||
localStorage.setItem(RELAY_BASE_KEY, RELAY);
|
||||
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
|
||||
installFetch({ [THIS_DEVICE]: true });
|
||||
let turn: Promise<unknown> = Promise.resolve();
|
||||
let release = () => {};
|
||||
turn = new Promise<void>((resolve) => { release = resolve; });
|
||||
Object.defineProperty(navigator, 'locks', {
|
||||
configurable: true,
|
||||
value: { request: (_n: string, run: () => Promise<unknown>) => { const next = turn.then(run, run); turn = next.catch(() => undefined); return next; } },
|
||||
});
|
||||
|
||||
const resync = resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
|
||||
await vi.waitFor(() => expect((navigator as unknown as { locks: unknown }).locks).toBeTruthy());
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
localStorage.removeItem(SUB_KEY); // The other tab, holding the lock.
|
||||
release();
|
||||
await resync;
|
||||
|
||||
expect(client.updatePushSubscription).not.toHaveBeenCalled();
|
||||
expect(client.createPushSubscription).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem(SUB_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a registration from before the relay was remembered', () => {
|
||||
it('gets the relay noted when it is refreshed', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-mine');
|
||||
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
|
||||
installFetch({ [THIS_DEVICE]: true });
|
||||
|
||||
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
|
||||
|
||||
expect(localStorage.getItem(RELAY_BASE_KEY)).toBe(RELAY);
|
||||
expect(client.createPushSubscription).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resyncWebPush when the relay changed (#903)', () => {
|
||||
afterEach(() => setNoThirdPartyTraffic(false));
|
||||
|
||||
@@ -504,6 +1034,48 @@ describe('resyncWebPush when the relay changed (#903)', () => {
|
||||
expect(client.destroyed).toContain('push-old');
|
||||
});
|
||||
|
||||
it('leaves no record on the relay it moved away from', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-old');
|
||||
localStorage.setItem(RELAY_BASE_KEY, 'https://old-relay.example');
|
||||
const client = makeClient([sub('push-old', THIS_DEVICE)]);
|
||||
const calls = installFetch({});
|
||||
|
||||
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true);
|
||||
|
||||
expect(calls).toContainEqual({ url: `https://old-relay.example/api/push/register/${THIS_DEVICE}`, method: 'DELETE' });
|
||||
expect(calls).not.toContainEqual({ url: `${RELAY}/api/push/register/${THIS_DEVICE}`, method: 'DELETE' });
|
||||
expect(localStorage.getItem(RELAY_BASE_KEY)).toBe(RELAY);
|
||||
expect(client.destroyed).toEqual(['push-old']);
|
||||
});
|
||||
|
||||
it('keeps the old relay until the new subscription is verified', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-old');
|
||||
localStorage.setItem(RELAY_BASE_KEY, 'https://old-relay.example');
|
||||
const client = makeClient([sub('push-old', THIS_DEVICE)]);
|
||||
const calls = installFetch({});
|
||||
(client.verifyPushSubscription as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('nope'));
|
||||
|
||||
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
|
||||
|
||||
expect(calls.some((c) => c.method === 'DELETE')).toBe(false);
|
||||
expect(localStorage.getItem(RELAY_BASE_KEY)).toBe('https://old-relay.example');
|
||||
});
|
||||
|
||||
it('does not contact the built-in relay to clean up when third-party traffic is off', async () => {
|
||||
setNoThirdPartyTraffic(true);
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-old');
|
||||
localStorage.setItem(RELAY_BASE_KEY, DEFAULT_RELAY_BASE_URL);
|
||||
const client = makeClient([sub('push-old', THIS_DEVICE)]);
|
||||
const calls = installFetch({});
|
||||
|
||||
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
|
||||
|
||||
expect(calls.filter((c) => c.url.startsWith(DEFAULT_RELAY_BASE_URL))).toEqual([]);
|
||||
});
|
||||
|
||||
it('re-registers inside the daily limit when the admin switched relays, and only once', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-old');
|
||||
@@ -1388,6 +1960,18 @@ describe('disableWebPush', () => {
|
||||
expect(localStorage.getItem(REFUSED_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('drops the record on the relay it was registered with, not only the current one', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-tracked');
|
||||
localStorage.setItem(RELAY_BASE_KEY, 'https://old-relay.example');
|
||||
const client = makeClient([sub('push-tracked', THIS_DEVICE)]);
|
||||
const calls = installFetch({});
|
||||
|
||||
await disableWebPush({ client, relayBaseUrl: RELAY });
|
||||
|
||||
expect(calls).toContainEqual({ url: `https://old-relay.example/api/push/register/${THIS_DEVICE}`, method: 'DELETE' });
|
||||
});
|
||||
|
||||
it('drops the relay registration for this device', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
const client = makeClient([]);
|
||||
@@ -1402,6 +1986,84 @@ describe('disableWebPush', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('endWebPushForSignOut', () => {
|
||||
it('takes everything down for a login with push on, using the relay it registered with', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-mine');
|
||||
localStorage.setItem(RELAY_BASE_KEY, RELAY);
|
||||
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
|
||||
const calls = installFetch({});
|
||||
|
||||
await endWebPushForSignOut(client);
|
||||
|
||||
expect(client.destroyed).toEqual(['push-mine']);
|
||||
expect(calls).toEqual([{ url: `${RELAY}/api/push/register/${THIS_DEVICE}`, method: 'DELETE' }]);
|
||||
expect(localStorage.getItem(SUB_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('does nothing and calls nobody for a login without push', async () => {
|
||||
const client = makeClient([]);
|
||||
const calls = installFetch({});
|
||||
|
||||
await endWebPushForSignOut(client);
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
expect(client.listPushSubscriptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never throws: signing out goes on', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-mine');
|
||||
localStorage.setItem(RELAY_BASE_KEY, RELAY);
|
||||
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('offline'); }));
|
||||
(client.destroyPushSubscription as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('offline'));
|
||||
|
||||
await expect(endWebPushForSignOut(client)).resolves.toBeUndefined();
|
||||
expect(localStorage.getItem(SUB_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('forgets the key material with nobody left', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-mine');
|
||||
localStorage.setItem(RELAY_BASE_KEY, RELAY);
|
||||
const { browserSub } = installPushBrowser();
|
||||
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
|
||||
installFetch({});
|
||||
|
||||
await endWebPushForSignOut(client);
|
||||
|
||||
expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropAllWebPushRegistrations', () => {
|
||||
it('takes down every login this browser registered, connected or not, and calls no relay it did not use', async () => {
|
||||
const { browserSub } = installPushBrowser();
|
||||
localStorage.setItem('bulwark.push.subscriptionId.v1.a', 'x');
|
||||
localStorage.setItem('bulwark.push.deviceClientId.v1.a', 'device-a');
|
||||
localStorage.setItem('bulwark.push.relayBase.v1.a', RELAY);
|
||||
localStorage.setItem('bulwark.push.subscriptionId.v1.b', 'y');
|
||||
localStorage.setItem('bulwark.push.deviceClientId.v1.b', 'device-b');
|
||||
const calls = installFetch({});
|
||||
|
||||
await dropAllWebPushRegistrations();
|
||||
|
||||
expect(calls).toEqual([{ url: `${RELAY}/api/push/register/device-a`, method: 'DELETE' }]);
|
||||
expect(localStorage.getItem('bulwark.push.subscriptionId.v1.a')).toBeNull();
|
||||
expect(localStorage.getItem('bulwark.push.subscriptionId.v1.b')).toBeNull();
|
||||
expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing with nothing registered', async () => {
|
||||
const { browserSub } = installPushBrowser();
|
||||
const calls = installFetch({});
|
||||
await dropAllWebPushRegistrations();
|
||||
expect(calls).toEqual([]);
|
||||
expect(browserSub.unsubscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listPushDevices', () => {
|
||||
it('flags this device and reports the relay status of each registration', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { assertWritable, isConfigReadOnly } from './paths';
|
||||
import { adminConfigDocs, adminStateDocs, isStoreUnavailable } from './admin-store';
|
||||
import type { AdminConfigData, AdminStateData } from './types';
|
||||
import { readFileEnv } from '../read-file-env';
|
||||
import { desktopServerSecret } from '@/lib/desktop/server-secrets';
|
||||
|
||||
const SCRYPT_KEYLEN = 64;
|
||||
const SCRYPT_COST = 16384; // 2^14
|
||||
@@ -269,7 +270,7 @@ export async function initAdminPassword(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
const envPassword = process.env.ADMIN_PASSWORD;
|
||||
const envPassword = desktopServerSecret('adminPassword') || process.env.ADMIN_PASSWORD;
|
||||
if (!envPassword) {
|
||||
if (!cache.initialized) logger.info('Admin dashboard disabled (no ADMIN_PASSWORD set)');
|
||||
cache.initialized = true;
|
||||
|
||||
+4
-1
@@ -123,7 +123,9 @@ export type PayloadPurpose =
|
||||
/** A parked mobile hand-off sign-in in the shared store (lib/auth/handoff-store.ts). */
|
||||
| 'handoff-store'
|
||||
/** Which server a desktop account's refresh token belongs to (lib/oauth/custom-upstream.ts). */
|
||||
| 'custom-upstream';
|
||||
| 'custom-upstream'
|
||||
/** Proof of an earlier session, for wiping a dropped account's plugin secrets (lib/plugin-secrets/wipe-proof.ts). */
|
||||
| 'plugin-wipe-proof';
|
||||
|
||||
/**
|
||||
* Whether a blob minted before purposes were bound may be re-admitted for
|
||||
@@ -148,6 +150,7 @@ const LEGACY_PAYLOAD_MARKERS: Record<PayloadPurpose, (payload: Record<string, un
|
||||
'pairing-store': () => false,
|
||||
'handoff-store': () => false,
|
||||
'custom-upstream': () => false,
|
||||
'plugin-wipe-proof': () => false,
|
||||
};
|
||||
|
||||
export function encryptPayload(payload: Record<string, unknown>, purpose: PayloadPurpose): string {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { desktopServerSecret } from '@/lib/desktop/server-secrets';
|
||||
import { ensureStateDir, getStatePath } from '@/lib/admin/paths';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { getStore, isSharedMode } from '@/lib/shared-store';
|
||||
@@ -25,6 +26,9 @@ function sharedSecret(): string {
|
||||
|
||||
/** A secret the operator set: env, file, or the setup wizard's config. */
|
||||
function configuredSessionSecret(): string {
|
||||
const fromShell = desktopServerSecret('sessionSecret');
|
||||
if (fromShell) return fromShell;
|
||||
|
||||
const fromEnv = process.env.SESSION_SECRET;
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@/lib/security/url-guard';
|
||||
import { forwardedClientHeaders } from '@/lib/auth/client-address';
|
||||
import { observeUpstream } from '@/lib/metrics/upstream';
|
||||
import { desktopRedirectRefused } from '@/lib/desktop/server-redirect';
|
||||
|
||||
const VERIFY_TIMEOUT_MS = 10000;
|
||||
const MAX_REDIRECTS = 3;
|
||||
@@ -453,7 +454,11 @@ async function fetchVerifiedSession(
|
||||
if (!location) {
|
||||
throw new JmapAuthVerificationError('Failed to verify JMAP session', 502);
|
||||
}
|
||||
currentUrl = new URL(location, currentUrl).toString();
|
||||
const next = new URL(location, currentUrl).toString();
|
||||
if (desktopRedirectRefused(currentUrl, next, options.trusted === true)) {
|
||||
throw new JmapAuthVerificationError('The mail server redirected to a server that has not been approved', 502);
|
||||
}
|
||||
currentUrl = next;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// @vitest-environment node
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* An approved server sits inside the SSRF guard, so where it redirects to is the
|
||||
* whole question. Real sockets: what counts is who received the request, and
|
||||
* whether it carried the user's credentials.
|
||||
*/
|
||||
|
||||
const TRUST_KEY = Symbol.for('bulwark.desktop.trustedJmapOrigins');
|
||||
const POLICY_KEY = Symbol.for('bulwark.desktop.serverPolicy');
|
||||
const holder = globalThis as unknown as Record<symbol, unknown>;
|
||||
const AUTH = `Basic ${Buffer.from('probe@example.org:not-a-real-password').toString('base64')}`;
|
||||
|
||||
interface Host {
|
||||
origin: string;
|
||||
hits: { url: string; auth: boolean }[];
|
||||
answer: (req: IncomingMessage, res: ServerResponse) => void;
|
||||
}
|
||||
|
||||
const servers: Server[] = [];
|
||||
|
||||
function session(res: ServerResponse) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ apiUrl: '/jmap/', accounts: { a: {} }, primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' }, username: 'probe@example.org' }));
|
||||
}
|
||||
|
||||
async function host(answer: Host['answer'] = (_req, res) => session(res)): Promise<Host> {
|
||||
const self: Host = { origin: '', hits: [], answer };
|
||||
const server = createServer((req, res) => {
|
||||
self.hits.push({ url: req.url ?? '', auth: !!req.headers.authorization });
|
||||
self.answer(req, res);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
servers.push(server);
|
||||
self.origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||
return self;
|
||||
}
|
||||
|
||||
const redirectTo = (location: string, status = 307): Host['answer'] => (_req, res) => {
|
||||
res.writeHead(status, { location });
|
||||
res.end();
|
||||
};
|
||||
|
||||
function approve(...origins: string[]) {
|
||||
holder[TRUST_KEY] = new Set(origins);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
delete holder[TRUST_KEY];
|
||||
delete holder[POLICY_KEY];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
delete holder[TRUST_KEY];
|
||||
delete holder[POLICY_KEY];
|
||||
await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve))));
|
||||
});
|
||||
|
||||
async function verify(server: string) {
|
||||
const { fetchJmapSessionIdentity } = await import('@/lib/auth/verify-jmap-auth');
|
||||
return fetchJmapSessionIdentity(server, AUTH, { trusted: true });
|
||||
}
|
||||
|
||||
async function fetchVia(url: string, init: RequestInit = {}) {
|
||||
const { fetchJmapServer } = await import('@/lib/stalwart/server-fetch');
|
||||
return fetchJmapServer(url, { headers: { Authorization: AUTH }, ...init }, true);
|
||||
}
|
||||
|
||||
describe('the desktop build, signing in to an approved server', () => {
|
||||
beforeEach(() => vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'));
|
||||
|
||||
it('follows a redirect on the same origin, as Stalwart answers /.well-known/jmap', async () => {
|
||||
const a = await host((req, res) => (req.url === '/.well-known/jmap' ? redirectTo('/jmap/session')(req, res) : session(res)));
|
||||
approve(a.origin);
|
||||
await expect(verify(a.origin)).resolves.toBeTruthy();
|
||||
expect(a.hits).toEqual([{ url: '/.well-known/jmap', auth: true }, { url: '/jmap/session', auth: true }]);
|
||||
});
|
||||
|
||||
it('refuses a redirect to a server nobody approved, which gets nothing', async () => {
|
||||
const b = await host();
|
||||
const a = await host(redirectTo(''));
|
||||
a.answer = redirectTo(`${b.origin}/.well-known/jmap`);
|
||||
approve(a.origin);
|
||||
await expect(verify(a.origin)).rejects.toMatchObject({ name: 'JmapAuthVerificationError', status: 502 });
|
||||
expect(b.hits).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts another name for the same machine as another server', async () => {
|
||||
const b = await host();
|
||||
const a = await host(redirectTo(`${b.origin.replace('127.0.0.1', 'localhost')}/x`));
|
||||
approve(a.origin);
|
||||
await expect(verify(a.origin)).rejects.toMatchObject({ status: 502 });
|
||||
expect(b.hits).toEqual([]);
|
||||
});
|
||||
|
||||
it('follows one to another approved server', async () => {
|
||||
const b = await host();
|
||||
const a = await host(redirectTo(`${b.origin}/jmap/session`));
|
||||
approve(a.origin, b.origin);
|
||||
await expect(verify(a.origin)).resolves.toBeTruthy();
|
||||
expect(b.hits).toEqual([{ url: '/jmap/session', auth: true }]);
|
||||
});
|
||||
|
||||
it('holds AllowedJmapServers across the redirect, even to an approved server', async () => {
|
||||
const b = await host();
|
||||
const a = await host(redirectTo(`${b.origin}/jmap/session`));
|
||||
approve(a.origin, b.origin);
|
||||
holder[POLICY_KEY] = { pluginsMode: 'allowed', pluginAllowlist: [], allowedJmapServers: [a.origin] };
|
||||
await expect(verify(a.origin)).rejects.toMatchObject({ status: 502 });
|
||||
expect(b.hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the desktop build, other requests to an approved server', () => {
|
||||
beforeEach(() => vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'));
|
||||
|
||||
it('follows the same origin', async () => {
|
||||
const a = await host((req, res) => (req.url === '/.well-known/jmap' ? redirectTo('/jmap/session')(req, res) : session(res)));
|
||||
approve(a.origin);
|
||||
expect((await fetchVia(`${a.origin}/.well-known/jmap`)).status).toBe(200);
|
||||
expect(a.hits.map((hit) => hit.auth)).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it('refuses a server nobody approved, which gets nothing', async () => {
|
||||
const b = await host();
|
||||
const a = await host(redirectTo(`${b.origin}/x`));
|
||||
approve(a.origin);
|
||||
await expect(fetchVia(`${a.origin}/y`)).rejects.toThrow();
|
||||
expect(b.hits).toEqual([]);
|
||||
});
|
||||
|
||||
it('still hands back the redirect when asked to', async () => {
|
||||
const b = await host();
|
||||
const a = await host(redirectTo(`${b.origin}/x`));
|
||||
approve(a.origin);
|
||||
expect((await fetchVia(`${a.origin}/y`, { redirect: 'manual' })).status).toBe(307);
|
||||
expect(b.hits).toEqual([]);
|
||||
});
|
||||
|
||||
it('postJmap keeps to the approved set too', async () => {
|
||||
const b = await host();
|
||||
const a = await host(redirectTo(`https://127.0.0.1:${new URL(b.origin).port}/jmap/`));
|
||||
approve(a.origin);
|
||||
const { postJmap } = await import('@/lib/stalwart/jmap-api');
|
||||
await expect(postJmap(`${a.origin}/jmap`, AUTH, '{}', { trusted: true })).rejects.toThrow(/untrusted host/);
|
||||
expect(b.hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('which hops count', () => {
|
||||
beforeEach(() => vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'));
|
||||
|
||||
async function refused(from: string, to: string, approved = true) {
|
||||
const { desktopRedirectRefused } = await import('../server-redirect');
|
||||
return desktopRedirectRefused(from, to, approved);
|
||||
}
|
||||
|
||||
it('lets the same host move up to https on the standard ports only', async () => {
|
||||
expect(await refused('http://mail.lan/x', 'https://mail.lan/x')).toBe(false);
|
||||
expect(await refused('http://mail.lan:8080/x', 'https://mail.lan/x')).toBe(true);
|
||||
expect(await refused('http://mail.lan/x', 'https://mail.lan:8443/x')).toBe(true);
|
||||
expect(await refused('http://mail.lan/x', 'https://other.lan/x')).toBe(true);
|
||||
expect(await refused('https://mail.lan/x', 'http://mail.lan/x')).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves a public server to the hop-by-hop guard, but not to the policy', async () => {
|
||||
expect(await refused('https://a.example/x', 'https://b.example/x', false)).toBe(false);
|
||||
holder[POLICY_KEY] = { pluginsMode: 'allowed', pluginAllowlist: [], allowedJmapServers: ['https://a.example'] };
|
||||
expect(await refused('https://a.example/x', 'https://b.example/x', false)).toBe(true);
|
||||
expect(await refused('https://a.example/x', 'https://a.example/y', false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a server install', () => {
|
||||
it('follows an admin-configured server as it always did', async () => {
|
||||
const b = await host();
|
||||
const a = await host(redirectTo(`${b.origin}/jmap/session`));
|
||||
approve(a.origin);
|
||||
await expect(verify(a.origin)).resolves.toBeTruthy();
|
||||
expect(b.hits).toEqual([{ url: '/jmap/session', auth: true }]);
|
||||
});
|
||||
|
||||
it('other requests too, where fetch takes the credentials off', async () => {
|
||||
const b = await host();
|
||||
const a = await host(redirectTo(`${b.origin}/x`));
|
||||
expect((await fetchVia(`${a.origin}/y`)).status).toBe(200);
|
||||
expect(b.hits).toEqual([{ url: '/x', auth: false }]);
|
||||
});
|
||||
|
||||
it('has no opinion on any hop', async () => {
|
||||
const { desktopRedirectRefused } = await import('../server-redirect');
|
||||
approve();
|
||||
holder[POLICY_KEY] = { pluginsMode: 'allowed', pluginAllowlist: [], allowedJmapServers: [] };
|
||||
expect(desktopRedirectRefused('http://a.lan/', 'http://169.254.169.254/', true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* The desktop app's server gets its secrets by message, so they stay out of its
|
||||
* environment block. No other build looks there.
|
||||
*/
|
||||
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: { get: (_key: string, fallback: unknown) => fallback },
|
||||
}));
|
||||
vi.mock('@/lib/shared-store', () => ({ isSharedMode: () => false, getStore: () => ({}) }));
|
||||
vi.mock('@/lib/admin/paths', () => ({ ensureStateDir: async () => {}, getStatePath: () => 'C:/nowhere/.session-secret' }));
|
||||
|
||||
const KEY = Symbol.for('bulwark.desktop.serverSecrets');
|
||||
const holder = globalThis as unknown as Record<symbol, unknown>;
|
||||
const FROM_SHELL = 'from-the-shell-from-the-shell-from-the-shell';
|
||||
|
||||
async function sessionSecret() {
|
||||
vi.resetModules();
|
||||
return (await import('@/lib/auth/session-secret')).getSessionSecret();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
holder[KEY] = { sessionSecret: FROM_SHELL, adminPassword: 'admin-from-the-shell' };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
delete holder[KEY];
|
||||
});
|
||||
|
||||
describe('the session secret', () => {
|
||||
it('comes from the shell in the desktop build, with nothing in the environment', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
|
||||
vi.stubEnv('SESSION_SECRET', '');
|
||||
expect(await sessionSecret()).toBe(FROM_SHELL);
|
||||
});
|
||||
|
||||
it('never from there in a server build', async () => {
|
||||
vi.stubEnv('SESSION_SECRET', '');
|
||||
expect(await sessionSecret()).toBe('');
|
||||
vi.stubEnv('SESSION_SECRET', 'from-the-environment');
|
||||
expect(await sessionSecret()).toBe('from-the-environment');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the admin password', () => {
|
||||
it('is only readable from the shell in the desktop build', async () => {
|
||||
const { desktopServerSecret } = await import('../server-secrets');
|
||||
expect(desktopServerSecret('adminPassword')).toBe('');
|
||||
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
|
||||
expect(desktopServerSecret('adminPassword')).toBe('admin-from-the-shell');
|
||||
delete holder[KEY];
|
||||
expect(desktopServerSecret('adminPassword')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -134,6 +134,20 @@ describe('the flag is fixed at build time', () => {
|
||||
expect(await trustCheckOfBuild('1')).toContain(DESKTOP_TRUST_MARKER);
|
||||
});
|
||||
|
||||
it('the secrets from the shell are read in the desktop build only', async () => {
|
||||
const compile = async (flag: string) => (await build({
|
||||
entryPoints: [path.join(ROOT, 'lib/desktop/server-secrets.ts')],
|
||||
bundle: true,
|
||||
write: false,
|
||||
minify: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
define: { 'process.env.NEXT_PUBLIC_BULWARK_DESKTOP': JSON.stringify(flag) },
|
||||
})).outputFiles[0].text;
|
||||
expect(await compile('')).not.toContain('bulwark.desktop.serverSecrets');
|
||||
expect(await compile('1')).toContain('bulwark.desktop.serverSecrets');
|
||||
});
|
||||
|
||||
it('no image build sets it', () => {
|
||||
for (const file of ['Dockerfile', 'Dockerfile.lite']) {
|
||||
expect(readFileSync(path.join(ROOT, file), 'utf8')).not.toContain('NEXT_PUBLIC_BULWARK_DESKTOP');
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Where a mail server may send the desktop app's own server with a redirect.
|
||||
*
|
||||
* An approved server gets past the SSRF guard, and the guard only knows about the
|
||||
* address the user approved. Following its redirects anywhere would let it aim the
|
||||
* app at the LAN or at loopback, with the user's credentials on the request. So a
|
||||
* hop stays on its origin, goes to another approved server, or moves the same host
|
||||
* from http to https on the standard ports. Nothing else counts as the same server.
|
||||
*
|
||||
* A public server's hops go through the rebinding-safe fetch, which checks each of
|
||||
* them. AllowedJmapServers holds for every hop, wherever it comes from.
|
||||
*
|
||||
* Every other build keeps its rules: a server an admin configured is the admin's.
|
||||
*/
|
||||
|
||||
import { mailServerRefusedByDevicePolicy } from './server-policy';
|
||||
import { isDesktopTrustedServer } from './trusted-servers';
|
||||
|
||||
function movesUpToHttps(from: URL, to: URL): boolean {
|
||||
return from.protocol === 'http:' && to.protocol === 'https:' && from.hostname === to.hostname && from.port === '' && to.port === '';
|
||||
}
|
||||
|
||||
/** `approved`: the request was let past the guard as an approved server. */
|
||||
export function desktopRedirectRefused(from: string | URL, to: string | URL, approved: boolean): boolean {
|
||||
if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP !== '1') return false;
|
||||
const source = new URL(from);
|
||||
const target = new URL(to);
|
||||
if (mailServerRefusedByDevicePolicy(target.href)) return true;
|
||||
if (!approved || source.origin === target.origin) return false;
|
||||
return !isDesktopTrustedServer(target.href) && !movesUpToHttps(source, target);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* The session secret and local admin password of the desktop app's own server.
|
||||
*
|
||||
* The Electron main process sends them by message, and src/server/host.ts in the
|
||||
* desktop repo puts them on globalThis, which all of Next's bundles share. They stay
|
||||
* out of the environment block, where a child process would inherit them.
|
||||
*
|
||||
* Every other build reads its secrets the usual way, and this compiles to nothing.
|
||||
*/
|
||||
|
||||
interface ShellSecrets {
|
||||
sessionSecret?: string;
|
||||
adminPassword?: string;
|
||||
}
|
||||
|
||||
export function desktopServerSecret(name: keyof ShellSecrets): string {
|
||||
if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP !== '1') return '';
|
||||
const holder = globalThis as unknown as Record<symbol, ShellSecrets | undefined>;
|
||||
return holder[Symbol.for('bulwark.desktop.serverSecrets')]?.[name] ?? '';
|
||||
}
|
||||
@@ -265,6 +265,7 @@ async function endWebPushIfNobodyLeft(): Promise<void> {
|
||||
const registration = await pushRegistration();
|
||||
const subscription = await registration?.pushManager?.getSubscription();
|
||||
await subscription?.unsubscribe();
|
||||
removeLocalKeys([], ['bulwark.push.']);
|
||||
}
|
||||
|
||||
/** With nobody left after this there's nothing to lock for, and a next person shouldn't meet it. */
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Plugin secrets of an account the app dropped, in the desktop app, where they sit
|
||||
* on this device's own server. The account has no cookie left to ask with, so the
|
||||
* route takes who it was, from what was written down at the time. On an instance
|
||||
* they aren't the device's to remove, and nothing is written down.
|
||||
* on this device's own server. The account has no session cookie left to ask with, so
|
||||
* the route takes who it was, from what was written down at the time, with the proof
|
||||
* the account got while it was signed in. On an instance they aren't the device's to
|
||||
* remove, and nothing is written down.
|
||||
*/
|
||||
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Proof that this browser had a session for an account, for the desktop app.
|
||||
*
|
||||
* An account the app dropped has no session cookie left, and its plugin secrets
|
||||
* still sit on this device until a wipe. So when a signed-in account stores or lists
|
||||
* them, it also gets this sealed cookie, and a wipe by name needs the one for that
|
||||
* name. Nobody can then clear an account's secrets by knowing its address.
|
||||
*
|
||||
* It opens nothing else, and only the desktop build writes or reads it.
|
||||
*/
|
||||
|
||||
import type { NextRequest, NextResponse } from 'next/server';
|
||||
import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { userKey, type VaultUser } from './vault';
|
||||
|
||||
/** Chromium's upper limit. A dropped account can stay dropped for a long time. */
|
||||
const MAX_AGE_SECONDS = 400 * 24 * 60 * 60;
|
||||
|
||||
function cookieName(key: string): string {
|
||||
return `jmap_psp_${key.slice(0, 16)}`;
|
||||
}
|
||||
|
||||
export function setWipeProof(response: NextResponse, user: VaultUser): void {
|
||||
const key = userKey(user);
|
||||
response.cookies.set(cookieName(key), encryptPayload({ u: key }, 'plugin-wipe-proof'), { ...getCookieOptions(), maxAge: MAX_AGE_SECONDS });
|
||||
}
|
||||
|
||||
export function clearWipeProof(response: NextResponse, user: VaultUser): void {
|
||||
response.cookies.set(cookieName(userKey(user)), '', { ...getCookieOptions(), maxAge: 0 });
|
||||
}
|
||||
|
||||
export function hasWipeProof(request: NextRequest, user: VaultUser): boolean {
|
||||
const key = userKey(user);
|
||||
const value = request.cookies.get(cookieName(key))?.value;
|
||||
if (!value) return false;
|
||||
try {
|
||||
return decryptPayload(value, 'plugin-wipe-proof')?.u === key;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,23 @@ export function pushPreferencesKey(origin: string): string {
|
||||
return `${origin}${BASE_PATH}/__push-state/prefs`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a login with "Email notifications" off or do not disturb on. The worker
|
||||
* shows nothing for its mail. Per login, as settings are: the store only holds the
|
||||
* active login's, and the other logins' mail must not go quiet because of them.
|
||||
*/
|
||||
export async function writeLoginQuiet(login: string, quiet: boolean): Promise<void> {
|
||||
try {
|
||||
if (typeof caches === 'undefined') return;
|
||||
const cache = await caches.open(PUSH_STATE_CACHE);
|
||||
const key = `${window.location.origin}${BASE_PATH}/__push-state/quiet-logins`;
|
||||
const stored = (await (await cache.match(key))?.json().catch(() => null)) as Record<string, boolean> | null;
|
||||
await cache.put(key, new Response(JSON.stringify({ ...stored, [login]: quiet }), { headers: { 'Content-Type': 'application/json' } }));
|
||||
} catch {
|
||||
// Without the store the worker shows every push.
|
||||
}
|
||||
}
|
||||
|
||||
export async function writePushPreferences(prefs: PushPreferences): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
|
||||
@@ -29,6 +29,71 @@ export function pendingReplyKey(origin: string): string {
|
||||
return `${origin}${BASE_PATH}/__push-state/pending-reply`;
|
||||
}
|
||||
|
||||
/** The cache key sw.js finds the relay registrations under. */
|
||||
export function relayRegistrationsKey(origin: string): string {
|
||||
return `${origin}${BASE_PATH}/__push-state/relay-registrations`;
|
||||
}
|
||||
|
||||
export interface RelayRegistration {
|
||||
relay: string;
|
||||
subscriptionId: string;
|
||||
accountLabel?: string;
|
||||
}
|
||||
|
||||
interface RelayRegistrations {
|
||||
applicationServerKey?: string;
|
||||
entries: Record<string, RelayRegistration>;
|
||||
}
|
||||
|
||||
async function readStored(cache: Cache, key: string): Promise<RelayRegistrations> {
|
||||
const stored = (await (await cache.match(key))?.json()) as RelayRegistrations | undefined;
|
||||
return stored?.entries ? stored : { entries: {} };
|
||||
}
|
||||
|
||||
/** Every login's relay registration on this browser, by JMAP account id. */
|
||||
export async function readRelayRegistrations(): Promise<Record<string, RelayRegistration>> {
|
||||
try {
|
||||
if (typeof caches === 'undefined') return {};
|
||||
const cache = await caches.open(PUSH_STATE_CACHE);
|
||||
return (await readStored(cache, relayRegistrationsKey(window.location.origin))).entries;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the worker re-register a new browser endpoint with the relay when no tab
|
||||
* is open (pushsubscriptionchange). None of this is secret to the browser that
|
||||
* subscribed, and it opens nothing but the relay record.
|
||||
*/
|
||||
export async function keepRelayRegistration(accountId: string, entry: RelayRegistration, applicationServerKey: string): Promise<void> {
|
||||
try {
|
||||
if (typeof caches === 'undefined') return;
|
||||
const cache = await caches.open(PUSH_STATE_CACHE);
|
||||
const key = relayRegistrationsKey(window.location.origin);
|
||||
const stored = await readStored(cache, key);
|
||||
stored.entries[accountId] = entry;
|
||||
stored.applicationServerKey = applicationServerKey;
|
||||
await cache.put(key, new Response(JSON.stringify(stored), { headers: { 'Content-Type': 'application/json' } }));
|
||||
} catch {
|
||||
// A new endpoint then waits for the page's re-sync.
|
||||
}
|
||||
}
|
||||
|
||||
export async function forgetRelayRegistration(accountId: string): Promise<void> {
|
||||
try {
|
||||
if (typeof caches === 'undefined') return;
|
||||
const cache = await caches.open(PUSH_STATE_CACHE);
|
||||
const key = relayRegistrationsKey(window.location.origin);
|
||||
const stored = await readStored(cache, key);
|
||||
if (!(accountId in stored.entries)) return;
|
||||
delete stored.entries[accountId];
|
||||
await cache.put(key, new Response(JSON.stringify(stored), { headers: { 'Content-Type': 'application/json' } }));
|
||||
} catch {
|
||||
// Signing out deletes the store anyway.
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeNotificationActionPrefs(prefs: NotificationActionPrefs): Promise<void> {
|
||||
try {
|
||||
if (typeof caches === 'undefined') return;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* this process — see calendar-agenda's session handling).
|
||||
*/
|
||||
|
||||
import { desktopRedirectRefused } from '@/lib/desktop/server-redirect';
|
||||
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
|
||||
import { UPSTREAM_JSON_MAX_BYTES, readJsonCapped } from '@/lib/security/capped-body';
|
||||
import { forwardedClientHeaders } from '@/lib/auth/client-address';
|
||||
@@ -88,7 +89,7 @@ export async function postJmap(
|
||||
if (!location) return response;
|
||||
|
||||
const next = new URL(location, url);
|
||||
if (!isTrustedRedirect(url, next)) {
|
||||
if (!isTrustedRedirect(url, next) || desktopRedirectRefused(url, next, options.trusted !== false)) {
|
||||
throw new JmapRedirectError(
|
||||
`JMAP endpoint redirected to an untrusted host: ${next.host}`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { decryptSession, sessionAuthHeader } from '@/lib/auth/crypto';
|
||||
import { isPastMaxAge, limitsEnforcedSince, maxSessionHoursFor } from '@/lib/auth/session-age';
|
||||
import { sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
import { readImpersonationConfig } from '@/lib/impersonation/master-config';
|
||||
import { readStalwartAuthContextFromStore, type StalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||
|
||||
type CookieStore = Awaited<ReturnType<typeof cookies>>;
|
||||
|
||||
async function limitsOnlyAPageEnforces(): Promise<boolean> {
|
||||
await configManager.ensureLoaded?.();
|
||||
const policy = configManager.getPolicy();
|
||||
if ((policy.organisation?.session?.idleMinutes ?? 0) > 0) return true;
|
||||
return (policy.scopes ?? []).some((scope) => {
|
||||
const session = scope.policy?.organisation?.session;
|
||||
if ((session?.idleMinutes ?? 0) > 0) return true;
|
||||
return (session?.maxHours ?? 0) > 0 && (scope.match?.groups?.length ?? 0) > 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The login for a request from the service worker, which has no page to refresh
|
||||
* anything. The context cookie goes with the browser session, so a browser that
|
||||
* was closed and woken again by a push only has the remembered one, which a page
|
||||
* would turn back into a context on load (GET /api/auth/session, with the same
|
||||
* checks). Not signed in with "remember me", or by OAuth with an expired token:
|
||||
* none, and the worker shows its generic notification.
|
||||
*
|
||||
* A page also applies limits this can't: the idle timeout and maximums by group
|
||||
* end the session when it loads. With either set anywhere, a closed browser's
|
||||
* notifications stay generic and its buttons open the app.
|
||||
*/
|
||||
export async function readPushAuthContext(cookieStore: CookieStore, slot: number): Promise<StalwartAuthContext | null> {
|
||||
const context = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
if (context) return context;
|
||||
const token = cookieStore.get(sessionCookieName(slot))?.value;
|
||||
if (!token) return null;
|
||||
const credentials = decryptSession(token);
|
||||
if (!credentials) return null;
|
||||
// Without a domain the login can't be matched against the device policy either,
|
||||
// and "Push without sender and subject" could miss it (no account name here).
|
||||
if (!credentials.username.includes('@')) return null;
|
||||
if (await limitsOnlyAPageEnforces()) return null;
|
||||
const impersonation = readImpersonationConfig();
|
||||
if (impersonation && credentials.password === impersonation.masterPassword) return null;
|
||||
const signedInAt = credentials.signedInAt ?? await limitsEnforcedSince();
|
||||
const maxHours = await maxSessionHoursFor(credentials.username.split('%', 1)[0]);
|
||||
if (isPastMaxAge(signedInAt, maxHours)) return null;
|
||||
return { serverUrl: credentials.serverUrl, username: credentials.username, authHeader: sessionAuthHeader(credentials) };
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { logger } from '@/lib/logger';
|
||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||
import { fetchPublicUrl, type PublicFetchResponse } from '@/lib/security/url-guard';
|
||||
import { observeUpstream } from '@/lib/metrics/upstream';
|
||||
import { desktopRedirectRefused } from '@/lib/desktop/server-redirect';
|
||||
import {
|
||||
isStoredCredentialRefused,
|
||||
recordStoredCredentialStatus,
|
||||
@@ -100,7 +101,9 @@ async function fetchUnbudgeted(
|
||||
// endpoints on, one the user chose) must not pin a connection for as long
|
||||
// as it likes. Callers with their own signal keep it.
|
||||
const init: ServerFetchInit = givenInit.signal ? givenInit : { ...givenInit, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) };
|
||||
if (trusted !== false) {
|
||||
// The desktop app's approved server gets its hops checked by hand as well.
|
||||
const approvedOnDesktop = process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' && trusted !== false;
|
||||
if (trusted !== false && !approvedOnDesktop) {
|
||||
return fetch(url, init);
|
||||
}
|
||||
|
||||
@@ -111,12 +114,10 @@ async function fetchUnbudgeted(
|
||||
let body = rest.body;
|
||||
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
const response: PublicFetchResponse = await fetchPublicUrl(currentUrl, {
|
||||
...(rest as Omit<ServerFetchInit, 'redirect' | 'headers'>),
|
||||
method,
|
||||
body: body as never,
|
||||
headers,
|
||||
});
|
||||
const hop = { ...(rest as Omit<ServerFetchInit, 'redirect' | 'headers'>), method, body: body as never, headers };
|
||||
const response: PublicFetchResponse = approvedOnDesktop
|
||||
? ((await fetch(currentUrl, { ...hop, redirect: 'manual' } as RequestInit)) as unknown as PublicFetchResponse)
|
||||
: await fetchPublicUrl(currentUrl, hop);
|
||||
|
||||
if (redirect === 'manual' || response.status < 300 || response.status >= 400) {
|
||||
return response as unknown as Response;
|
||||
@@ -129,6 +130,9 @@ async function fetchUnbudgeted(
|
||||
await response.body?.cancel().catch(() => {});
|
||||
|
||||
const next = new URL(location, currentUrl);
|
||||
if (desktopRedirectRefused(currentUrl, next, approvedOnDesktop)) {
|
||||
throw new Error(`${currentUrl} redirected to a server that has not been approved`);
|
||||
}
|
||||
if (next.origin !== new URL(currentUrl).origin) {
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase() === 'authorization' || key.toLowerCase() === 'cookie') {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { jmapErrorType } from '@/lib/jmap/jmap-error';
|
||||
|
||||
type WebPushStep = 'blocked' | 'not-granted' | 'relay' | 'verification-timeout' | 'teardown';
|
||||
|
||||
/** A step that failed for a reason the user can act on. The message stays for logs. */
|
||||
export class WebPushStepError extends Error {
|
||||
constructor(public readonly step: WebPushStep, message: string) {
|
||||
super(message);
|
||||
this.name = 'WebPushStepError';
|
||||
}
|
||||
}
|
||||
|
||||
export type WebPushErrorKey =
|
||||
| 'error_blocked' | 'error_not_granted' | 'error_relay' | 'error_verification_timeout' | 'error_teardown'
|
||||
| 'error_forbidden' | 'error_over_quota';
|
||||
|
||||
const STEP_MESSAGES: Record<WebPushStep, WebPushErrorKey> = {
|
||||
blocked: 'error_blocked',
|
||||
'not-granted': 'error_not_granted',
|
||||
relay: 'error_relay',
|
||||
'verification-timeout': 'error_verification_timeout',
|
||||
teardown: 'error_teardown',
|
||||
};
|
||||
|
||||
/** The key under settings.notifications.push that says what went wrong, if any does. */
|
||||
export function webPushErrorKey(error: unknown): WebPushErrorKey | null {
|
||||
if (error instanceof WebPushStepError) return STEP_MESSAGES[error.step];
|
||||
const type = jmapErrorType(error);
|
||||
if (type === 'forbidden') return 'error_forbidden';
|
||||
if (type === 'overQuota') return 'error_over_quota';
|
||||
return null;
|
||||
}
|
||||
+255
-47
@@ -8,11 +8,13 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { isDesktopApp } from '@/lib/desktop/bridge';
|
||||
import type { AccountInfo, EmailPushConfig, Mailbox } from '@/lib/jmap/types';
|
||||
import { isJmapSetError, jmapErrorType } from '@/lib/jmap/jmap-error';
|
||||
import { WebPushStepError } from '@/lib/web-push-errors';
|
||||
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
|
||||
import { isBuiltInHost, relayAllowed } from '@/lib/outgoing-hosts';
|
||||
import { noThirdPartyTraffic } from '@/lib/third-party-mode';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { serviceWorkerUrl, SW_SCOPE } from '@/lib/app-update';
|
||||
import { forgetRelayRegistration, keepRelayRegistration, readRelayRegistrations } from '@/lib/pwa-notify/worker-state';
|
||||
import {
|
||||
EMPTY_NOTIFICATION_MUTES,
|
||||
isAccountMuted,
|
||||
@@ -51,6 +53,12 @@ function relayBaseKey(accountId: string): string {
|
||||
return RELAY_BASE_PREFIX + accountId;
|
||||
}
|
||||
|
||||
/** The browser endpoint this account last gave the relay: see relayDroppedOurEndpoint. */
|
||||
const ENDPOINT_PREFIX = 'bulwark.push.endpoint.v1.';
|
||||
function endpointKey(accountId: string): string {
|
||||
return ENDPOINT_PREFIX + accountId;
|
||||
}
|
||||
|
||||
function emailPushRefusedKey(accountId: string): string {
|
||||
return EMAIL_PUSH_REFUSED_PREFIX + accountId;
|
||||
}
|
||||
@@ -420,6 +428,8 @@ export interface EnableWebPushParams {
|
||||
// that outlives a permission change keeps pushing for mailboxes the user can
|
||||
// no longer read - recreating is the only client-side remedy (#841).
|
||||
forceRecreate?: boolean;
|
||||
// For a re-sync: stop if another tab turned push off while this waited its turn.
|
||||
onlyIfStillOn?: boolean;
|
||||
// Scope OS push notifications to Inbox-only mail. Mirrors settings-store's
|
||||
// pushNotifyInboxOnly. Required, not optional: it changes the server-side
|
||||
// delivery filter, so every caller must decide explicitly.
|
||||
@@ -525,12 +535,14 @@ function readPushKey(
|
||||
}
|
||||
|
||||
async function fetchVapidPublicKey(relayBaseUrl: string): Promise<string> {
|
||||
const res = await fetch(buildRelayUrl(relayBaseUrl, '/api/push/vapid-public-key'));
|
||||
const res = await fetch(buildRelayUrl(relayBaseUrl, '/api/push/vapid-public-key')).catch((err: unknown) => {
|
||||
throw new WebPushStepError('relay', `Could not reach the push relay: ${String(err)}`);
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 503) {
|
||||
throw new Error('The push relay does not have Web Push configured');
|
||||
}
|
||||
throw new Error(`Failed to fetch VAPID key: ${res.status}`);
|
||||
throw new WebPushStepError('relay', `Failed to fetch VAPID key: ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as { publicKey?: string };
|
||||
if (!body.publicKey) throw new Error('Relay returned an empty VAPID key');
|
||||
@@ -540,11 +552,11 @@ async function fetchVapidPublicKey(relayBaseUrl: string): Promise<string> {
|
||||
async function ensurePermission(): Promise<void> {
|
||||
if (Notification.permission === 'granted') return;
|
||||
if (Notification.permission === 'denied') {
|
||||
throw new Error('Notifications are blocked - allow them in browser settings to continue');
|
||||
throw new WebPushStepError('blocked', 'Notifications are blocked - allow them in browser settings to continue');
|
||||
}
|
||||
const result = await Notification.requestPermission();
|
||||
if (result !== 'granted') {
|
||||
throw new Error('Notification permission was not granted');
|
||||
throw new WebPushStepError(result === 'denied' ? 'blocked' : 'not-granted', 'Notification permission was not granted');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,9 +595,11 @@ async function registerWithRelay(params: {
|
||||
subscription: { endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } },
|
||||
accountLabel: params.accountLabel,
|
||||
}),
|
||||
}).catch((err: unknown) => {
|
||||
throw new WebPushStepError('relay', `Could not reach the push relay: ${String(err)}`);
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Relay register failed: ${res.status}`);
|
||||
throw new WebPushStepError('relay', `Relay register failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,28 +646,85 @@ async function relayReportsDead(
|
||||
return (await relayStatusFor(relayBaseUrl, subscriptionId)) === 'inactive';
|
||||
}
|
||||
|
||||
// Stalwart rate-limits PushVerification per account (60 s by default).
|
||||
const VERIFICATION_RESEND_AFTER_MS = 62_000;
|
||||
const VERIFICATION_TIMEOUT_MS = 80_000;
|
||||
|
||||
/**
|
||||
* The relay deletes a record when the push service answers 404 or 410. The browser
|
||||
* doesn't get told: it keeps the subscription, and registering that endpoint again
|
||||
* only lasts until the next push. Only for the endpoint this account registered:
|
||||
* after another account moved the browser on, a missing record means nothing.
|
||||
*/
|
||||
async function relayDroppedOurEndpoint(
|
||||
accountId: string,
|
||||
relayBaseUrl: string,
|
||||
subscriptionId: string,
|
||||
endpoint: string,
|
||||
): Promise<boolean> {
|
||||
if (localStorage.getItem(relayBaseKey(accountId)) !== relayBaseUrl) return false;
|
||||
if (localStorage.getItem(endpointKey(accountId)) !== endpoint) return false;
|
||||
try {
|
||||
const res = await fetch(buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`));
|
||||
if (res.status !== 404) return false;
|
||||
// An old relay without this route answers 404 for everything.
|
||||
const body = (await res.json().catch(() => null)) as { error?: unknown } | null;
|
||||
return body?.error === 'Unknown subscription';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The browser moved to a new endpoint: every other login's relay record has to follow, connected in this tab or not. */
|
||||
async function moveOtherLogins(accountId: string, subscription: PushSubscription): Promise<void> {
|
||||
const others = Object.entries(await readRelayRegistrations()).filter(([id]) => id !== accountId);
|
||||
for (const [, entry] of others) {
|
||||
await registerWithRelay({
|
||||
relayBaseUrl: entry.relay,
|
||||
subscriptionId: entry.subscriptionId,
|
||||
subscription: { endpoint: subscription.endpoint, keys: { p256dh: readPushKey(subscription, 'p256dh'), auth: readPushKey(subscription, 'auth') } },
|
||||
accountLabel: entry.accountLabel,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollVerificationCode(
|
||||
relayBaseUrl: string,
|
||||
subscriptionId: string,
|
||||
askAgain: () => Promise<unknown>,
|
||||
): Promise<string> {
|
||||
// Stalwart per-account rate-limits PushVerification posts (default 60s).
|
||||
// If there are leftover unverified subscriptions on the account, our new
|
||||
// one queues up behind them - so we wait long enough to clear one verify
|
||||
// window even in the unlucky case.
|
||||
const timeoutAt = Date.now() + 75_000;
|
||||
// If there are leftover unverified subscriptions on the account, our new one
|
||||
// queues up behind them, so we wait long enough to clear one verify window.
|
||||
//
|
||||
// Stalwart hands out the lowest free subscription id, so one destroyed and
|
||||
// created again within the window (off and on again, or a retry) gets the same
|
||||
// id. It takes that for a repeat and drops the verification for good, on 0.16.27
|
||||
// and 1.0 alike (push.rs: `verified_id == subscription.id`). Any change to the
|
||||
// subscription after the window makes it send one.
|
||||
const startedAt = Date.now();
|
||||
const timeoutAt = startedAt + VERIFICATION_TIMEOUT_MS;
|
||||
let askedAgain = false;
|
||||
let delay = 400;
|
||||
while (Date.now() < timeoutAt) {
|
||||
const res = await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`),
|
||||
);
|
||||
if (res.ok) {
|
||||
const body = (await res.json()) as { verificationCode?: string | null };
|
||||
if (body.verificationCode) return body.verificationCode;
|
||||
if (!askedAgain && Date.now() - startedAt >= VERIFICATION_RESEND_AFTER_MS) {
|
||||
askedAgain = true;
|
||||
await askAgain().catch(() => undefined);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`),
|
||||
);
|
||||
if (res.ok) {
|
||||
const body = (await res.json()) as { verificationCode?: string | null };
|
||||
if (body.verificationCode) return body.verificationCode;
|
||||
}
|
||||
} catch {
|
||||
// A blip on the way to the relay; the code is still coming.
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
delay = Math.min(delay * 1.5, 2000);
|
||||
}
|
||||
throw new Error('Timed out waiting for PushVerification from the JMAP server');
|
||||
throw new WebPushStepError('verification-timeout', 'Timed out waiting for PushVerification from the JMAP server');
|
||||
}
|
||||
|
||||
// Push the subscription's expiry forward and re-sync `types` / the delivery
|
||||
@@ -701,6 +772,28 @@ async function refreshSubscriptionExpires(
|
||||
}
|
||||
}
|
||||
|
||||
// Another tab may be on the same steps, and the relay and the server only know
|
||||
// one registration per device: one tab would reap the subscription the other is
|
||||
// verifying. Tabs take turns, in every browser that has Web Locks. Whoever comes
|
||||
// second finds the first one's work and only refreshes it.
|
||||
function oneTabAtATime<T>(run: () => Promise<T>): Promise<T> {
|
||||
const limited = () => withinPushSetupLimit(run);
|
||||
const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
|
||||
return locks ? locks.request('bulwark-web-push', limited) as Promise<T> : limited();
|
||||
}
|
||||
|
||||
// A worker that never gets ready or a subscribe() that never answers would hold up
|
||||
// every tab and every login, for good. Verification alone can take 80 s.
|
||||
const PUSH_SETUP_LIMIT_MS = 120_000;
|
||||
|
||||
function withinPushSetupLimit<T>(run: () => Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const limit = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new WebPushStepError('verification-timeout', 'Push setup took too long')), PUSH_SETUP_LIMIT_MS);
|
||||
});
|
||||
return Promise.race([run(), limit]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
export async function enableWebPush(
|
||||
params: EnableWebPushParams,
|
||||
): Promise<EnableWebPushResult> {
|
||||
@@ -713,7 +806,18 @@ export async function enableWebPush(
|
||||
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
|
||||
if (!relayBaseUrl) throw new Error('relayBaseUrl is required');
|
||||
|
||||
// Outside the lock: the browser's question must come with the user's click.
|
||||
await ensurePermission();
|
||||
return oneTabAtATime(() => enableWebPushForThisTab(params, relayBaseUrl));
|
||||
}
|
||||
|
||||
async function enableWebPushForThisTab(
|
||||
params: EnableWebPushParams,
|
||||
relayBaseUrl: string,
|
||||
): Promise<EnableWebPushResult> {
|
||||
if (params.onlyIfStillOn && !localStorage.getItem(subscriptionIdKey(params.client.getAccountId()))) {
|
||||
return { subscriptionId: '' };
|
||||
}
|
||||
const registration = await ensureServiceWorker();
|
||||
|
||||
const vapidPublicKey = await fetchVapidPublicKey(relayBaseUrl);
|
||||
@@ -743,16 +847,24 @@ export async function enableWebPush(
|
||||
await pushSubscription.unsubscribe().catch(() => undefined);
|
||||
pushSubscription = null;
|
||||
}
|
||||
const accountId = params.client.getAccountId();
|
||||
const deviceClientId = getOrCreateDeviceClientId(accountId);
|
||||
let endpointGivenUp = false;
|
||||
if (pushSubscription && await relayDroppedOurEndpoint(accountId, relayBaseUrl, deviceClientId, pushSubscription.endpoint)) {
|
||||
await pushSubscription.unsubscribe().catch(() => undefined);
|
||||
pushSubscription = null;
|
||||
endpointGivenUp = true;
|
||||
// The other accounts' records point at the old endpoint: their turn comes too.
|
||||
lastResync.clear();
|
||||
}
|
||||
if (!pushSubscription) {
|
||||
pushSubscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey,
|
||||
});
|
||||
if (endpointGivenUp) await moveOtherLogins(accountId, pushSubscription);
|
||||
}
|
||||
|
||||
const accountId = params.client.getAccountId();
|
||||
const deviceClientId = getOrCreateDeviceClientId(accountId);
|
||||
|
||||
await registerWithRelay({
|
||||
relayBaseUrl,
|
||||
subscriptionId: deviceClientId,
|
||||
@@ -765,6 +877,12 @@ export async function enableWebPush(
|
||||
},
|
||||
accountLabel: params.accountLabel,
|
||||
});
|
||||
localStorage.setItem(endpointKey(accountId), pushSubscription.endpoint);
|
||||
await keepRelayRegistration(accountId, {
|
||||
relay: relayBaseUrl,
|
||||
subscriptionId: deviceClientId,
|
||||
...(params.accountLabel ? { accountLabel: params.accountLabel } : {}),
|
||||
}, vapidPublicKey);
|
||||
|
||||
// Reuse the JMAP-side PushSubscription if the server still has it, just
|
||||
// refreshing the expiry so it doesn't time out between sessions. With
|
||||
@@ -793,12 +911,16 @@ export async function enableWebPush(
|
||||
const storedServerId = localStorage.getItem(subIdKey);
|
||||
let replacedServerId: string | null = null;
|
||||
if (storedServerId) {
|
||||
const match = existingSubs.find((s) => s.id === storedServerId);
|
||||
// By device too: Stalwart gives the id of an expired subscription to the next
|
||||
// one, which may be another device's.
|
||||
const match = existingSubs.find((s) => s.id === storedServerId && s.deviceClientId === deviceClientId);
|
||||
if (match) {
|
||||
if (!params.forceRecreate) {
|
||||
const refreshed = await refreshSubscriptionExpires(params.client, match, emailPush);
|
||||
if (refreshed) {
|
||||
rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush);
|
||||
// Unless it moved, which goes the other way, this is where it lives.
|
||||
if (!localStorage.getItem(relayBaseKey(accountId))) localStorage.setItem(relayBaseKey(accountId), relayBaseUrl);
|
||||
return { subscriptionId: storedServerId };
|
||||
}
|
||||
}
|
||||
@@ -829,7 +951,7 @@ export async function enableWebPush(
|
||||
// relay can't vouch for (a different relay, a non-Bulwark client, a
|
||||
// network blip) - is left untouched.
|
||||
for (const s of existingSubs) {
|
||||
if (s.id === storedServerId) continue;
|
||||
if (s.id === replacedServerId) continue;
|
||||
if (s.deviceClientId === deviceClientId) {
|
||||
await params.client.destroyPushSubscription(s.id).catch(() => undefined);
|
||||
continue;
|
||||
@@ -866,13 +988,27 @@ export async function enableWebPush(
|
||||
const serverAssignedId = created.result;
|
||||
rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush);
|
||||
|
||||
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId);
|
||||
await params.client.verifyPushSubscription(serverAssignedId, verificationCode);
|
||||
try {
|
||||
const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId, () =>
|
||||
params.client.updatePushSubscription(serverAssignedId, { expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS) }),
|
||||
);
|
||||
await params.client.verifyPushSubscription(serverAssignedId, verificationCode);
|
||||
} catch (err) {
|
||||
// Unverified, it never delivers anything, takes up one of the account's
|
||||
// places and stands in the way of the next verification.
|
||||
await params.client.destroyPushSubscription(serverAssignedId).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
const previousRelay = localStorage.getItem(relayBaseKey(accountId));
|
||||
localStorage.setItem(subIdKey, serverAssignedId);
|
||||
localStorage.setItem(relayBaseKey(accountId), relayBaseUrl);
|
||||
if (replacedServerId) {
|
||||
await params.client.destroyPushSubscription(replacedServerId).catch(() => undefined);
|
||||
}
|
||||
// Moved: the old relay would otherwise keep this browser's endpoint for 90 days.
|
||||
if (previousRelay && previousRelay !== relayBaseUrl) {
|
||||
await dropRelayRecord(previousRelay, deviceClientId);
|
||||
}
|
||||
|
||||
return { subscriptionId: serverAssignedId };
|
||||
}
|
||||
@@ -882,46 +1018,76 @@ export interface DisableWebPushParams {
|
||||
relayBaseUrl?: string;
|
||||
}
|
||||
|
||||
// Best-effort teardown: clear the JMAP subscription, the relay mapping, and
|
||||
// (only when no other accounts still need it) the browser-wide
|
||||
// PushSubscription. Any single failure is swallowed so the user always ends
|
||||
// up in a "disabled" state locally.
|
||||
export async function disableWebPush(params: DisableWebPushParams): Promise<void> {
|
||||
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
|
||||
/** Nothing is sent to a relay that may not be used, such as the built-in one in the no-third-party mode. */
|
||||
async function dropRelayRecord(relayBaseUrl: string, deviceClientId: string): Promise<boolean> {
|
||||
if (!relayBaseUrl || !relayAllowed(relayBaseUrl)) return true;
|
||||
try {
|
||||
const res = await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
return res.ok || res.status === 404;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Teardown: clear the JMAP subscription, the relay mapping, and (only when no
|
||||
// other accounts still need it) the browser-wide PushSubscription. A failed step
|
||||
// doesn't stop the others, and the browser always ends up "disabled" locally, so
|
||||
// a later page load won't bring push back. But a server or relay that couldn't be
|
||||
// told keeps pinging this browser for days, so the caller hears about it at the end.
|
||||
export function disableWebPush(params: DisableWebPushParams): Promise<void> {
|
||||
// Read now: a client signing out loses it while this waits its turn.
|
||||
const accountId = params.client.getAccountId();
|
||||
return oneTabAtATime(() => disableWebPushForThisTab(params, accountId));
|
||||
}
|
||||
|
||||
async function disableWebPushForThisTab(params: DisableWebPushParams, accountId: string): Promise<void> {
|
||||
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
|
||||
|
||||
const subIdKey = subscriptionIdKey(accountId);
|
||||
const devIdKey = deviceClientIdKey(accountId);
|
||||
|
||||
const storedServerId = localStorage.getItem(subIdKey);
|
||||
const deviceClientId = localStorage.getItem(devIdKey);
|
||||
const registeredWith = localStorage.getItem(relayBaseKey(accountId));
|
||||
|
||||
// Destroy every subscription the server holds for this device, not just the
|
||||
// id we happen to have recorded. A destroy that lost its round-trip, a failed
|
||||
// enable, or site data cleared between sessions can leave a registration this
|
||||
// client no longer tracks - and Stalwart keeps fanning StateChanges out to it
|
||||
// until it expires, so "disable" has to mean gone (#841).
|
||||
const failed: string[] = [];
|
||||
const idsToDestroy = new Set<string>();
|
||||
if (storedServerId) idsToDestroy.add(storedServerId);
|
||||
// The stored id alone can have gone to another device since (Stalwart reuses them).
|
||||
if (storedServerId && !deviceClientId) idsToDestroy.add(storedServerId);
|
||||
if (deviceClientId) {
|
||||
const existingSubs = await params.client.listPushSubscriptions().catch(() => []);
|
||||
const existingSubs = await params.client.listPushSubscriptions().catch(() => {
|
||||
failed.push('listing the subscriptions');
|
||||
return [];
|
||||
});
|
||||
for (const s of existingSubs) {
|
||||
if (s.deviceClientId === deviceClientId) idsToDestroy.add(s.id);
|
||||
}
|
||||
}
|
||||
for (const id of idsToDestroy) {
|
||||
await params.client.destroyPushSubscription(id).catch(() => undefined);
|
||||
await params.client.destroyPushSubscription(id).catch(() => {
|
||||
failed.push('destroying a subscription');
|
||||
});
|
||||
}
|
||||
localStorage.removeItem(subIdKey);
|
||||
localStorage.removeItem(relayBaseKey(accountId));
|
||||
localStorage.removeItem(endpointKey(accountId));
|
||||
await forgetRelayRegistration(accountId);
|
||||
// Enabling again starts from the full emailPush map.
|
||||
localStorage.removeItem(emailPushRefusedKey(accountId));
|
||||
|
||||
if (deviceClientId && relayBaseUrl && relayAllowed(relayBaseUrl)) {
|
||||
await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
|
||||
{ method: 'DELETE' },
|
||||
).catch(() => undefined);
|
||||
if (deviceClientId) {
|
||||
// The relay this device registered with can differ from the current choice.
|
||||
for (const relay of new Set([registeredWith ?? relayBaseUrl, relayBaseUrl])) {
|
||||
if (!(await dropRelayRecord(relay, deviceClientId))) failed.push('dropping the relay record');
|
||||
}
|
||||
}
|
||||
// Keep the deviceClientId around so a later re-enable for this account
|
||||
// reuses the same relay subscriptionId rather than scattering orphans.
|
||||
@@ -937,6 +1103,55 @@ export async function disableWebPush(params: DisableWebPushParams): Promise<void
|
||||
const sub = await registration?.pushManager.getSubscription();
|
||||
if (sub) await sub.unsubscribe().catch(() => undefined);
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
throw new WebPushStepError('teardown', `Push is off in this browser, but ${failed.join(' and ')} failed`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A login is signing out or being removed: nothing for it should wake this
|
||||
* browser any more, and it won't be there to refresh anything. Takes no relay
|
||||
* from the settings: what counts is the one it registered with.
|
||||
*/
|
||||
export async function endWebPushForSignOut(client: IJMAPClient): Promise<void> {
|
||||
let accountId: string;
|
||||
try {
|
||||
accountId = client.getAccountId();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!accountId || typeof localStorage === 'undefined' || !localStorage.getItem(subscriptionIdKey(accountId))) return;
|
||||
const relayBaseUrl = localStorage.getItem(relayBaseKey(accountId)) ?? undefined;
|
||||
await disableWebPush({ client, relayBaseUrl }).catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nobody stays signed in here. A login without a connection can't reach its mail
|
||||
* server, but the relay needs no login: without the record its pings go nowhere.
|
||||
* Whatever is left of the keys goes, so nothing keeps the browser subscription.
|
||||
*/
|
||||
export async function dropAllWebPushRegistrations(): Promise<void> {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
const accounts: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith(SUBSCRIPTION_ID_PREFIX)) accounts.push(key.slice(SUBSCRIPTION_ID_PREFIX.length));
|
||||
}
|
||||
if (accounts.length === 0) return;
|
||||
for (const accountId of accounts) {
|
||||
const relay = localStorage.getItem(relayBaseKey(accountId));
|
||||
const device = localStorage.getItem(deviceClientIdKey(accountId));
|
||||
if (relay && device) await dropRelayRecord(relay, device);
|
||||
localStorage.removeItem(subscriptionIdKey(accountId));
|
||||
localStorage.removeItem(relayBaseKey(accountId));
|
||||
localStorage.removeItem(endpointKey(accountId));
|
||||
await forgetRelayRegistration(accountId);
|
||||
}
|
||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
||||
const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE).catch(() => null);
|
||||
const sub = await registration?.pushManager.getSubscription().catch(() => null);
|
||||
await sub?.unsubscribe().catch(() => undefined);
|
||||
}
|
||||
|
||||
export interface PushDevice {
|
||||
@@ -1004,15 +1219,7 @@ export async function revokePushDevice(params: {
|
||||
}
|
||||
|
||||
await params.client.destroyPushSubscription(params.device.id);
|
||||
if (relayBaseUrl && relayAllowed(relayBaseUrl) && params.device.deviceClientId) {
|
||||
await fetch(
|
||||
buildRelayUrl(
|
||||
relayBaseUrl,
|
||||
`/api/push/register/${encodeURIComponent(params.device.deviceClientId)}`,
|
||||
),
|
||||
{ method: 'DELETE' },
|
||||
).catch(() => undefined);
|
||||
}
|
||||
if (params.device.deviceClientId) await dropRelayRecord(relayBaseUrl, params.device.deviceClientId);
|
||||
}
|
||||
|
||||
export async function isWebPushEnabled(accountId: string): Promise<boolean> {
|
||||
@@ -1117,6 +1324,7 @@ async function runResync(accountId: string, params: ResyncWebPushParams): Promis
|
||||
if (!moved && !params.force && !(await resyncDue(accountId))) return false;
|
||||
await enableWebPush({
|
||||
client: params.client,
|
||||
onlyIfStillOn: true,
|
||||
forceRecreate: moved,
|
||||
relayBaseUrl: params.relayBaseUrl,
|
||||
accountLabel: params.accountLabel,
|
||||
|
||||
@@ -1740,6 +1740,7 @@
|
||||
"status_inactive": "غير مفعّل على هذا الجهاز",
|
||||
"status_unsupported": "هذا المتصفح لا يدعم Web Push",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "الإشعارات في الخلفية غير متاحة في النسخة التجريبية.",
|
||||
"status_busy": "جارٍ العمل…",
|
||||
"enable": "تفعيل",
|
||||
"reenable": "إعادة التسجيل",
|
||||
@@ -1766,7 +1767,12 @@
|
||||
"confirm_revoke_message_this": "سيتوقّف هذا الجهاز فورًا عن تلقّي الإشعارات في الخلفية.",
|
||||
"shared_mailboxes_no_push": "صناديق البريد التي يشاركها الآخرون معك لا ترسل إشعارات في الخلفية.",
|
||||
"error_forbidden": "لا يسمح خادم البريد بالإشعارات في الخلفية لهذا الحساب.",
|
||||
"error_over_quota": "لدى هذا الحساب عدد كبير جدًا من تسجيلات الإشعارات الفورية على خادم البريد (الحد عادةً 15). ألغِ تسجيل الأجهزة التي لم تعد تستخدمها من إعدادات الإشعارات، ثم حاول مرة أخرى."
|
||||
"error_over_quota": "لدى هذا الحساب عدد كبير جدًا من تسجيلات الإشعارات الفورية على خادم البريد (الحد عادةً 15). ألغِ تسجيل الأجهزة التي لم تعد تستخدمها من إعدادات الإشعارات، ثم حاول مرة أخرى.",
|
||||
"error_blocked": "الإشعارات محظورة لهذا الموقع. اسمح بها في إعدادات الموقع في المتصفح، ثم حاول مرة أخرى.",
|
||||
"error_not_granted": "لم يتم السماح بالإشعارات، لذا يبقى الإشعار الفوري متوقفًا.",
|
||||
"error_verification_timeout": "لم يؤكد خادم البريد هذا الجهاز في الوقت المحدد، لذا يبقى الإشعار الفوري متوقفًا. حاول مرة أخرى بعد دقيقة.",
|
||||
"error_relay": "لا يمكن الوصول إلى مرحّل الإشعارات الآن، لذا يبقى الإشعار الفوري متوقفًا. حاول لاحقًا.",
|
||||
"error_teardown": "تم إيقاف الإشعارات الفورية في هذا المتصفح، لكن تعذر الوصول إلى خادم البريد أو المرحّل. قد تستمر الإشعارات في الوصول لبضعة أيام. لإيقافها أسرع، فعّل الإعدادات المتقدمة عندما تعود إلى الاتصال وألغِ هذا الجهاز ضمن الأجهزة المسجلة."
|
||||
},
|
||||
"push_all": {
|
||||
"title": "All accounts",
|
||||
|
||||
@@ -1740,6 +1740,7 @@
|
||||
"status_inactive": "No activat en aquest dispositiu",
|
||||
"status_unsupported": "Aquest navegador no admet Web Push",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "Les notificacions en segon pla no estan disponibles a la demo.",
|
||||
"status_busy": "Treballant…",
|
||||
"enable": "Activa",
|
||||
"reenable": "Torna a registrar",
|
||||
@@ -1766,7 +1767,12 @@
|
||||
"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."
|
||||
"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.",
|
||||
"error_blocked": "Les notificacions estan bloquejades per a aquest lloc. Permet-les a la configuració del lloc del navegador i torna-ho a provar.",
|
||||
"error_not_granted": "No s'han permès les notificacions, així que les notificacions push continuen desactivades.",
|
||||
"error_verification_timeout": "El servidor de correu no ha confirmat aquest dispositiu a temps, així que les notificacions push continuen desactivades. Torna-ho a provar d'aquí a un minut.",
|
||||
"error_relay": "Ara mateix no es pot accedir al repetidor de notificacions, així que les notificacions push continuen desactivades. Torna-ho a provar més tard.",
|
||||
"error_teardown": "Les notificacions push estan desactivades en aquest navegador, però no s'ha pogut accedir al servidor de correu o al repetidor. Encara poden arribar notificacions durant uns dies. Per aturar-les abans, activa la configuració avançada quan tornis a tenir connexió i revoca aquest dispositiu a Dispositius registrats."
|
||||
},
|
||||
"push_all": {
|
||||
"title": "All accounts",
|
||||
|
||||
@@ -1780,6 +1780,7 @@
|
||||
"status_inactive": "Není povoleno na tomto zařízení",
|
||||
"status_unsupported": "Tento prohlížeč nepodporuje Web Push",
|
||||
"status_lite": "Web Push není v Bulwark Lite k dispozici; vyžaduje server Bulwark.",
|
||||
"status_demo": "Oznámení na pozadí nejsou v ukázce k dispozici.",
|
||||
"title": "Oznámení na pozadí",
|
||||
"devices_title": "Registrovaná zařízení",
|
||||
"devices_desc": "Všechna zařízení registrovaná pro příjem oznamení na pozadí u tohoto účtu. Odvolání okamžitě zastaví jejich příjem.",
|
||||
@@ -1799,7 +1800,12 @@
|
||||
"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."
|
||||
"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.",
|
||||
"error_blocked": "Oznámení jsou pro tento web blokována. Povolte je v nastavení webu v prohlížeči a zkuste to znovu.",
|
||||
"error_not_granted": "Oznámení nebyla povolena, takže push zůstává vypnutý.",
|
||||
"error_verification_timeout": "Poštovní server toto zařízení včas nepotvrdil, takže push zůstává vypnutý. Zkuste to znovu za minutu.",
|
||||
"error_relay": "Push relay teď není dostupné, takže push zůstává vypnutý. Zkuste to později.",
|
||||
"error_teardown": "Push je v tomto prohlížeči vypnutý, ale poštovní server nebo relay nebyly dostupné. Oznámení mohou ještě několik dní přicházet. Chcete-li je zastavit dříve, až budete online, zapněte pokročilá nastavení a odvolejte toto zařízení v části Registrovaná zařízení."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
@@ -1740,6 +1740,7 @@
|
||||
"status_inactive": "Ikke aktiveret på denne enhed",
|
||||
"status_unsupported": "Denne browser understøtter ikke Web Push",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "Baggrundsnotifikationer er ikke tilgængelige i demoen.",
|
||||
"status_busy": "Arbejder…",
|
||||
"enable": "Aktivér",
|
||||
"reenable": "Genregistrér",
|
||||
@@ -1766,7 +1767,12 @@
|
||||
"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."
|
||||
"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.",
|
||||
"error_blocked": "Notifikationer er blokeret for dette websted. Tillad dem i browserens webstedsindstillinger, og prøv igen.",
|
||||
"error_not_granted": "Notifikationer blev ikke tilladt, så push forbliver slået fra.",
|
||||
"error_verification_timeout": "Mailserveren bekræftede ikke denne enhed i tide, så push forbliver slået fra. Prøv igen om et minut.",
|
||||
"error_relay": "Push-relayet kan ikke nås lige nu, så push forbliver slået fra. Prøv igen senere.",
|
||||
"error_teardown": "Push er slået fra i denne browser, men mailserveren eller relayet kunne ikke nås. Der kan stadig komme notifikationer i nogle dage. Vil du stoppe dem før, så slå avancerede indstillinger til, når du er online igen, og tilbagekald denne enhed under Registrerede enheder."
|
||||
},
|
||||
"push_all": {
|
||||
"title": "All accounts",
|
||||
|
||||
@@ -1780,6 +1780,7 @@
|
||||
"status_inactive": "Auf diesem Gerät nicht aktiviert",
|
||||
"status_unsupported": "Dieser Browser unterstützt Web Push nicht",
|
||||
"status_lite": "Web Push ist in Bulwark Lite nicht verfügbar; es benötigt den Bulwark-Server.",
|
||||
"status_demo": "Hintergrundbenachrichtigungen sind in der Demo nicht verfügbar.",
|
||||
"title": "Hintergrundbenachrichtigungen",
|
||||
"devices_title": "Registrierte Geräte",
|
||||
"devices_desc": "Alle Geräte, die Hintergrundbenachrichtigungen für dieses Konto erhalten. Wird eines widerrufen, empfängt es sofort keine mehr.",
|
||||
@@ -1799,7 +1800,12 @@
|
||||
"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."
|
||||
"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.",
|
||||
"error_blocked": "Benachrichtigungen sind für diese Website blockiert. Erlauben Sie sie in den Website-Einstellungen Ihres Browsers und versuchen Sie es dann erneut.",
|
||||
"error_not_granted": "Benachrichtigungen wurden nicht erlaubt, Push bleibt deshalb aus.",
|
||||
"error_verification_timeout": "Der Mailserver hat dieses Gerät nicht rechtzeitig bestätigt, Push bleibt deshalb aus. Versuchen Sie es in einer Minute erneut.",
|
||||
"error_relay": "Das Push-Relay ist gerade nicht erreichbar, Push bleibt deshalb aus. Versuchen Sie es später erneut.",
|
||||
"error_teardown": "Push ist in diesem Browser aus, aber der Mailserver oder das Relay waren nicht erreichbar. Benachrichtigungen können noch einige Tage ankommen. Um sie früher zu beenden, schalten Sie die erweiterten Einstellungen ein, sobald Sie wieder online sind, und widerrufen Sie dieses Gerät unter „Registrierte Geräte“."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
@@ -1740,6 +1740,7 @@
|
||||
"status_inactive": "Not enabled on this device",
|
||||
"status_unsupported": "This browser does not support Web Push",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "Background notifications aren't available in the demo.",
|
||||
"status_busy": "Working…",
|
||||
"enable": "Enable",
|
||||
"reenable": "Re-register",
|
||||
@@ -1766,7 +1767,12 @@
|
||||
"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."
|
||||
"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.",
|
||||
"error_blocked": "Notifications are blocked for this site. Allow them in your browser's site settings, then try again.",
|
||||
"error_not_granted": "Notifications weren't allowed, so push stays off.",
|
||||
"error_verification_timeout": "The mail server didn't confirm this device in time, so push stays off. Try again in a minute.",
|
||||
"error_relay": "The push relay can't be reached right now, so push stays off. Try again later.",
|
||||
"error_teardown": "Push is off in this browser, but the mail server or the relay couldn't be reached. Notifications may keep arriving for a few days. To stop them sooner, turn on advanced settings once you're back online and revoke this device under Registered devices."
|
||||
},
|
||||
"push_all": {
|
||||
"title": "All accounts",
|
||||
|
||||
@@ -1780,6 +1780,7 @@
|
||||
"status_inactive": "No habilitado en este dispositivo",
|
||||
"status_unsupported": "Este navegador no admite Web Push",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "Las notificaciones en segundo plano no están disponibles en la demo.",
|
||||
"title": "Notificaciones en segundo plano",
|
||||
"devices_title": "Dispositivos registrados",
|
||||
"devices_desc": "Todos los dispositivos registrados para recibir notificaciones en segundo plano de esta cuenta. Al revocar uno, deja de recibirlas de inmediato.",
|
||||
@@ -1799,7 +1800,12 @@
|
||||
"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."
|
||||
"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.",
|
||||
"error_blocked": "Las notificaciones están bloqueadas para este sitio. Permítelas en la configuración del sitio de tu navegador y vuelve a intentarlo.",
|
||||
"error_not_granted": "No se permitieron las notificaciones, así que las notificaciones push siguen desactivadas.",
|
||||
"error_verification_timeout": "El servidor de correo no confirmó este dispositivo a tiempo, así que las notificaciones push siguen desactivadas. Inténtalo de nuevo en un minuto.",
|
||||
"error_relay": "No se puede acceder al relé de notificaciones en este momento, así que las notificaciones push siguen desactivadas. Inténtalo más tarde.",
|
||||
"error_teardown": "Las notificaciones push están desactivadas en este navegador, pero no se pudo acceder al servidor de correo o al relé. Es posible que sigan llegando notificaciones durante unos días. Para detenerlas antes, activa los ajustes avanzados cuando vuelvas a tener conexión y revoca este dispositivo en Dispositivos registrados."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
@@ -1740,6 +1740,7 @@
|
||||
"status_inactive": "Selles seadmes pole lubatud",
|
||||
"status_unsupported": "See brauser ei toeta Web Pushi",
|
||||
"status_lite": "Veebi-tõukemärguanded pole Bulwark Lite'is saadaval; need vajavad Bulwarki serverit.",
|
||||
"status_demo": "Taustateavitused pole demos saadaval.",
|
||||
"status_busy": "Töötleb…",
|
||||
"enable": "Luba",
|
||||
"reenable": "Registreeri uuesti",
|
||||
@@ -1766,7 +1767,12 @@
|
||||
"confirm_revoke_message_this": "See seade lakkab taustateavitusi koheselt saamast.",
|
||||
"shared_mailboxes_no_push": "Postkastid, mida teised sinuga jagavad, taustateavitusi ei saada.",
|
||||
"error_forbidden": "Meiliserver ei luba selle konto jaoks taustateavitusi.",
|
||||
"error_over_quota": "Sellel kontol on meiliserveris liiga palju pushi registreeringuid (piirang on tavaliselt 15). Tühista teavituste seadetes seadmed, mida sa enam ei kasuta, ja proovi uuesti."
|
||||
"error_over_quota": "Sellel kontol on meiliserveris liiga palju pushi registreeringuid (piirang on tavaliselt 15). Tühista teavituste seadetes seadmed, mida sa enam ei kasuta, ja proovi uuesti.",
|
||||
"error_blocked": "Teavitused on selle saidi jaoks blokeeritud. Luba need brauseri saidiseadetes ja proovi uuesti.",
|
||||
"error_not_granted": "Teavitusi ei lubatud, seega jääb tõuketeavitus välja.",
|
||||
"error_verification_timeout": "E-posti server ei kinnitanud seda seadet õigel ajal, seega jääb tõuketeavitus välja. Proovi minuti pärast uuesti.",
|
||||
"error_relay": "Tõuketeavituste releed ei saa praegu kätte, seega jääb tõuketeavitus välja. Proovi hiljem uuesti.",
|
||||
"error_teardown": "Tõuketeavitus on selles brauseris väljas, kuid e-posti serverit või releed ei saadud kätte. Teavitusi võib veel mõne päeva tulla. Et need varem lõpetada, lülita võrku naastes sisse täpsemad seaded ja tühista see seade jaotises Registreeritud seadmed."
|
||||
},
|
||||
"push_all": {
|
||||
"title": "Kõik kontod",
|
||||
|
||||
@@ -1740,6 +1740,7 @@
|
||||
"status_inactive": "فعال نشده",
|
||||
"status_unsupported": "مرورگر پشتیبانی نمیکند",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "اعلانهای پسزمینه در نسخهٔ نمایشی در دسترس نیستند.",
|
||||
"status_busy": "در حال کار…",
|
||||
"enable": "فعال کردن",
|
||||
"reenable": "ثبت مجدد",
|
||||
@@ -1766,7 +1767,12 @@
|
||||
"confirm_revoke_message_this": "این دستگاه بیدرنگ دریافت اعلانهای پسزمینه را متوقف میکند.",
|
||||
"shared_mailboxes_no_push": "صندوقهای پستی که دیگران با شما به اشتراک میگذارند اعلان پسزمینه ارسال نمیکنند.",
|
||||
"error_forbidden": "سرور ایمیل اعلانهای پسزمینه را برای این حساب مجاز نمیداند.",
|
||||
"error_over_quota": "این حساب روی سرور ایمیل ثبتهای پوش بیش از حد دارد (سقف معمولاً 15 است). دستگاههایی را که دیگر استفاده نمیکنید در تنظیمات اعلانها لغو کنید و دوباره امتحان کنید."
|
||||
"error_over_quota": "این حساب روی سرور ایمیل ثبتهای پوش بیش از حد دارد (سقف معمولاً 15 است). دستگاههایی را که دیگر استفاده نمیکنید در تنظیمات اعلانها لغو کنید و دوباره امتحان کنید.",
|
||||
"error_blocked": "اعلانها برای این سایت مسدود هستند. آنها را در تنظیمات سایت مرورگر مجاز کنید و دوباره امتحان کنید.",
|
||||
"error_not_granted": "اعلانها مجاز نشدند، پس اعلان فوری خاموش میماند.",
|
||||
"error_verification_timeout": "سرور ایمیل این دستگاه را بهموقع تأیید نکرد، پس اعلان فوری خاموش میماند. یک دقیقه دیگر دوباره امتحان کنید.",
|
||||
"error_relay": "در حال حاضر دسترسی به رله اعلان ممکن نیست، پس اعلان فوری خاموش میماند. بعداً دوباره امتحان کنید.",
|
||||
"error_teardown": "اعلان فوری در این مرورگر خاموش شد، اما دسترسی به سرور ایمیل یا رله ممکن نبود. ممکن است تا چند روز اعلانها برسند. برای قطع زودتر آنها، وقتی دوباره آنلاین شدید تنظیمات پیشرفته را روشن کنید و این دستگاه را در بخش دستگاههای ثبتشده لغو کنید."
|
||||
},
|
||||
"push_all": {
|
||||
"title": "All accounts",
|
||||
|
||||
@@ -1780,6 +1780,7 @@
|
||||
"status_inactive": "Non activé sur cet appareil",
|
||||
"status_unsupported": "Ce navigateur ne prend pas en charge Web Push",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "Les notifications en arrière-plan ne sont pas disponibles dans la démo.",
|
||||
"title": "Notifications en arrière-plan",
|
||||
"devices_title": "Appareils enregistrés",
|
||||
"devices_desc": "Tous les appareils enregistrés pour recevoir les notifications en arrière-plan de ce compte. Révoquer un appareil l’empêche immédiatement d’en recevoir.",
|
||||
@@ -1799,7 +1800,12 @@
|
||||
"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."
|
||||
"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.",
|
||||
"error_blocked": "Les notifications sont bloquées pour ce site. Autorisez-les dans les paramètres du site de votre navigateur, puis réessayez.",
|
||||
"error_not_granted": "Les notifications n'ont pas été autorisées, les notifications push restent donc désactivées.",
|
||||
"error_verification_timeout": "Le serveur de messagerie n'a pas confirmé cet appareil à temps, les notifications push restent donc désactivées. Réessayez dans une minute.",
|
||||
"error_relay": "Le relais de notifications est injoignable pour le moment, les notifications push restent donc désactivées. Réessayez plus tard.",
|
||||
"error_teardown": "Les notifications push sont désactivées dans ce navigateur, mais le serveur de messagerie ou le relais n'a pas pu être joint. Des notifications peuvent encore arriver pendant quelques jours. Pour les arrêter plus tôt, activez les paramètres avancés une fois de nouveau en ligne et révoquez cet appareil sous Appareils enregistrés."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
@@ -1716,6 +1716,7 @@
|
||||
"status_inactive": "לא מופעל בהתקן זה",
|
||||
"status_unsupported": "דפדפן זה אינו תומך בדחיפה ברשת",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "התראות ברקע אינן זמינות בהדגמה.",
|
||||
"status_busy": "עובד…",
|
||||
"enable": "הפעל",
|
||||
"reenable": "רשום מחדש",
|
||||
@@ -1742,7 +1743,12 @@
|
||||
"confirm_revoke_message_this": "מכשיר זה יפסיק לקבל התראות ברקע מיד.",
|
||||
"shared_mailboxes_no_push": "תיבות דואר שאחרים משתפים איתך אינן שולחות התראות ברקע.",
|
||||
"error_forbidden": "שרת הדואר אינו מאפשר התראות ברקע עבור חשבון זה.",
|
||||
"error_over_quota": "לחשבון זה יש יותר מדי רישומי פוש בשרת הדואר (המגבלה היא בדרך כלל 15). בטל בהגדרות ההתראות מכשירים שאינך משתמש בהם עוד, ונסה שוב."
|
||||
"error_over_quota": "לחשבון זה יש יותר מדי רישומי פוש בשרת הדואר (המגבלה היא בדרך כלל 15). בטל בהגדרות ההתראות מכשירים שאינך משתמש בהם עוד, ונסה שוב.",
|
||||
"error_blocked": "ההתראות חסומות עבור אתר זה. אפשר אותן בהגדרות האתר בדפדפן ונסה שוב.",
|
||||
"error_not_granted": "ההתראות לא אושרו, ולכן התראות הפוש נשארות כבויות.",
|
||||
"error_verification_timeout": "שרת הדואר לא אישר את המכשיר הזה בזמן, ולכן התראות הפוש נשארות כבויות. נסה שוב בעוד דקה.",
|
||||
"error_relay": "אי אפשר להגיע כרגע לממסר ההתראות, ולכן התראות הפוש נשארות כבויות. נסה שוב מאוחר יותר.",
|
||||
"error_teardown": "התראות הפוש כבויות בדפדפן הזה, אבל לא ניתן היה להגיע לשרת הדואר או לממסר. ייתכן שהתראות ימשיכו להגיע עוד כמה ימים. כדי להפסיק אותן מוקדם יותר, כשתחזור להיות מחובר הפעל את ההגדרות המתקדמות ובטל את המכשיר הזה תחת מכשירים רשומים."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
@@ -1740,6 +1740,7 @@
|
||||
"status_inactive": "Nincs engedélyezve ezen az eszközön",
|
||||
"status_unsupported": "Ez a böngésző nem támogatja a Web Push-t",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "A háttérértesítések nem érhetők el a bemutatóban.",
|
||||
"status_busy": "Munka...",
|
||||
"enable": "Engedélyezés",
|
||||
"reenable": "Újraregisztráció",
|
||||
@@ -1766,7 +1767,12 @@
|
||||
"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."
|
||||
"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.",
|
||||
"error_blocked": "Az értesítések le vannak tiltva ezen a webhelyen. Engedélyezd őket a böngésző webhelybeállításaiban, majd próbáld újra.",
|
||||
"error_not_granted": "Az értesítések nem lettek engedélyezve, ezért a push kikapcsolva marad.",
|
||||
"error_verification_timeout": "A levelezőszerver nem erősítette meg időben ezt az eszközt, ezért a push kikapcsolva marad. Próbáld újra egy perc múlva.",
|
||||
"error_relay": "A push relay most nem érhető el, ezért a push kikapcsolva marad. Próbáld újra később.",
|
||||
"error_teardown": "A push ki van kapcsolva ebben a böngészőben, de a levelezőszerver vagy a relay nem volt elérhető. Néhány napig még érkezhetnek értesítések. Ha hamarabb le szeretnéd állítani őket, amint újra online vagy, kapcsold be a speciális beállításokat, és vond vissza ezt az eszközt a Regisztrált eszközök résznél."
|
||||
},
|
||||
"push_all": {
|
||||
"title": "All accounts",
|
||||
|
||||
@@ -1780,6 +1780,7 @@
|
||||
"status_inactive": "Non abilitato su questo dispositivo",
|
||||
"status_unsupported": "Questo browser non supporta Web Push",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "Le notifiche in background non sono disponibili nella demo.",
|
||||
"title": "Notifiche in background",
|
||||
"devices_title": "Dispositivi registrati",
|
||||
"devices_desc": "Tutti i dispositivi registrati per ricevere le notifiche in background di questo account. Revocandone uno, smette subito di riceverle.",
|
||||
@@ -1799,7 +1800,12 @@
|
||||
"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."
|
||||
"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.",
|
||||
"error_blocked": "Le notifiche sono bloccate per questo sito. Consentile nelle impostazioni del sito del browser, poi riprova.",
|
||||
"error_not_granted": "Le notifiche non sono state consentite, quindi le notifiche push restano disattivate.",
|
||||
"error_verification_timeout": "Il server di posta non ha confermato questo dispositivo in tempo, quindi le notifiche push restano disattivate. Riprova tra un minuto.",
|
||||
"error_relay": "Il relay delle notifiche non è raggiungibile in questo momento, quindi le notifiche push restano disattivate. Riprova più tardi.",
|
||||
"error_teardown": "Le notifiche push sono disattivate in questo browser, ma il server di posta o il relay non erano raggiungibili. Potrebbero arrivare notifiche ancora per qualche giorno. Per fermarle prima, quando torni online attiva le impostazioni avanzate e revoca questo dispositivo in Dispositivi registrati."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
@@ -1780,6 +1780,7 @@
|
||||
"status_inactive": "このデバイスでは有効になっていません",
|
||||
"status_unsupported": "このブラウザは Web Push をサポートしていません",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "デモではバックグラウンド通知を利用できません。",
|
||||
"title": "バックグラウンド通知",
|
||||
"devices_title": "登録済みのデバイス",
|
||||
"devices_desc": "このアカウントのバックグラウンド通知を受け取るよう登録されているデバイスの一覧です。取り消すと、そのデバイスはすぐに受信を停止します。",
|
||||
@@ -1799,7 +1800,12 @@
|
||||
"confirm_revoke_message_this": "このデバイスはすぐにバックグラウンド通知の受信を停止します。",
|
||||
"shared_mailboxes_no_push": "他のユーザーから共有されたメールボックスでは、バックグラウンド通知は送信されません。",
|
||||
"error_forbidden": "メールサーバーは、このアカウントのバックグラウンド通知を許可していません。",
|
||||
"error_over_quota": "このアカウントはメールサーバー上のプッシュ登録が多すぎます(上限は通常 15 件)。通知設定で使用していないデバイスを取り消してから、もう一度お試しください。"
|
||||
"error_over_quota": "このアカウントはメールサーバー上のプッシュ登録が多すぎます(上限は通常 15 件)。通知設定で使用していないデバイスを取り消してから、もう一度お試しください。",
|
||||
"error_blocked": "このサイトの通知はブロックされています。ブラウザのサイト設定で許可してから、もう一度お試しください。",
|
||||
"error_not_granted": "通知が許可されなかったため、プッシュはオフのままです。",
|
||||
"error_verification_timeout": "メールサーバーがこのデバイスを時間内に確認しなかったため、プッシュはオフのままです。1分後にもう一度お試しください。",
|
||||
"error_relay": "プッシュリレーに接続できないため、プッシュはオフのままです。後でもう一度お試しください。",
|
||||
"error_teardown": "このブラウザのプッシュはオフになりましたが、メールサーバーまたはリレーに接続できませんでした。数日間は通知が届く場合があります。早く止めるには、オンラインに戻ったら詳細設定をオンにして、登録済みデバイスでこのデバイスを取り消してください。"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
@@ -1780,6 +1780,7 @@
|
||||
"status_inactive": "이 기기에서 활성화되지 않음",
|
||||
"status_unsupported": "이 브라우저는 Web Push를 지원하지 않습니다",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "데모에서는 백그라운드 알림을 사용할 수 없습니다.",
|
||||
"title": "백그라운드 알림",
|
||||
"devices_title": "등록된 기기",
|
||||
"devices_desc": "이 계정의 백그라운드 알림을 받도록 등록된 모든 기기입니다. 해지하면 해당 기기는 즉시 수신을 멈추니다.",
|
||||
@@ -1799,7 +1800,12 @@
|
||||
"confirm_revoke_message_this": "이 기기는 즉시 백그라운드 알림 수신을 멈추게 됩니다.",
|
||||
"shared_mailboxes_no_push": "다른 사람이 공유한 메일함은 백그라운드 알림을 보내지 않습니다.",
|
||||
"error_forbidden": "메일 서버에서 이 계정의 백그라운드 알림을 허용하지 않습니다.",
|
||||
"error_over_quota": "이 계정은 메일 서버에 푸시 등록이 너무 많습니다(한도는 보통 15개). 알림 설정에서 더 이상 사용하지 않는 기기를 해지한 후 다시 시도하세요."
|
||||
"error_over_quota": "이 계정은 메일 서버에 푸시 등록이 너무 많습니다(한도는 보통 15개). 알림 설정에서 더 이상 사용하지 않는 기기를 해지한 후 다시 시도하세요.",
|
||||
"error_blocked": "이 사이트의 알림이 차단되어 있습니다. 브라우저의 사이트 설정에서 허용한 후 다시 시도하세요.",
|
||||
"error_not_granted": "알림이 허용되지 않아 푸시가 꺼진 상태로 유지됩니다.",
|
||||
"error_verification_timeout": "메일 서버가 이 기기를 제때 확인하지 않아 푸시가 꺼진 상태로 유지됩니다. 1분 후에 다시 시도하세요.",
|
||||
"error_relay": "지금은 푸시 릴레이에 연결할 수 없어 푸시가 꺼진 상태로 유지됩니다. 나중에 다시 시도하세요.",
|
||||
"error_teardown": "이 브라우저에서 푸시가 꺼졌지만 메일 서버나 릴레이에 연결할 수 없었습니다. 며칠 동안 알림이 계속 올 수 있습니다. 더 빨리 중지하려면 다시 온라인 상태가 되었을 때 고급 설정을 켜고 등록된 기기에서 이 기기를 취소하세요."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
@@ -1780,6 +1780,7 @@
|
||||
"status_inactive": "Nav iespējots šajā ierīcē",
|
||||
"status_unsupported": "Šī pārlūkprogramma neatbalsta Web Push",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "Fona paziņojumi demonstrācijā nav pieejami.",
|
||||
"title": "Fona paziņojumi",
|
||||
"devices_title": "Reģistrētās ierīces",
|
||||
"devices_desc": "Visas ierīces, kas reģistrētas šī konta fona paziņojumu saņemšanai. Atsaucot ierīci, tā nekavējoties pārstāj tās saņemt.",
|
||||
@@ -1799,7 +1800,12 @@
|
||||
"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."
|
||||
"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.",
|
||||
"error_blocked": "Paziņojumi šai vietnei ir bloķēti. Atļaujiet tos pārlūka vietnes iestatījumos un mēģiniet vēlreiz.",
|
||||
"error_not_granted": "Paziņojumi netika atļauti, tāpēc push paliek izslēgts.",
|
||||
"error_verification_timeout": "Pasta serveris laikus neapstiprināja šo ierīci, tāpēc push paliek izslēgts. Mēģiniet vēlreiz pēc minūtes.",
|
||||
"error_relay": "Push releju pašlaik nevar sasniegt, tāpēc push paliek izslēgts. Mēģiniet vēlreiz vēlāk.",
|
||||
"error_teardown": "Push šajā pārlūkā ir izslēgts, bet pasta serveri vai releju nevarēja sasniegt. Paziņojumi vēl dažas dienas var turpināt pienākt. Lai tos apturētu ātrāk, kad atkal būsiet tiešsaistē, ieslēdziet papildu iestatījumus un atsauciet šo ierīci sadaļā Reģistrētās ierīces."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
@@ -1740,6 +1740,7 @@
|
||||
"status_inactive": "Энэ төхөөрөмж дээр идэвхжээгүй",
|
||||
"status_unsupported": "Энэ хөтөч Web Push-ийг дэмждэггүй",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "Демо хувилбарт арын мэдэгдэл ашиглах боломжгүй.",
|
||||
"status_busy": "Ажиллаж байна…",
|
||||
"enable": "Идэвхжүүлэх",
|
||||
"reenable": "Дахин бүртгүүлэх",
|
||||
@@ -1766,7 +1767,12 @@
|
||||
"confirm_revoke_message_this": "Энэ төхөөрөмж тэр даруй араарх мэдэгдэл хүлээн авахаа болино.",
|
||||
"shared_mailboxes_no_push": "Бусдын тантай хуваалцсан шуудангийн хайрцгууд арын мэдэгдэл илгээдэггүй.",
|
||||
"error_forbidden": "Шуудангийн сервер энэ бүртгэлд арын мэдэгдэл зөвшөөрдөггүй.",
|
||||
"error_over_quota": "Энэ бүртгэл шуудангийн сервер дээр хэт олон push бүртгэлтэй байна (хязгаар нь ихэвчлэн 15). Мэдэгдлийн тохиргооноос ашиглахаа больсон төхөөрөмжүүдээ цуцлаад дахин оролдоно уу."
|
||||
"error_over_quota": "Энэ бүртгэл шуудангийн сервер дээр хэт олон push бүртгэлтэй байна (хязгаар нь ихэвчлэн 15). Мэдэгдлийн тохиргооноос ашиглахаа больсон төхөөрөмжүүдээ цуцлаад дахин оролдоно уу.",
|
||||
"error_blocked": "Энэ сайтын мэдэгдлийг хориглосон байна. Хөтчийнхөө сайтын тохиргооноос зөвшөөрөөд дахин оролдоно уу.",
|
||||
"error_not_granted": "Мэдэгдлийг зөвшөөрөөгүй тул push унтраалттай хэвээр байна.",
|
||||
"error_verification_timeout": "Шуудангийн сервер энэ төхөөрөмжийг цагт нь баталгаажуулаагүй тул push унтраалттай хэвээр байна. Нэг минутын дараа дахин оролдоно уу.",
|
||||
"error_relay": "Push релейд одоогоор холбогдож чадахгүй байгаа тул push унтраалттай хэвээр байна. Дараа дахин оролдоно уу.",
|
||||
"error_teardown": "Энэ хөтөч дээр push унтарсан ч шуудангийн сервер эсвэл релейд холбогдож чадсангүй. Мэдэгдэл хэдэн өдрийн турш ирсээр байж магадгүй. Эртхэн зогсоохын тулд онлайн болсныхоо дараа нарийвчилсан тохиргоог асааж, Бүртгэлтэй төхөөрөмжүүд хэсгээс энэ төхөөрөмжийг цуцална уу."
|
||||
},
|
||||
"push_all": {
|
||||
"title": "All accounts",
|
||||
|
||||
@@ -1740,6 +1740,7 @@
|
||||
"status_inactive": "Ikke aktivert på denne enheten",
|
||||
"status_unsupported": "Denne nettleseren støtter ikke Web Push",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "Bakgrunnsvarsler er ikke tilgjengelige i demoen.",
|
||||
"status_busy": "Behandler...",
|
||||
"enable": "Aktiver",
|
||||
"reenable": "Registrer på nytt",
|
||||
@@ -1766,7 +1767,12 @@
|
||||
"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."
|
||||
"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.",
|
||||
"error_blocked": "Varsler er blokkert for dette nettstedet. Tillat dem i nettleserens nettstedsinnstillinger, og prøv igjen.",
|
||||
"error_not_granted": "Varsler ble ikke tillatt, så push forblir av.",
|
||||
"error_verification_timeout": "E-postserveren bekreftet ikke denne enheten i tide, så push forblir av. Prøv igjen om et minutt.",
|
||||
"error_relay": "Push-reléet kan ikke nås akkurat nå, så push forblir av. Prøv igjen senere.",
|
||||
"error_teardown": "Push er av i denne nettleseren, men e-postserveren eller reléet kunne ikke nås. Varsler kan fortsette å komme i noen dager. For å stoppe dem tidligere, slå på avanserte innstillinger når du er på nett igjen, og tilbakekall denne enheten under Registrerte enheter."
|
||||
},
|
||||
"push_all": {
|
||||
"title": "All accounts",
|
||||
|
||||
@@ -1780,6 +1780,7 @@
|
||||
"status_inactive": "Niet ingeschakeld op dit apparaat",
|
||||
"status_unsupported": "Deze browser ondersteunt geen Web Push",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "Achtergrondmeldingen zijn niet beschikbaar in de demo.",
|
||||
"title": "Achtergrondmeldingen",
|
||||
"devices_title": "Geregistreerde apparaten",
|
||||
"devices_desc": "Alle apparaten die achtergrondmeldingen voor dit account ontvangen. Trek je er een in, dan stopt de ontvangst meteen.",
|
||||
@@ -1799,7 +1800,12 @@
|
||||
"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."
|
||||
"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.",
|
||||
"error_blocked": "Meldingen zijn geblokkeerd voor deze site. Sta ze toe in de site-instellingen van uw browser en probeer het opnieuw.",
|
||||
"error_not_granted": "Meldingen zijn niet toegestaan, dus push blijft uit.",
|
||||
"error_verification_timeout": "De mailserver heeft dit apparaat niet op tijd bevestigd, dus push blijft uit. Probeer het over een minuut opnieuw.",
|
||||
"error_relay": "De pushrelay is nu niet bereikbaar, dus push blijft uit. Probeer het later opnieuw.",
|
||||
"error_teardown": "Push staat uit in deze browser, maar de mailserver of de relay was niet bereikbaar. Er kunnen nog een paar dagen meldingen binnenkomen. Wilt u ze eerder stoppen, zet dan de geavanceerde instellingen aan zodra u weer online bent en trek dit apparaat in onder Geregistreerde apparaten."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
@@ -1780,6 +1780,7 @@
|
||||
"status_inactive": "Niewłączone na tym urządzeniu",
|
||||
"status_unsupported": "Ta przeglądarka nie obsługuje Web Push",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "Powiadomienia w tle nie są dostępne w wersji demonstracyjnej.",
|
||||
"title": "Powiadomienia w tle",
|
||||
"devices_title": "Zarejestrowane urządzenia",
|
||||
"devices_desc": "Wszystkie urządzenia zarejestrowane do odbioru powiadomień w tle dla tego konta. Unieważnienie natychmiast wstrzymuje ich odbiór.",
|
||||
@@ -1799,7 +1800,12 @@
|
||||
"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."
|
||||
"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.",
|
||||
"error_blocked": "Powiadomienia są zablokowane dla tej witryny. Zezwól na nie w ustawieniach witryny w przeglądarce i spróbuj ponownie.",
|
||||
"error_not_granted": "Nie zezwolono na powiadomienia, więc push pozostaje wyłączony.",
|
||||
"error_verification_timeout": "Serwer poczty nie potwierdził tego urządzenia na czas, więc push pozostaje wyłączony. Spróbuj ponownie za minutę.",
|
||||
"error_relay": "Przekaźnik push jest teraz niedostępny, więc push pozostaje wyłączony. Spróbuj ponownie później.",
|
||||
"error_teardown": "Push jest wyłączony w tej przeglądarce, ale serwer poczty lub przekaźnik był niedostępny. Powiadomienia mogą przychodzić jeszcze przez kilka dni. Aby zatrzymać je wcześniej, gdy znów będziesz online, włącz ustawienia zaawansowane i unieważnij to urządzenie w sekcji Zarejestrowane urządzenia."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
@@ -1780,6 +1780,7 @@
|
||||
"status_inactive": "Não ativado neste dispositivo",
|
||||
"status_unsupported": "Este navegador não oferece suporte a Web Push",
|
||||
"status_lite": "O Web Push não está disponível no Bulwark Lite; ele requer o servidor do Bulwark.",
|
||||
"status_demo": "As notificações em segundo plano não estão disponíveis na demonstração.",
|
||||
"title": "Notificações em segundo plano",
|
||||
"devices_title": "Dispositivos registrados",
|
||||
"devices_desc": "Todos os dispositivos registrados para receber notificações em segundo plano desta conta. Revogar um deles interrompe o recebimento imediatamente.",
|
||||
@@ -1799,7 +1800,12 @@
|
||||
"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."
|
||||
"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.",
|
||||
"error_blocked": "As notificações estão bloqueadas para este site. Permita-as nas definições do site do navegador e tente novamente.",
|
||||
"error_not_granted": "As notificações não foram permitidas, por isso as notificações push continuam desligadas.",
|
||||
"error_verification_timeout": "O servidor de correio não confirmou este dispositivo a tempo, por isso as notificações push continuam desligadas. Tente novamente dentro de um minuto.",
|
||||
"error_relay": "Não é possível contactar o retransmissor de notificações neste momento, por isso as notificações push continuam desligadas. Tente mais tarde.",
|
||||
"error_teardown": "As notificações push estão desligadas neste navegador, mas não foi possível contactar o servidor de correio ou o retransmissor. Podem continuar a chegar notificações durante alguns dias. Para as parar mais cedo, ligue as definições avançadas quando voltar a estar online e revogue este dispositivo em Dispositivos registados."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
@@ -1740,6 +1740,7 @@
|
||||
"status_inactive": "Nu este activat pe acest dispozitiv",
|
||||
"status_unsupported": "Acest browser nu acceptă Web Push",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "Notificările în fundal nu sunt disponibile în demonstrație.",
|
||||
"status_busy": "Se lucrează…",
|
||||
"enable": "Activați",
|
||||
"reenable": "Reînregistrează-te",
|
||||
@@ -1766,7 +1767,12 @@
|
||||
"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."
|
||||
"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.",
|
||||
"error_blocked": "Notificările sunt blocate pentru acest site. Permite-le în setările site-ului din browser, apoi încearcă din nou.",
|
||||
"error_not_granted": "Notificările nu au fost permise, deci push rămâne dezactivat.",
|
||||
"error_verification_timeout": "Serverul de e-mail nu a confirmat la timp acest dispozitiv, deci push rămâne dezactivat. Încearcă din nou peste un minut.",
|
||||
"error_relay": "Releul push nu poate fi accesat acum, deci push rămâne dezactivat. Încearcă din nou mai târziu.",
|
||||
"error_teardown": "Push este dezactivat în acest browser, dar serverul de e-mail sau releul nu au putut fi accesate. Notificările pot continua să sosească câteva zile. Pentru a le opri mai devreme, când ești din nou online activează setările avansate și revocă acest dispozitiv la Dispozitive înregistrate."
|
||||
},
|
||||
"push_all": {
|
||||
"title": "All accounts",
|
||||
|
||||
@@ -1780,6 +1780,7 @@
|
||||
"status_inactive": "Не включено на этом устройстве",
|
||||
"status_unsupported": "Этот браузер не поддерживает Web Push",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "Фоновые уведомления недоступны в демоверсии.",
|
||||
"title": "Фоновые уведомления",
|
||||
"devices_title": "Зарегистрированные устройства",
|
||||
"devices_desc": "Все устройства, зарегистрированные для получения фоновых уведомлений этой учётной записи. После отзыва устройство сразу перестаёт их получать.",
|
||||
@@ -1799,7 +1800,12 @@
|
||||
"confirm_revoke_message_this": "Это устройство сразу перестанет получать фоновые уведомления.",
|
||||
"shared_mailboxes_no_push": "Почтовые ящики, к которым вам открыли доступ другие пользователи, не присылают фоновые уведомления.",
|
||||
"error_forbidden": "Почтовый сервер не разрешает фоновые уведомления для этой учётной записи.",
|
||||
"error_over_quota": "У этой учётной записи слишком много push-регистраций на почтовом сервере (обычно разрешено 15). Отзовите неиспользуемые устройства в настройках уведомлений и повторите попытку."
|
||||
"error_over_quota": "У этой учётной записи слишком много push-регистраций на почтовом сервере (обычно разрешено 15). Отзовите неиспользуемые устройства в настройках уведомлений и повторите попытку.",
|
||||
"error_blocked": "Уведомления для этого сайта заблокированы. Разрешите их в настройках сайта в браузере и попробуйте снова.",
|
||||
"error_not_granted": "Уведомления не разрешены, поэтому push остаётся выключенным.",
|
||||
"error_verification_timeout": "Почтовый сервер не подтвердил это устройство вовремя, поэтому push остаётся выключенным. Попробуйте снова через минуту.",
|
||||
"error_relay": "Push-ретранслятор сейчас недоступен, поэтому push остаётся выключенным. Попробуйте позже.",
|
||||
"error_teardown": "Push в этом браузере выключен, но почтовый сервер или ретранслятор были недоступны. Уведомления могут приходить ещё несколько дней. Чтобы остановить их раньше, когда снова будете в сети, включите расширенные настройки и отзовите это устройство в разделе «Зарегистрированные устройства»."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
@@ -1776,6 +1776,7 @@
|
||||
"status_inactive": "Nie je povolené na tomto zariadení",
|
||||
"status_unsupported": "Tento prehliadač nepodporuje Web Push",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "Oznámenia na pozadí nie sú v ukážke k dispozícii.",
|
||||
"status_busy": "Pracujem…",
|
||||
"enable": "Povoliť",
|
||||
"reenable": "Znovu zaregistrovať",
|
||||
@@ -1802,7 +1803,12 @@
|
||||
"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."
|
||||
"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.",
|
||||
"error_blocked": "Oznámenia sú pre túto stránku zablokované. Povoľte ich v nastaveniach stránky v prehliadači a skúste to znova.",
|
||||
"error_not_granted": "Oznámenia neboli povolené, takže push zostáva vypnutý.",
|
||||
"error_verification_timeout": "Poštový server toto zariadenie včas nepotvrdil, takže push zostáva vypnutý. Skúste to znova o minútu.",
|
||||
"error_relay": "Push relay teraz nie je dostupné, takže push zostáva vypnutý. Skúste to neskôr.",
|
||||
"error_teardown": "Push je v tomto prehliadači vypnutý, ale poštový server alebo relay neboli dostupné. Oznámenia môžu ešte niekoľko dní prichádzať. Ak ich chcete zastaviť skôr, keď budete online, zapnite rozšírené nastavenia a odvolajte toto zariadenie v časti Registrované zariadenia."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
@@ -4636,6 +4636,7 @@
|
||||
"status_inactive": "ไม่ได้เปิดใช้งานบนอุปกรณ์นี้",
|
||||
"status_unsupported": "เบราว์เซอร์นี้ไม่รองรับ Web Push",
|
||||
"status_lite": "Web Push ไม่พร้อมใช้งานใน Bulwark Lite จำเป็นต้องใช้ Bulwark server",
|
||||
"status_demo": "การแจ้งเตือนในพื้นหลังไม่พร้อมใช้งานในเดโม",
|
||||
"status_busy": "กำลังดำเนินการ…",
|
||||
"enable": "เปิดใช้งาน",
|
||||
"reenable": "ลงทะเบียนใหม่",
|
||||
@@ -4662,7 +4663,12 @@
|
||||
"confirm_revoke_message_this": "อุปกรณ์นี้จะหยุดรับการแจ้งเตือนเบื้องหลังทันที",
|
||||
"shared_mailboxes_no_push": "กล่องข้อความที่ผู้อื่นแชร์กับคุณจะไม่ส่งการแจ้งเตือนเบื้องหลัง",
|
||||
"error_forbidden": "เมลเซิร์ฟเวอร์ไม่อนุญาตการแจ้งเตือนเบื้องหลังสำหรับบัญชีนี้",
|
||||
"error_over_quota": "บัญชีนี้มีการลงทะเบียนพุชบนเมลเซิร์ฟเวอร์มากเกินไป (ขีดจำกัดปกติคือ 15 อุปกรณ์) กรุณาเพิกถอนอุปกรณ์ที่ไม่ได้ใช้งานแล้วในการตั้งค่าการแจ้งเตือน แล้วลองใหม่อีกครั้ง"
|
||||
"error_over_quota": "บัญชีนี้มีการลงทะเบียนพุชบนเมลเซิร์ฟเวอร์มากเกินไป (ขีดจำกัดปกติคือ 15 อุปกรณ์) กรุณาเพิกถอนอุปกรณ์ที่ไม่ได้ใช้งานแล้วในการตั้งค่าการแจ้งเตือน แล้วลองใหม่อีกครั้ง",
|
||||
"error_blocked": "การแจ้งเตือนถูกบล็อกสำหรับเว็บไซต์นี้ อนุญาตในการตั้งค่าเว็บไซต์ของเบราว์เซอร์ แล้วลองอีกครั้ง",
|
||||
"error_not_granted": "ไม่ได้อนุญาตการแจ้งเตือน พุชจึงยังปิดอยู่",
|
||||
"error_verification_timeout": "เซิร์ฟเวอร์อีเมลไม่ยืนยันอุปกรณ์นี้ทันเวลา พุชจึงยังปิดอยู่ ลองอีกครั้งในอีกหนึ่งนาที",
|
||||
"error_relay": "ไม่สามารถเชื่อมต่อรีเลย์พุชได้ในขณะนี้ พุชจึงยังปิดอยู่ ลองอีกครั้งภายหลัง",
|
||||
"error_teardown": "ปิดพุชในเบราว์เซอร์นี้แล้ว แต่ไม่สามารถเชื่อมต่อเซิร์ฟเวอร์อีเมลหรือรีเลย์ได้ อาจยังได้รับการแจ้งเตือนอีกสองสามวัน หากต้องการหยุดเร็วขึ้น เมื่อกลับมาออนไลน์ให้เปิดการตั้งค่าขั้นสูงและเพิกถอนอุปกรณ์นี้ในอุปกรณ์ที่ลงทะเบียน"
|
||||
},
|
||||
"push_all": {
|
||||
"title": "ทุกบัญชี",
|
||||
|
||||
@@ -1780,6 +1780,7 @@
|
||||
"status_inactive": "Bu cihazda etkinleştirilmedi",
|
||||
"status_unsupported": "Bu tarayıcı Web Push'u desteklemiyor",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "Arka plan bildirimleri demoda kullanılamaz.",
|
||||
"title": "Arka plan bildirimleri",
|
||||
"devices_title": "Kayıtlı cihazlar",
|
||||
"devices_desc": "Bu hesap için arka plan bildirimleri almak üzere kayıtlı tüm cihazlar. Birini iptal ettiğinizde o cihaz bildirimleri hemen almayı bırakır.",
|
||||
@@ -1799,7 +1800,12 @@
|
||||
"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."
|
||||
"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.",
|
||||
"error_blocked": "Bu site için bildirimler engellendi. Tarayıcınızın site ayarlarından izin verin ve yeniden deneyin.",
|
||||
"error_not_granted": "Bildirimlere izin verilmedi, bu yüzden anlık bildirim kapalı kalıyor.",
|
||||
"error_verification_timeout": "Posta sunucusu bu cihazı zamanında onaylamadı, bu yüzden anlık bildirim kapalı kalıyor. Bir dakika sonra yeniden deneyin.",
|
||||
"error_relay": "Anlık bildirim aktarıcısına şu anda ulaşılamıyor, bu yüzden anlık bildirim kapalı kalıyor. Daha sonra yeniden deneyin.",
|
||||
"error_teardown": "Anlık bildirim bu tarayıcıda kapatıldı, ancak posta sunucusuna veya aktarıcıya ulaşılamadı. Bildirimler birkaç gün daha gelebilir. Daha erken durdurmak için yeniden çevrimiçi olduğunuzda gelişmiş ayarları açın ve bu cihazı Kayıtlı cihazlar bölümünden iptal edin."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
@@ -1780,6 +1780,7 @@
|
||||
"status_inactive": "Не ввімкнено на цьому пристрої",
|
||||
"status_unsupported": "Цей браузер не підтримує Web Push",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "Фонові сповіщення недоступні в демоверсії.",
|
||||
"title": "Фонові сповіщення",
|
||||
"devices_title": "Зареєстровані пристрої",
|
||||
"devices_desc": "Усі пристрої, зареєстровані для отримання фонових сповіщень цього облікового запису. Після відкликання пристрій одразу перестає їх отримувати.",
|
||||
@@ -1799,7 +1800,12 @@
|
||||
"confirm_revoke_message_this": "Цей пристрій одразу перестане отримувати фонові сповіщення.",
|
||||
"shared_mailboxes_no_push": "Поштові скриньки, до яких вам надали доступ інші користувачі, не надсилають фонових сповіщень.",
|
||||
"error_forbidden": "Поштовий сервер не дозволяє фонові сповіщення для цього облікового запису.",
|
||||
"error_over_quota": "Цей обліковий запис має забагато push-реєстрацій на поштовому сервері (зазвичай дозволено 15). Відкличте в налаштуваннях сповіщень пристрої, якими ви більше не користуєтеся, і спробуйте ще раз."
|
||||
"error_over_quota": "Цей обліковий запис має забагато push-реєстрацій на поштовому сервері (зазвичай дозволено 15). Відкличте в налаштуваннях сповіщень пристрої, якими ви більше не користуєтеся, і спробуйте ще раз.",
|
||||
"error_blocked": "Сповіщення для цього сайту заблоковано. Дозвольте їх у налаштуваннях сайту в браузері та спробуйте ще раз.",
|
||||
"error_not_granted": "Сповіщення не дозволено, тому push залишається вимкненим.",
|
||||
"error_verification_timeout": "Поштовий сервер не підтвердив цей пристрій вчасно, тому push залишається вимкненим. Спробуйте ще раз за хвилину.",
|
||||
"error_relay": "Push-ретранслятор зараз недоступний, тому push залишається вимкненим. Спробуйте пізніше.",
|
||||
"error_teardown": "Push у цьому браузері вимкнено, але поштовий сервер або ретранслятор були недоступні. Сповіщення можуть надходити ще кілька днів. Щоб зупинити їх раніше, коли знову будете онлайн, увімкніть розширені налаштування й відкличте цей пристрій у розділі «Зареєстровані пристрої»."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
@@ -1752,6 +1752,7 @@
|
||||
"status_inactive": "Chưa bật trên thiết bị này",
|
||||
"status_unsupported": "Trình duyệt này không hỗ trợ Web Push",
|
||||
"status_lite": "Bulwark Lite không hỗ trợ Web Push; tính năng này cần máy chủ Bulwark.",
|
||||
"status_demo": "Thông báo nền không khả dụng trong bản demo.",
|
||||
"status_busy": "Đang xử lý…",
|
||||
"enable": "Bật",
|
||||
"reenable": "Đăng ký lại",
|
||||
@@ -1778,7 +1779,12 @@
|
||||
"confirm_revoke_message_this": "Thiết bị này sẽ ngừng nhận thông báo chạy nền ngay lập tức.",
|
||||
"shared_mailboxes_no_push": "Hộp thư được người khác chia sẻ với bạn không gửi thông báo chạy nền.",
|
||||
"error_forbidden": "Máy chủ thư không cho phép bật thông báo chạy nền cho tài khoản này.",
|
||||
"error_over_quota": "Tài khoản này có quá nhiều lượt đăng ký push trên máy chủ thư (giới hạn thường là 15). Hãy thu hồi các thiết bị không còn dùng trong phần cài đặt thông báo rồi thử lại."
|
||||
"error_over_quota": "Tài khoản này có quá nhiều lượt đăng ký push trên máy chủ thư (giới hạn thường là 15). Hãy thu hồi các thiết bị không còn dùng trong phần cài đặt thông báo rồi thử lại.",
|
||||
"error_blocked": "Thông báo đang bị chặn cho trang này. Hãy cho phép trong cài đặt trang của trình duyệt rồi thử lại.",
|
||||
"error_not_granted": "Thông báo không được cho phép nên push vẫn tắt.",
|
||||
"error_verification_timeout": "Máy chủ thư không xác nhận thiết bị này kịp thời nên push vẫn tắt. Hãy thử lại sau một phút.",
|
||||
"error_relay": "Hiện không thể kết nối với relay push nên push vẫn tắt. Hãy thử lại sau.",
|
||||
"error_teardown": "Push đã tắt trong trình duyệt này nhưng không thể kết nối với máy chủ thư hoặc relay. Thông báo có thể tiếp tục đến trong vài ngày. Để dừng sớm hơn, khi có mạng trở lại hãy bật cài đặt nâng cao và thu hồi thiết bị này trong Thiết bị đã đăng ký."
|
||||
},
|
||||
"push_all": {
|
||||
"title": "Tất cả tài khoản",
|
||||
|
||||
@@ -1740,6 +1740,7 @@
|
||||
"status_inactive": "在此裝置上未啟用",
|
||||
"status_unsupported": "此瀏覽器不支援 Web Push",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "展示版不提供背景通知。",
|
||||
"status_busy": "工作中…",
|
||||
"enable": "啟用",
|
||||
"reenable": "重新註冊",
|
||||
@@ -1766,7 +1767,12 @@
|
||||
"confirm_revoke_message_this": "此裝置會立即停止接收背景通知。",
|
||||
"shared_mailboxes_no_push": "其他人與您共用的信箱不會傳送背景通知。",
|
||||
"error_forbidden": "郵件伺服器不允許此帳號使用背景通知。",
|
||||
"error_over_quota": "此帳號在郵件伺服器上的推送註冊過多(上限通常為 15 個)。請在通知設定中撤銷不再使用的裝置,然後再試一次。"
|
||||
"error_over_quota": "此帳號在郵件伺服器上的推送註冊過多(上限通常為 15 個)。請在通知設定中撤銷不再使用的裝置,然後再試一次。",
|
||||
"error_blocked": "此網站的通知已被封鎖。請在瀏覽器的網站設定中允許通知,然後再試一次。",
|
||||
"error_not_granted": "未允許通知,因此推播保持關閉。",
|
||||
"error_verification_timeout": "郵件伺服器未及時確認此裝置,因此推播保持關閉。請一分鐘後再試一次。",
|
||||
"error_relay": "目前無法連線到推播中繼,因此推播保持關閉。請稍後再試。",
|
||||
"error_teardown": "此瀏覽器的推播已關閉,但無法連線到郵件伺服器或中繼。通知可能還會持續幾天。如需盡快停止,請在恢復連線後開啟進階設定,並在已註冊裝置中撤銷此裝置。"
|
||||
},
|
||||
"push_all": {
|
||||
"title": "All accounts",
|
||||
|
||||
@@ -1780,6 +1780,7 @@
|
||||
"status_inactive": "在此设备上未启用",
|
||||
"status_unsupported": "此浏览器不支持 Web Push",
|
||||
"status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.",
|
||||
"status_demo": "演示版不提供后台通知。",
|
||||
"title": "后台通知",
|
||||
"devices_title": "已注册的设备",
|
||||
"devices_desc": "已注册接收此账户后台通知的所有设备。撤销后,该设备会立即停止接收。",
|
||||
@@ -1799,7 +1800,12 @@
|
||||
"confirm_revoke_message_this": "此设备将立即停止接收后台通知。",
|
||||
"shared_mailboxes_no_push": "他人与您共享的邮箱不会发送后台通知。",
|
||||
"error_forbidden": "邮件服务器不允许此账户使用后台通知。",
|
||||
"error_over_quota": "此账户在邮件服务器上的推送注册过多(上限通常为 15 个)。请在通知设置中撤销不再使用的设备,然后重试。"
|
||||
"error_over_quota": "此账户在邮件服务器上的推送注册过多(上限通常为 15 个)。请在通知设置中撤销不再使用的设备,然后重试。",
|
||||
"error_blocked": "此网站的通知已被阻止。请在浏览器的网站设置中允许通知,然后重试。",
|
||||
"error_not_granted": "未允许通知,因此推送保持关闭。",
|
||||
"error_verification_timeout": "邮件服务器未及时确认此设备,因此推送保持关闭。请一分钟后重试。",
|
||||
"error_relay": "目前无法连接推送中继,因此推送保持关闭。请稍后重试。",
|
||||
"error_teardown": "此浏览器的推送已关闭,但无法连接邮件服务器或中继。通知可能还会持续几天。如需尽快停止,请在恢复联网后打开高级设置,并在已注册设备中撤销此设备。"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
+179
-29
@@ -589,17 +589,40 @@ async function handlePush(event) {
|
||||
// message ids that passed it - spam filed into Junk never gets here.
|
||||
// - "jmap-state-change" (older servers): a bare EmailDelivery ping wrapped
|
||||
// as { changed: { [accountId]: {...} } }, fired for every ingested
|
||||
// message including junk. The relay forwards a single account per push,
|
||||
// so the first key is the one this notification is for.
|
||||
// message including junk. Stalwart batches what changes between two
|
||||
// pushes, so it can name the user's own account and a group's at once.
|
||||
const changed = payload && payload.changed && typeof payload.changed === "object"
|
||||
? payload.changed
|
||||
: null;
|
||||
const accountId = (payload && typeof payload.accountId === "string" && payload.accountId)
|
||||
const named = (payload && typeof payload.accountId === "string" && payload.accountId)
|
||||
|| (changed ? Object.keys(changed)[0] || "" : "");
|
||||
const emailIds = payload && Array.isArray(payload.emailIds)
|
||||
? payload.emailIds.filter((id) => typeof id === "string" && id)
|
||||
: [];
|
||||
|
||||
const withNewMail = emailIds.length > 0 || !changed
|
||||
? []
|
||||
: Object.keys(changed).filter((id) => changed[id] && typeof changed[id] === "object" && "EmailDelivery" in changed[id]);
|
||||
// Side by side: each has its own preview deadline, and Safari wants the
|
||||
// notifications within 30 s of the push.
|
||||
await Promise.all((withNewMail.length > 0 ? withNewMail : [named]).map(async (accountId) => {
|
||||
try {
|
||||
await notifyAccount(accountId, emailIds, accountLabel);
|
||||
} catch (_) {
|
||||
// Nothing above may cost the user the alert, or a browser its quota for
|
||||
// this site: see showWithoutAlert.
|
||||
await self.registration.showNotification(accountLabel ? `New mail (${accountLabel})` : "New mail", {
|
||||
body: "You have new mail",
|
||||
tag: "bulwark-mail:" + (accountId || "default"),
|
||||
icon: `${BASE_PATH}/api/pwa-icon/192`,
|
||||
badge: `${BASE_PATH}/api/pwa-icon/192`,
|
||||
data: { kind: "mail-list", accountId },
|
||||
});
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async function notifyAccount(accountId, emailIds, accountLabel) {
|
||||
// Never notify twice for the same message. A push can be redelivered (relay
|
||||
// retry, browser replay after coming back online) and, on the older
|
||||
// state-change path, a junk delivery wakes us while an unread message we
|
||||
@@ -613,6 +636,14 @@ async function handlePush(event) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Email notifications off, or do not disturb on: a browser still wants a
|
||||
// notification for the push, so one comes and goes with nothing on it.
|
||||
if (!(await showPreference(accountLabel))) {
|
||||
if (freshIds.length > 0) await writeNotifiedIds(accountId, notified.concat(freshIds));
|
||||
await showWithoutAlert(groupTag);
|
||||
return;
|
||||
}
|
||||
|
||||
// Best effort: ask the webmail to look up the email so we can build a useful
|
||||
// notification. With ids from the server we ask for exactly the newest of
|
||||
// them; otherwise the API falls back to the newest unread Inbox message. If
|
||||
@@ -625,6 +656,9 @@ async function handlePush(event) {
|
||||
if (!lockHidesContent) try {
|
||||
const query = new URLSearchParams();
|
||||
if (accountId) query.set("accountId", accountId);
|
||||
// The relay record's label is the login that registered. Account and message
|
||||
// ids repeat across logins, and this tells them apart.
|
||||
if (accountLabel) query.set("login", accountLabel);
|
||||
if (freshIds.length > 0) query.set("emailId", freshIds[freshIds.length - 1]);
|
||||
const qs = query.toString();
|
||||
const previewUrl = `${BASE_PATH}/api/push/preview${qs ? `?${qs}` : ""}`;
|
||||
@@ -684,31 +718,36 @@ async function handlePush(event) {
|
||||
// into a single, self-updating notification instead of one toast per message
|
||||
// (Android renders one notification per unique tag, which is why 50 arrivals
|
||||
// used to stack 50 toasts). The newest message is the headline and a
|
||||
// Gmail-style "+N more" line carries the rest, counted from the account's
|
||||
// unread total.
|
||||
// Gmail-style "+N more" line carries the rest: the messages this notification
|
||||
// has announced and the user hasn't dismissed. Mail that was unread before
|
||||
// doesn't count, or one new message in a full inbox would say "+40 more" and
|
||||
// open the inbox instead of itself.
|
||||
const announced = freshIds.length > 0 ? freshIds : (email ? [email.id] : []);
|
||||
const ids = Array.from(new Set((await idsOnScreen(groupTag)).concat(announced))).slice(-NOTIFICATION_IDS_LIMIT);
|
||||
const more = Math.max(ids.length - 1, 0);
|
||||
const oneMessage = email ? { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot, login, ids } : null;
|
||||
let title;
|
||||
let body;
|
||||
let data = { kind: "mail-list", accountId, slot };
|
||||
let data = { kind: "mail-list", accountId, slot, ids };
|
||||
|
||||
if (email && preview && preview.redacted) {
|
||||
// The organisation keeps sender and subject off notifications
|
||||
// (Organisation > Devices): the route sent neither.
|
||||
title = accountLabel ? `New mail (${accountLabel})` : "New mail";
|
||||
body = unreadTotal > 1 ? `${unreadTotal} unread messages` : "You have new mail";
|
||||
if (unreadTotal <= 1) data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot, login };
|
||||
body = more > 0 ? `${ids.length} new messages` : "You have new mail";
|
||||
if (more === 0) data = oneMessage;
|
||||
} else if (email) {
|
||||
const sender = email.from && email.from[0];
|
||||
const senderName = (sender && sender.name) || (sender && sender.email) || "New mail";
|
||||
title = senderName + (accountLabel ? ` (${accountLabel})` : "");
|
||||
body = email.subject || email.preview || "(no subject)";
|
||||
const more = unreadTotal > 1 ? unreadTotal - 1 : 0;
|
||||
if (more > 0) {
|
||||
// Several unread: this is a group. Keep the newest as the headline, add
|
||||
// the "+N more" count, and open the inbox (not one message) on click.
|
||||
// Several new: this is a group. Keep the newest as the headline, add the
|
||||
// "+N more" count, and open the inbox (not one message) on click.
|
||||
body += "\n" + (more === 1 ? "+1 more message" : `+${more} more messages`);
|
||||
} else {
|
||||
// Exactly one unread: deep-link straight to that message on click.
|
||||
data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot, login };
|
||||
// One new message: deep-link straight to it on click.
|
||||
data = oneMessage;
|
||||
}
|
||||
} else {
|
||||
title = accountLabel ? `New mail (${accountLabel})` : "New mail";
|
||||
@@ -733,12 +772,26 @@ async function handlePush(event) {
|
||||
silent: !audible,
|
||||
});
|
||||
|
||||
const announced = freshIds.length > 0 ? freshIds : (email ? [email.id] : []);
|
||||
if (announced.length > 0) {
|
||||
await writeNotifiedIds(accountId, notified.concat(announced));
|
||||
}
|
||||
}
|
||||
|
||||
const NOTIFICATION_IDS_LIMIT = 100;
|
||||
|
||||
/** The messages the account's notification on screen stands for. */
|
||||
async function idsOnScreen(groupTag) {
|
||||
try {
|
||||
const shown = (await self.registration.getNotifications({ tag: groupTag }))[0];
|
||||
const data = shown && shown.data;
|
||||
if (!data) return [];
|
||||
if (Array.isArray(data.ids)) return data.ids.filter((id) => typeof id === "string");
|
||||
return typeof data.emailId === "string" ? [data.emailId] : [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// How long the preview may take before the generic notification is shown.
|
||||
const PREVIEW_DEADLINE_MS = 10_000;
|
||||
|
||||
@@ -752,24 +805,32 @@ const PREVIEW_DEADLINE_MS = 10_000;
|
||||
*/
|
||||
async function showWithoutAlert(groupTag) {
|
||||
const options = {
|
||||
tag: groupTag,
|
||||
icon: `${BASE_PATH}/api/pwa-icon/192`,
|
||||
badge: `${BASE_PATH}/api/pwa-icon/192`,
|
||||
renotify: false,
|
||||
silent: true,
|
||||
};
|
||||
let existing = null;
|
||||
try {
|
||||
existing = (await self.registration.getNotifications({ tag: groupTag }))[0] || null;
|
||||
} catch (_) {
|
||||
// Can't tell: treated as none on screen.
|
||||
}
|
||||
try {
|
||||
const existing = (await self.registration.getNotifications({ tag: groupTag }))[0];
|
||||
if (existing) {
|
||||
await showWithActions(existing.title, {
|
||||
...options,
|
||||
tag: groupTag,
|
||||
body: existing.body,
|
||||
data: existing.data,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await self.registration.showNotification("New mail", { ...options, data: { kind: "mail-list" } });
|
||||
const shown = await self.registration.getNotifications({ tag: groupTag });
|
||||
// Under a tag of its own: a real notification for this account that comes in
|
||||
// at the same moment must neither be replaced by this nor closed with it.
|
||||
const emptyTag = `${groupTag}:nothing-new`;
|
||||
await self.registration.showNotification("New mail", { ...options, tag: emptyTag, data: { kind: "mail-list" } });
|
||||
const shown = await self.registration.getNotifications({ tag: emptyTag });
|
||||
for (const notification of shown) notification.close();
|
||||
} catch (_) {
|
||||
// Nothing more a worker can do.
|
||||
@@ -852,6 +913,23 @@ async function soundPreference() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Email notifications" switch and do not disturb. Each login has its own
|
||||
* settings, so the page leaves one answer per login (the push's label names it).
|
||||
* Nothing stored for it: show.
|
||||
*/
|
||||
async function showPreference(accountLabel) {
|
||||
if (!accountLabel) return true;
|
||||
try {
|
||||
const cache = await caches.open(PUSH_STATE_CACHE);
|
||||
const res = await cache.match(`${self.location.origin}${BASE_PATH}/__push-state/quiet-logins`);
|
||||
const quiet = res ? await res.json() : null;
|
||||
return !quiet || quiet[accountLabel] !== true;
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when another account alerted audibly less than QUIET_WINDOW_MS ago; otherwise records this one. */
|
||||
async function withinQuietWindow(accountId) {
|
||||
const now = Date.now();
|
||||
@@ -912,20 +990,73 @@ async function writeNotifiedIds(accountId, ids) {
|
||||
}
|
||||
}
|
||||
|
||||
/** What the page left here: lib/pwa-notify/worker-state.ts. */
|
||||
async function readRelayRegistrations() {
|
||||
try {
|
||||
const cache = await caches.open(PUSH_STATE_CACHE);
|
||||
const res = await cache.match(`${self.location.origin}${BASE_PATH}/__push-state/relay-registrations`);
|
||||
const stored = res ? await res.json() : null;
|
||||
return stored && stored.entries && typeof stored.entries === "object" ? stored : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** No-third-party mode may have come on since the page left a relay here. Unknown counts as no. */
|
||||
async function mayCallRelays() {
|
||||
try {
|
||||
const res = await fetch(`${BASE_PATH}/api/config`, { cache: "no-store" });
|
||||
if (!res.ok) return false;
|
||||
const config = await res.json();
|
||||
return config.noThirdPartyTraffic !== true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function applicationServerKeyOf(base64Url) {
|
||||
const padded = base64Url + "=".repeat((4 - (base64Url.length % 4)) % 4);
|
||||
const raw = atob(padded.replace(/-/g, "+").replace(/_/g, "/"));
|
||||
return Uint8Array.from(raw, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
// The push service dropped or replaced the browser's subscription (expiry,
|
||||
// a key rotation, revoked permission). Re-subscribe with the same relay key
|
||||
// unless the browser already did, then tell the open tabs: only a page has
|
||||
// the login needed to register the new endpoint with the relay and the mail
|
||||
// server (resyncWebPush in lib/web-push.ts). With no tab open, the next page
|
||||
// load's re-sync picks the new subscription up.
|
||||
// unless the browser already did. The mail server's subscription points at the
|
||||
// relay record, not at the endpoint, so telling the relay is all it takes, and
|
||||
// that needs no login: push is back with no tab open. Open tabs hear of it too,
|
||||
// for what only a page can do (resyncWebPush in lib/web-push.ts).
|
||||
async function handlePushSubscriptionChange(event) {
|
||||
const old = event.oldSubscription;
|
||||
if (!event.newSubscription && old && old.options) {
|
||||
try {
|
||||
await self.registration.pushManager.subscribe(old.options);
|
||||
} catch (_) {
|
||||
// Permission revoked or push service unreachable: the page's re-sync
|
||||
// subscribes again itself when it can.
|
||||
const registrations = await readRelayRegistrations();
|
||||
let subscription = event.newSubscription || null;
|
||||
if (!subscription) {
|
||||
const options = old && old.options
|
||||
? old.options
|
||||
: registrations && registrations.applicationServerKey
|
||||
? { userVisibleOnly: true, applicationServerKey: applicationServerKeyOf(registrations.applicationServerKey) }
|
||||
: null;
|
||||
if (options) {
|
||||
try {
|
||||
subscription = await self.registration.pushManager.subscribe(options);
|
||||
} catch (_) {
|
||||
// Permission revoked or push service unreachable: the page's re-sync
|
||||
// subscribes again itself when it can.
|
||||
}
|
||||
}
|
||||
}
|
||||
if (subscription && registrations && typeof subscription.toJSON === "function" && (await mayCallRelays())) {
|
||||
const json = subscription.toJSON();
|
||||
for (const entry of Object.values(registrations.entries)) {
|
||||
try {
|
||||
await fetch(`${entry.relay}/api/push/register/web`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ subscriptionId: entry.subscriptionId, subscription: json, accountLabel: entry.accountLabel }),
|
||||
});
|
||||
} catch (_) {
|
||||
// The page's re-sync registers it when it next runs.
|
||||
}
|
||||
}
|
||||
}
|
||||
const allClients = await self.clients.matchAll({
|
||||
@@ -973,7 +1104,7 @@ async function handleNotificationClick(event) {
|
||||
if (action === "reply") targetUrl = buildClickUrl(data, true);
|
||||
}
|
||||
|
||||
for (const client of allClients) {
|
||||
for (const client of windowsForClick(allClients)) {
|
||||
// Reuse an existing tab whenever possible - users on desktop browsers
|
||||
// get annoyed when each notification opens a fresh window.
|
||||
if ("focus" in client) {
|
||||
@@ -1014,6 +1145,25 @@ async function handleNotificationClick(event) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The windows a click may take over, the one in front first. Not a frame inside
|
||||
* a page, and not a pop-out or a protocol page: navigating one of those would
|
||||
* lose what it shows and leave the message in a window without the app around it.
|
||||
*/
|
||||
function windowsForClick(allClients) {
|
||||
const score = (client) => (client.focused ? 0 : client.visibilityState === "visible" ? 1 : 2);
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
if (client.frameType && client.frameType !== "top-level") return false;
|
||||
try {
|
||||
return !/\/(popout|protocol)(\/|$)/.test(new URL(client.url).pathname);
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
.sort((a, b) => score(a) - score(b));
|
||||
}
|
||||
|
||||
async function focusExistingWindowClient(sourceClientId, requireMailtoReady) {
|
||||
const entry = await findReusableWindowClientEntry(sourceClientId, requireMailtoReady);
|
||||
const client = entry && entry.client;
|
||||
|
||||
@@ -25,7 +25,7 @@ const TRACE_ALLOWED = new Set(['node_modules', '.next', 'public', 'locales', 'pa
|
||||
|
||||
/** Never in a standalone tree, wherever it sits outside node_modules. */
|
||||
const FORBIDDEN_DIRS = new Set(['data', 'local-data', 'repos']);
|
||||
const FORBIDDEN_FILE = /^(\.env.*|plugin-signing\.key)$/;
|
||||
const FORBIDDEN_FILE = /^(\.env.*|plugin-signing\.key)$/i;
|
||||
|
||||
function toPosix(p) {
|
||||
return p.split(path.sep).join('/');
|
||||
@@ -106,10 +106,13 @@ export function findForbidden(standaloneDir) {
|
||||
const visit = (dir) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.isSymbolicLink()) {
|
||||
// Not .next's or a package's, which aren't visited: Turbopack links those on purpose.
|
||||
if (entry.name !== 'node_modules' && entry.name !== '.next') found.push(`${toPosix(path.relative(standaloneDir, full))} (link)`);
|
||||
} else if (entry.isDirectory()) {
|
||||
// Packages may hold anything, and .next/server has routes named after data.
|
||||
if (entry.name === 'node_modules' || entry.name === '.next') continue;
|
||||
if (FORBIDDEN_DIRS.has(entry.name)) found.push(toPosix(path.relative(standaloneDir, full)) + '/');
|
||||
if (FORBIDDEN_DIRS.has(entry.name.toLowerCase())) found.push(toPosix(path.relative(standaloneDir, full)) + '/');
|
||||
else visit(full);
|
||||
} else if (FORBIDDEN_FILE.test(entry.name)) {
|
||||
found.push(toPosix(path.relative(standaloneDir, full)));
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const endWebPushForSignOut = vi.hoisted(() => vi.fn(async (_client: unknown) => {}));
|
||||
const dropAllWebPushRegistrations = vi.hoisted(() => vi.fn(async () => {}));
|
||||
vi.mock('@/lib/web-push', async (original) => ({ ...(await original<typeof import('@/lib/web-push')>()), endWebPushForSignOut, dropAllWebPushRegistrations }));
|
||||
|
||||
import { setClientForTests, useAuthStore } from '../auth-store';
|
||||
import { useAccountStore } from '../account-store';
|
||||
|
||||
function fakeClient() {
|
||||
return { disconnect: vi.fn(), getAccountId: () => 'jmap-a' } as unknown as NonNullable<ReturnType<typeof useAuthStore.getState>['client']>;
|
||||
}
|
||||
|
||||
describe('signing out ends the login\'s push registration', () => {
|
||||
beforeEach(() => {
|
||||
endWebPushForSignOut.mockClear();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 })));
|
||||
});
|
||||
|
||||
it('when the user signs out', async () => {
|
||||
const client = fakeClient();
|
||||
useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: false });
|
||||
await useAuthStore.getState().logout();
|
||||
expect(endWebPushForSignOut).toHaveBeenCalledWith(client);
|
||||
});
|
||||
|
||||
it('when a wipe signs the login out', async () => {
|
||||
const client = fakeClient();
|
||||
useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: false });
|
||||
await useAuthStore.getState().logout({ endProviderSession: false });
|
||||
expect(endWebPushForSignOut).toHaveBeenCalledWith(client);
|
||||
});
|
||||
|
||||
it('not when the session only ran out: it comes back with the next sign-in', async () => {
|
||||
const client = fakeClient();
|
||||
useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: false });
|
||||
await useAuthStore.getState().logout({ endProviderSession: false, keepLocalData: true });
|
||||
expect(endWebPushForSignOut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removing a login ends its push while its client can still reach the server', async () => {
|
||||
let accountId = 'jmap-b';
|
||||
const order: string[] = [];
|
||||
const client = {
|
||||
disconnect: vi.fn(() => { order.push('disconnect'); accountId = ''; }),
|
||||
getAccountId: () => accountId,
|
||||
} as unknown as Parameters<typeof setClientForTests>[1];
|
||||
endWebPushForSignOut.mockImplementationOnce(async (seen: unknown) => {
|
||||
await Promise.resolve();
|
||||
order.push(`push ended for ${(seen as { getAccountId: () => string }).getAccountId()}`);
|
||||
});
|
||||
setClientForTests('login-b', client);
|
||||
useAccountStore.setState({ accounts: [{ id: 'login-b', cookieSlot: 1, username: 'b', serverUrl: 'https://x' }] as never });
|
||||
useAuthStore.setState({ activeAccountId: 'login-a', isDemoMode: false });
|
||||
|
||||
useAuthStore.getState().removeAccount('login-b');
|
||||
await vi.waitFor(() => expect(order).toContain('disconnect'));
|
||||
|
||||
expect(order).toEqual(['push ended for jmap-b', 'disconnect']);
|
||||
});
|
||||
|
||||
it('a login the session threw out keeps its push for the next sign-in', async () => {
|
||||
const client = fakeClient();
|
||||
setClientForTests('login-c', client as unknown as Parameters<typeof setClientForTests>[1]);
|
||||
useAccountStore.setState({ accounts: [{ id: 'login-c', cookieSlot: 1, username: 'c', serverUrl: 'https://x' }] as never });
|
||||
useAuthStore.setState({ activeAccountId: 'login-a', isDemoMode: false });
|
||||
|
||||
useAuthStore.getState().removeAccount('login-c', 'evicted');
|
||||
await vi.waitFor(() => expect(client.disconnect).toHaveBeenCalled());
|
||||
|
||||
expect(endWebPushForSignOut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('signs out even if the push code cannot be loaded', async () => {
|
||||
const client = fakeClient();
|
||||
endWebPushForSignOut.mockRejectedValueOnce(new Error('chunk failed'));
|
||||
useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: false });
|
||||
await useAuthStore.getState().logout();
|
||||
expect(useAuthStore.getState().client).toBeNull();
|
||||
});
|
||||
|
||||
it('signing out of everything also covers logins with no connection', async () => {
|
||||
dropAllWebPushRegistrations.mockClear();
|
||||
useAuthStore.setState({ isDemoMode: false });
|
||||
await useAuthStore.getState().logoutAll();
|
||||
expect(dropAllWebPushRegistrations).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('not in demo mode', async () => {
|
||||
const client = fakeClient();
|
||||
useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: true });
|
||||
await useAuthStore.getState().logout();
|
||||
expect(endWebPushForSignOut).not.toHaveBeenCalled();
|
||||
useAuthStore.setState({ isDemoMode: false });
|
||||
});
|
||||
});
|
||||
+39
-9
@@ -1742,6 +1742,17 @@ async function adoptProviderSession(
|
||||
notifySignedIn(get());
|
||||
}
|
||||
|
||||
/** Within the sign-out's budget: an unreachable server doesn't hold it up. */
|
||||
function endPushForSignOut(signingOut: Iterable<IJMAPClient>): Promise<void> {
|
||||
return Promise.race([
|
||||
(async () => {
|
||||
const { endWebPushForSignOut } = await import('@/lib/web-push');
|
||||
for (const client of signingOut) await endWebPushForSignOut(client);
|
||||
})().catch(() => undefined),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, LOGOUT_HOOK_BUDGET_MS)),
|
||||
]);
|
||||
}
|
||||
|
||||
function beforeLogoutHooks(accountId: string, options: SignOutCleanupOptions = {}): Promise<void> {
|
||||
return Promise.race([
|
||||
Promise.all([authHooks.onBeforeLogout.emit({ accountId }), runBeforeSignOut(accountId, options)]).then(() => {}),
|
||||
@@ -1749,6 +1760,11 @@ function beforeLogoutHooks(accountId: string, options: SignOutCleanupOptions = {
|
||||
]);
|
||||
}
|
||||
|
||||
/** Test helper: the store's own clients come from signing in. */
|
||||
export function setClientForTests(accountId: string, client: JMAPClient): void {
|
||||
clients.set(accountId, client);
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -2620,6 +2636,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
if (userInitiated && !(await passSignOutGates(accountId ?? 'all'))) return;
|
||||
|
||||
await beforeLogoutHooks(accountId ?? 'all', { keepLocalStore: keepLocalData });
|
||||
// A session that only ran out comes back with the next sign-in, push included.
|
||||
if (state.client && !wasDemoMode && !keepLocalData) await endPushForSignOut([state.client]);
|
||||
|
||||
// From here on nothing may renew this slot's session; the cleanup
|
||||
// below reopens it once the credentials are gone.
|
||||
@@ -2769,16 +2787,19 @@ export const useAuthStore = create<AuthState>()(
|
||||
if (!account) return;
|
||||
const slot = account.cookieSlot ?? 0;
|
||||
const wasOAuth = account.authMode === 'oauth';
|
||||
|
||||
// Started first: the cleanup marks the slot closing right away, so a
|
||||
// renewal in flight cannot bring the session back.
|
||||
void clearSlotCredentials(slot, wasOAuth);
|
||||
clearRefreshTimer(accountId);
|
||||
const client = clients.get(accountId);
|
||||
if (client) {
|
||||
forgetCalendarSubscriptions(client);
|
||||
try { client.disconnect(); } catch { /* noop */ }
|
||||
}
|
||||
// The slot is closing from here on: a renewal in flight cannot bring the
|
||||
// session back.
|
||||
closingSlots.add(slot);
|
||||
clearRefreshTimer(accountId);
|
||||
if (client) forgetCalendarSubscriptions(client);
|
||||
// The login's push goes first: a client that disconnected has no account
|
||||
// id and no API left to reach the server with.
|
||||
const pushEnded = client && reason === 'explicit' ? endPushForSignOut([client]) : Promise.resolve();
|
||||
void pushEnded.finally(() => {
|
||||
try { client?.disconnect(); } catch { /* noop */ }
|
||||
return clearSlotCredentials(slot, wasOAuth);
|
||||
});
|
||||
clients.delete(accountId);
|
||||
evictAccount(accountId);
|
||||
forgetAccountRows(accountId, get().client);
|
||||
@@ -2796,6 +2817,15 @@ export const useAuthStore = create<AuthState>()(
|
||||
for (const account of useAccountStore.getState().accounts) noteRefusedLogin(account.id, true, 'session');
|
||||
}
|
||||
await beforeLogoutHooks('all', { keepLocalStore: keepLocalData });
|
||||
if (!get().isDemoMode && !keepLocalData) {
|
||||
// Each connected login first, on a budget that one unreachable server can
|
||||
// use up. What's left, and whoever had no connection, only needs the relay.
|
||||
await endPushForSignOut(clients.values());
|
||||
await Promise.race([
|
||||
import('@/lib/web-push').then((push) => push.dropAllWebPushRegistrations()).catch(() => undefined),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, LOGOUT_HOOK_BUDGET_MS)),
|
||||
]);
|
||||
}
|
||||
|
||||
const accountStore = useAccountStore.getState();
|
||||
const allAccounts = [...accountStore.accounts];
|
||||
|
||||
@@ -2276,9 +2276,8 @@ if (typeof window !== 'undefined') {
|
||||
applyInterfaceLayout(store.interfaceLayout, store.density);
|
||||
|
||||
// The service worker shows push notifications without this store, so it
|
||||
// reads the sound preference from its own Cache store (#1289).
|
||||
// Do not disturb can't keep a push from showing (browsers require one per
|
||||
// push), but it keeps it silent.
|
||||
// reads the sound preference from its own Cache store (#1289). Whether they
|
||||
// show at all goes per login (NotificationActionsHost).
|
||||
const pushSound = (s: SettingsState) => s.emailNotificationsEnabled && s.emailNotificationSound && !s.doNotDisturb;
|
||||
let lastPushSound = pushSound(store);
|
||||
void writePushPreferences({ sound: lastPushSound });
|
||||
|
||||
Reference in New Issue
Block a user