Files
LambdaMail/services/webmail/src/app/api/v1/[...path]/route.ts
T
Lucas RayanandClaude Opus 5 3e60852551 #21: put real mail on the webmail screens
The mail list rendered two hardcoded messages and the reader showed their
canned HTML; compose ran a countdown and then navigated away without ever
submitting anything. Both now use the API, including search, the unread
badges in the sidebar (which were the literals "3" and "1", so an empty
inbox still advertised unread mail), and marking a message read on open.

The undo window now means what it says: the message is held in the browser
and only submitted when the countdown elapses, so cancelling it means
nothing was ever queued rather than trying to recall it afterwards.

Message bodies render in an iframe with an empty sandbox - no scripts, no
same-origin - and remote references are stripped until the reader asks for
images, so a tracking pixel cannot fire on open. The previous sandbox
allowed popups.

The admin page titles and the language switcher labels now come from the
bundles. The labels live there rather than in the source because PLAN.md
R9 keeps non-ASCII out of code, and each language should be named the way
that language names it.

ADR-001 assigned the mail API to the TypeScript storage service; the
reasoning for putting it in Go instead is recorded in PLAN.md, which
.gitignore deliberately keeps out of the repository, so that revision
lives only in the working copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 10:04:05 -03:00

109 lines
3.6 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
/**
* Proxies /api/v1/* to the auth service.
*
* The browser cannot reach http://auth:3001 - that name only resolves inside
* the compose network - so every call the pages make used to 404 against the
* Next server. Routing them through here keeps the session cookie first-party
* (same origin, so SameSite=Strict still applies) and keeps the auth service
* off the public network.
*/
const AUTH_SERVICE_URL = process.env.AUTH_SERVICE_URL ?? "http://auth:3001";
// The mail screens are served by the protocols service, which owns the folder,
// UID, flag and blob logic. Both verify the same session token, so the browser
// sees one origin and one cookie either way.
const PROTOCOLS_SERVICE_URL = process.env.PROTOCOLS_SERVICE_URL ?? "http://protocols:8080";
function upstreamFor(path: string[]): string {
return path[0] === "mail" ? PROTOCOLS_SERVICE_URL : AUTH_SERVICE_URL;
}
// Hop-by-hop headers must not be forwarded (RFC 9110 section 7.6.1), and the
// upstream sets its own content-length.
const STRIPPED = new Set([
"host",
"connection",
"keep-alive",
"transfer-encoding",
"upgrade",
"proxy-authorization",
"proxy-authenticate",
"te",
"trailer",
"content-length",
]);
function filterHeaders(source: Headers): Headers {
const out = new Headers();
source.forEach((value, key) => {
if (!STRIPPED.has(key.toLowerCase())) out.append(key, value);
});
return out;
}
async function forward(req: NextRequest, path: string[]): Promise<Response> {
const target = `${upstreamFor(path)}/api/v1/${path.join("/")}${req.nextUrl.search}`;
const headers = filterHeaders(req.headers);
// The client's address is what the auth service records on sessions and
// recovery-code use; without this every login looks like it came from the
// web container.
const forwardedFor = req.headers.get("x-forwarded-for");
if (forwardedFor) headers.set("x-forwarded-for", forwardedFor);
const hasBody = req.method !== "GET" && req.method !== "HEAD";
let upstream: Response;
try {
upstream = await fetch(target, {
method: req.method,
headers,
body: hasBody ? await req.arrayBuffer() : undefined,
redirect: "manual",
cache: "no-store",
});
} catch {
// A dead upstream is a gateway problem, not a 404 - the difference decides
// whether an operator looks at the auth service or at this route.
return NextResponse.json(
{ error: "UPSTREAM_UNAVAILABLE", message: "Upstream service is unreachable" },
{ status: 502 },
);
}
const responseHeaders = new Headers();
upstream.headers.forEach((value, key) => {
if (!STRIPPED.has(key.toLowerCase())) responseHeaders.append(key, value);
});
// Set-Cookie can legitimately appear more than once (logout clears both
// surfaces); getSetCookie preserves them where forEach would fold them into
// one malformed header.
responseHeaders.delete("set-cookie");
for (const cookie of upstream.headers.getSetCookie?.() ?? []) {
responseHeaders.append("set-cookie", cookie);
}
return new NextResponse(upstream.body, {
status: upstream.status,
headers: responseHeaders,
});
}
type Context = { params: Promise<{ path: string[] }> };
async function handler(req: NextRequest, ctx: Context): Promise<Response> {
const { path } = await ctx.params;
return forward(req, path ?? []);
}
export const GET = handler;
export const POST = handler;
export const PUT = handler;
export const PATCH = handler;
export const DELETE = handler;
// Sessions are per-request; nothing here may be cached or prerendered.
export const dynamic = "force-dynamic";