Files
webmail/app/api/push/preview/route.ts
T
Linus Rath adbec718ef feat: let admins keep sender and subject out of push notifications, enforced by the server
The push preview route now reads the device policy of the login it
serves (its scopes worked out from the login's own JMAP session, every
non-personal account counted as a group) and under
`pushWithoutContent` asks the mail server for the message's ids only,
so sender, subject and text never reach the notification. The service
worker shows "New mail" for such an answer. A policy that can't be read
counts as on.

The three existing push preview route tests now mock the config manager
with the default policy: without it the route can't read a policy in the
test environment and holds the text back, which those tests didn't mean
to cover. No assertion changed.
2026-11-01 08:40:02 +01:00

389 lines
16 KiB
TypeScript

import { cookies } from 'next/headers';
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 {
getStalwartCredentials,
type StalwartCredentials,
} from '@/lib/stalwart/credentials';
import { fetchJmapServer, isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch';
import { rebaseApiUrl } from '@/lib/stalwart/jmap-api';
import { forwardedClientHeaders, loginClientAddress } from '@/lib/auth/client-address';
import { DisallowedUrlError } from '@/lib/security/url-guard';
import { UPSTREAM_JSON_MAX_BYTES, UpstreamBodyTooLargeError, readJsonCapped } from '@/lib/security/capped-body';
import { serverDevicePolicy } from '@/lib/admin/server-device-policy';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
interface ResolvedTarget {
authHeader: string;
apiUrl: string;
accountId: string;
/** See StalwartCredentials.trusted - false routes through the guarded fetch. */
trusted: boolean;
/** The login, for its device policy. */
serverUrl: string;
username: string;
accountName?: string;
/**
* Cookie slot of the login that owns the account. Handed back so a click
* on the notification opens the message in that login: the worker knows
* only the JMAP account id, which can repeat across servers.
*/
slot?: number;
}
// 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, 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.
//
// 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> {
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);
if (!ctx) continue;
const serverUrl = ctx.serverUrl.replace(/\/+$/, '');
probes.push(
(async () => {
try {
const trusted = await isTrustedJmapServerUrl(serverUrl);
const res = await fetchJmapServer(`${serverUrl}/.well-known/jmap`, {
headers: { Authorization: ctx.authHeader, ...forwarded },
}, trusted);
if (!res.ok) return null;
const session = (await readJsonCapped(res, UPSTREAM_JSON_MAX_BYTES)) as {
apiUrl?: string;
primaryAccounts?: Record<string, string>;
accounts?: Record<string, unknown>;
};
const mailAccountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
// On the host this process reaches, not the server's public one (#627).
const apiUrl = rebaseApiUrl(session, serverUrl);
if (!apiUrl || !mailAccountId) return null;
// Shared and group mailboxes are never the primary mail account -
// they only appear in session.accounts. Accept those too, or the
// SW falls back to a generic "New mail" notification for them. (#839)
const isSharedAccount =
accountId !== mailAccountId &&
Object.prototype.hasOwnProperty.call(session.accounts ?? {}, accountId);
if (mailAccountId !== accountId && !isSharedAccount) return null;
return {
target: { authHeader: ctx.authHeader, apiUrl, accountId, trusted, slot, serverUrl, username: ctx.username, accountName: ctx.accountName },
own: mailAccountId === accountId,
};
} catch {
return null;
}
})(),
);
}
const results = (await Promise.all(probes)).filter((r) => r !== null);
const owners = results.filter((r) => r.own);
if (owners.length === 1) return owners[0].target;
if (results.length === 1) return results[0].target;
return results.length > 1 ? 'ambiguous' : null;
}
async function resolveDefaultTarget(creds: StalwartCredentials, forwarded: Record<string, string>): Promise<ResolvedTarget | null> {
const sessionRes = await fetchJmapServer(`${creds.serverUrl}/.well-known/jmap`, {
headers: { Authorization: creds.authHeader, ...forwarded },
}, creds.trusted);
if (!sessionRes.ok) return null;
const session = (await readJsonCapped(sessionRes, UPSTREAM_JSON_MAX_BYTES)) as {
apiUrl?: string;
primaryAccounts?: Record<string, string>;
};
const apiUrl = rebaseApiUrl(session, creds.serverUrl);
const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail'];
if (!apiUrl || !accountId) return null;
return { authHeader: creds.authHeader, apiUrl, accountId, trusted: creds.trusted, serverUrl: creds.serverUrl, username: creds.username, accountName: creds.accountName };
}
/**
* GET /api/push/preview
*
* Called from the service worker when a Web Push wake-up arrives. Fetches the
* latest unread email so the SW can build an enriched system notification
* (sender, subject, avatar) without ever exposing JMAP credentials to the
* SW context.
*
* The relay's push payload is intentionally minimal (just a state-change
* ping), so this is what makes "From: Alice / Subject: …" appear instead of
* a generic "New mail" string.
*/
export async function GET(request: NextRequest) {
try {
// SW passes ?accountId=<jmap-account-id> derived from the push payload's
// StateChange so multi-account browsers fetch from the right slot. Older
// clients (and the manual /api/push/preview probe) omit it and fall back
// to the first signed-in slot.
const requestedAccountId = request.nextUrl.searchParams.get('accountId');
// With a server-side delivery filter (draft-ietf-jmap-emailpush) the push
// carries the id of the message that was actually delivered, so the SW
// asks for that one instead of guessing "newest unread in the Inbox" -
// which is wrong whenever Sieve filed the new message into a folder.
const requestedEmailId = request.nextUrl.searchParams.get('emailId');
const forwarded = forwardedClientHeaders(loginClientAddress(request));
let target: ResolvedTarget | null = null;
let authHeader: string;
if (requestedAccountId) {
const resolved = await resolveTargetForAccount(requestedAccountId, forwarded);
if (!resolved) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
// The service worker shows its generic notification, and a click opens
// the app without switching login.
if (resolved === 'ambiguous') {
return NextResponse.json({ error: 'Account is in more than one signed-in login' }, { status: 409 });
}
target = resolved;
authHeader = target.authHeader;
} else {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
target = await resolveDefaultTarget(creds, forwarded);
if (!target) {
return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 });
}
authHeader = creds.authHeader;
}
const { apiUrl, accountId, trusted } = target;
const inboxRes = await fetchJmapServer(apiUrl, {
method: 'POST',
headers: {
Authorization: authHeader,
'Content-Type': 'application/json',
...forwarded,
},
body: JSON.stringify({
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls: [
[
'Mailbox/query',
{ accountId, filter: { role: 'inbox' }, limit: 1 },
'mb',
],
],
}),
}, trusted);
if (!inboxRes.ok) {
return NextResponse.json({ error: 'JMAP mailbox query failed' }, { status: 502 });
}
const inboxData = (await readJsonCapped(inboxRes, UPSTREAM_JSON_MAX_BYTES)) as {
methodResponses: [string, Record<string, unknown>, string][];
};
const inboxBody = inboxData.methodResponses.find(
([method, , callId]) => method === 'Mailbox/query' && callId === 'mb',
)?.[1] as { ids?: string[] } | undefined;
// A method-level JMAP error still has HTTP 200. Do not turn it into
// an empty Inbox: the service worker would silently discard the push.
if (!Array.isArray(inboxBody?.ids)) {
return NextResponse.json({ error: 'JMAP mailbox query failed' }, { status: 502 });
}
const inboxId = inboxBody.ids[0];
// Organisation > Devices > Push without sender and subject, for this
// login's scopes (worked out from its own session): the message's
// sender, subject and text are never read, so they can't reach the
// notification, the push service or the lock screen. Unknown counts as on.
const redacted = await serverDevicePolicy({
serverUrl: target.serverUrl,
authHeader,
username: target.username,
accountName: target.accountName,
trusted,
}).then((devices) => devices.pushWithoutContent, () => true);
const emailProperties = redacted
? ['id', 'threadId', 'receivedAt']
: ['id', 'threadId', 'from', 'subject', 'preview', 'receivedAt'];
if (!inboxId && !requestedEmailId) {
return NextResponse.json({
email: null,
unreadTotal: 0,
}, {
headers: {
'Cache-Control': 'no-store',
},
});
}
// Pull the most recent unread message from the resolved Inbox mailbox
// (and its unread total for the "+N more" line). When the SW named the
// delivered message, fetch that one too and prefer it.
const methodCalls: unknown[] = [];
if (inboxId) {
methodCalls.push(
[
'Email/query',
{
accountId,
filter: {
operator: 'AND',
conditions: [
{ inMailbox: inboxId },
{ notKeyword: '$seen' },
],
},
sort: [{ property: 'receivedAt', isAscending: false }],
limit: 1,
calculateTotal: true,
},
'eq',
],
[
'Email/get',
{
accountId,
'#ids': { resultOf: 'eq', name: 'Email/query', path: '/ids' },
properties: emailProperties,
},
'eg',
],
);
}
if (requestedEmailId) {
methodCalls.push([
'Email/get',
{ accountId, ids: [requestedEmailId], properties: emailProperties },
'delivered',
]);
}
const requestBody = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
methodCalls,
};
const jmapRes = await fetchJmapServer(apiUrl, {
method: 'POST',
headers: {
Authorization: authHeader,
'Content-Type': 'application/json',
...forwarded,
},
body: JSON.stringify(requestBody),
}, trusted);
if (!jmapRes.ok) {
return NextResponse.json({ error: 'JMAP request failed' }, { status: 502 });
}
const data = (await readJsonCapped(jmapRes, UPSTREAM_JSON_MAX_BYTES)) as {
methodResponses: [string, Record<string, unknown>, string][];
};
// Missing/failed method responses are preview failures, not proof that
// there is no unread mail. A non-2xx response lets the SW show its fallback.
// The message the server named, once read, is a complete preview on its
// own, whatever became of the Inbox lookups next to it.
const resultOf = (name: string, id: string) =>
data.methodResponses.find(([method, , callId]) => method === name && callId === id)?.[1];
const failed = (methodCalls as [string, unknown, string][]).some(([name, , id]) => {
const result = resultOf(name, id);
return !result || (name === 'Email/query' ? !Array.isArray(result.ids) : !Array.isArray(result.list));
});
const deliveredList = resultOf('Email/get', 'delivered')?.list;
if (failed && !(Array.isArray(deliveredList) && deliveredList.length > 0)) {
return NextResponse.json({ error: 'JMAP email query failed' }, { status: 502 });
}
type EmailLite = {
id: string;
threadId: string;
from?: { name?: string | null; email?: string }[] | null;
subject?: string | null;
preview?: string | null;
receivedAt?: string | null;
};
let email: EmailLite | null = null;
let delivered: EmailLite | null = null;
let unreadTotal = 0;
for (const [method, body, callId] of data.methodResponses) {
if (method === 'Email/query') {
// A server that leaves out the optional total still lists the ids.
const query = body as { total?: number; ids?: unknown[] };
unreadTotal = typeof query.total === 'number' ? query.total : (query.ids?.length ?? 0);
}
if (method === 'Email/get') {
const list = (body as { list?: EmailLite[] }).list ?? [];
if (callId === 'delivered') delivered = list[0] ?? null;
else email = list[0] ?? null;
}
}
// The message the server said it delivered beats "newest unread in the
// Inbox" - it may have been filed elsewhere by Sieve. Keep the Inbox
// unread total for the group line; count the delivered one if it isn't
// already in it (unread total is Inbox-scoped).
if (delivered) {
if (!email || email.id !== delivered.id) {
unreadTotal = Math.max(unreadTotal, 1);
}
email = delivered;
} else if (requestedEmailId) {
// The server named the message that arrived and it could not be read -
// deleted since, or filed where this login cannot see it. "Newest unread
// in the Inbox" is then some other message, and announcing it would be
// confidently wrong. Name no message: the unread total still comes back,
// so the SW posts its generic toast instead of staying silent.
email = null;
}
// Some servers build the preview from HTML without skipping <style>; a
// notification that opens on a style sheet says nothing.
if (email?.preview) email = { ...email, preview: cleanPreview(email.preview) };
// Whatever the server sent anyway, only what a click needs goes back.
if (redacted && email) email = { id: email.id, threadId: email.threadId, receivedAt: email.receivedAt ?? null };
return NextResponse.json({
email,
unreadTotal,
...(redacted ? { redacted: true } : {}),
...(target.slot !== undefined ? { slot: target.slot } : {}),
}, {
headers: {
// SW already gates on its own logic - don't let push events get
// cached and served stale.
'Cache-Control': 'no-store',
},
});
} catch (error) {
if (error instanceof DisallowedUrlError) {
logger.warn('push preview refused non-public server address', { error: error.message });
return NextResponse.json({ error: 'JMAP server address is not allowed' }, { status: 502 });
}
if (error instanceof UpstreamBodyTooLargeError) {
logger.warn('push preview: JMAP response too large', { maxBytes: error.maxBytes });
return NextResponse.json({ error: 'JMAP server response is too large' }, { status: 502 });
}
// `fetch failed` from undici is too generic to debug - the real reason
// (ENOTFOUND, ECONNREFUSED, TLS error, …) is on `error.cause`.
const err = error as Error & { cause?: { code?: string; message?: string } };
logger.error('push preview failed', {
error: err?.message ?? 'Unknown error',
causeCode: err?.cause?.code,
causeMessage: err?.cause?.message,
});
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
}