mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
fix: show the push notification for mail in an account shared into another signed-in login
This commit is contained in:
@@ -31,10 +31,13 @@ interface ResolvedTarget {
|
||||
// When the SW passes ?accountId=, we need the slot whose JMAP session owns
|
||||
// that account - not just "the first signed-in slot", which is what
|
||||
// getStalwartCredentials() defaults to. Probe each candidate's session in
|
||||
// parallel and return the first match.
|
||||
// parallel and return the first match, the login whose own account it is
|
||||
// ahead of the others: an account with a folder shared out is listed in the
|
||||
// session of everyone it is shared with, who sees only that folder - not its
|
||||
// Inbox, nor the message that just arrived there.
|
||||
async function resolveTargetForAccount(accountId: string): Promise<ResolvedTarget | null> {
|
||||
const cookieStore = await cookies();
|
||||
const probes: Promise<ResolvedTarget | null>[] = [];
|
||||
const probes: Promise<{ target: ResolvedTarget; own: boolean } | null>[] = [];
|
||||
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
|
||||
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
|
||||
if (!ctx) continue;
|
||||
@@ -61,7 +64,10 @@ async function resolveTargetForAccount(accountId: string): Promise<ResolvedTarge
|
||||
accountId !== mailAccountId &&
|
||||
Object.prototype.hasOwnProperty.call(session.accounts ?? {}, accountId);
|
||||
if (mailAccountId !== accountId && !isSharedAccount) return null;
|
||||
return { authHeader: ctx.authHeader, apiUrl: session.apiUrl, accountId, trusted, slot };
|
||||
return {
|
||||
target: { authHeader: ctx.authHeader, apiUrl: session.apiUrl, accountId, trusted, slot },
|
||||
own: mailAccountId === accountId,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -69,7 +75,7 @@ async function resolveTargetForAccount(accountId: string): Promise<ResolvedTarge
|
||||
);
|
||||
}
|
||||
const results = await Promise.all(probes);
|
||||
return results.find((r): r is ResolvedTarget => r !== null) ?? null;
|
||||
return (results.find((r) => r?.own) ?? results.find((r) => r !== null))?.target ?? null;
|
||||
}
|
||||
|
||||
async function resolveDefaultTarget(creds: StalwartCredentials): Promise<ResolvedTarget | null> {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Two logins in one browser: slot 0 is Bob, slot 1 is Alice, and Alice shares
|
||||
// a folder with Bob. Alice's account is then listed in Bob's session too, but
|
||||
// Bob sees only the shared folder - not her Inbox, nor the message that just
|
||||
// arrived there.
|
||||
|
||||
vi.mock('next/server', () => ({
|
||||
NextResponse: {
|
||||
json: (data: unknown, init?: { status?: number }) => ({
|
||||
json: async () => data,
|
||||
status: init?.status ?? 200,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: async () => ({ get: () => undefined }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/account-utils', () => ({ MAX_ACCOUNT_SLOTS: 2 }));
|
||||
|
||||
const AUTH = ['Basic bob', 'Basic alice'];
|
||||
vi.mock('@/lib/stalwart/auth-context', () => ({
|
||||
readStalwartAuthContextFromStore: (_store: unknown, slot: number) =>
|
||||
slot < 2 ? { serverUrl: 'https://mail.example.com', authHeader: AUTH[slot] } : null,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/stalwart/credentials', () => ({
|
||||
getStalwartCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
const SESSIONS: Record<string, unknown> = {
|
||||
'Basic bob': {
|
||||
apiUrl: 'https://mail.example.com/jmap/',
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'n' },
|
||||
accounts: { n: { name: 'bob@example.com' }, m: { name: 'alice@example.com' } },
|
||||
},
|
||||
'Basic alice': {
|
||||
apiUrl: 'https://mail.example.com/jmap/',
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'm' },
|
||||
accounts: { m: { name: 'alice@example.com' } },
|
||||
},
|
||||
};
|
||||
|
||||
const NEW_MAIL = { id: 'e9', threadId: 't9', subject: 'For Alice', from: [{ email: 'carol@example.com' }] };
|
||||
|
||||
const fetchJmapServer = vi.fn(async (url: string, init?: { headers?: Record<string, string>; body?: string }) => {
|
||||
const auth = init?.headers?.Authorization ?? '';
|
||||
const json = (data: unknown) => ({ ok: true, json: async () => data });
|
||||
if (url.endsWith('/.well-known/jmap')) return json(SESSIONS[auth]);
|
||||
const isOwner = auth === 'Basic alice';
|
||||
const calls = (JSON.parse(init?.body ?? '{}') as { methodCalls: [string, Record<string, unknown>, string][] }).methodCalls;
|
||||
return json({
|
||||
methodResponses: calls.map(([name, , id]) => {
|
||||
if (name === 'Mailbox/query') return [name, { ids: isOwner ? ['inbox'] : [] }, id];
|
||||
if (name === 'Email/query') return [name, { ids: isOwner ? ['e9'] : [], total: isOwner ? 1 : 0 }, id];
|
||||
return [name, { list: isOwner ? [NEW_MAIL] : [], notFound: isOwner ? [] : ['e9'] }, id];
|
||||
}),
|
||||
});
|
||||
});
|
||||
vi.mock('@/lib/stalwart/server-fetch', () => ({
|
||||
fetchJmapServer: (...args: Parameters<typeof fetchJmapServer>) => fetchJmapServer(...args),
|
||||
isTrustedJmapServerUrl: async () => true,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/security/url-guard', () => ({
|
||||
DisallowedUrlError: class DisallowedUrlError extends Error {},
|
||||
}));
|
||||
|
||||
async function callRoute(accountId: string, emailId: string | null) {
|
||||
const { GET } = await import('@/app/api/push/preview/route');
|
||||
const request = {
|
||||
nextUrl: { searchParams: { get: (k: string) => (k === 'accountId' ? accountId : k === 'emailId' ? emailId : null) } },
|
||||
};
|
||||
const res = (await GET(request as unknown as Parameters<typeof GET>[0])) as unknown as {
|
||||
status: number;
|
||||
json: () => Promise<Record<string, unknown>>;
|
||||
};
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
describe('push preview for an account shared into another login', () => {
|
||||
beforeEach(() => {
|
||||
fetchJmapServer.mockClear();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the delivered message', 'e9'],
|
||||
['the newest unread Inbox message', null],
|
||||
])('reads %s through the login that owns the account', async (_label, emailId) => {
|
||||
const { status, body } = await callRoute('m', emailId);
|
||||
|
||||
expect(status).toBe(200);
|
||||
// Through Bob's login the preview came back empty, and the service worker
|
||||
// drops a push whose preview reports no mail: Alice got no notification.
|
||||
expect(body.email).toMatchObject({ id: 'e9' });
|
||||
expect(body.unreadTotal).toBe(1);
|
||||
expect(body.slot).toBe(1);
|
||||
});
|
||||
|
||||
it('still resolves an account only another login shares in', async () => {
|
||||
const { status, body } = await callRoute('n', 'e9');
|
||||
expect(status).toBe(200);
|
||||
expect(body.slot).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user