Files
webmail/app/api/push/preview/route.ts
T
Linus Rath 2bb0797996 feat: archive, mark read or reply right from a new mail notification
A notification for one message gets buttons where the browser has room
for them, and a click on its body works as before. The service worker
has no login, so an open tab takes the action first, through the store
actions. With no tab open a new same-origin route, /api/push/action,
does Mark read and Archive with the context cookie. When the cookie is
gone, the token has expired or the login can't be told apart, the
message opens in the app instead. Reply opens the composer through a
`?reply=1` link, with any text typed on the notification.

- `public/sw.js`: buttons, asking open tabs, the route, `sync` handling
- `app/api/push/action`: same-origin, checks the login and the account,
  honours the admin's Archive gate, files by the mirrored archive mode
- `lib/pwa-notify`: the page's side, the mirror for the worker, and
  `requestOutboxSync`, which the offline queue calls
- Background Sync, thin version: a `sync` event tells open tabs to flush

Two push-preview-route assertions change: they match the whole answer
for a named message, which now also carries `login`, what the action
route checks so a stale notification can't act on whoever holds the
cookie slot now.
2026-11-03 05:26:43 +01:00

393 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';
import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
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 } : {}),
// For the notification's buttons, which must not land on whoever signs
// into this slot later (app/api/push/action).
...(target.slot !== undefined && email ? { login: await loginFingerprint(target.serverUrl, target.username) } : {}),
}, {
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 });
}
}