mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
368 lines
15 KiB
TypeScript
368 lines
15 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';
|
|
|
|
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;
|
|
/**
|
|
* 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 },
|
|
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 };
|
|
}
|
|
|
|
/**
|
|
* 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];
|
|
const emailProperties = ['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) };
|
|
|
|
return NextResponse.json({
|
|
email,
|
|
unreadTotal,
|
|
...(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 });
|
|
}
|
|
}
|