diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json
index 65e292e..2313671 100644
--- a/services/webmail/messages/en.json
+++ b/services/webmail/messages/en.json
@@ -51,7 +51,9 @@
"enableMfa": "Enable 2FA",
"generateAppPassword": "Generate App Password",
"recoveryCodesTitle": "Recovery Codes",
- "saveRecoveryCodes": "Save these recovery codes in a safe place. They will not be shown again."
+ "saveRecoveryCodes": "Save these recovery codes in a safe place. They will not be shown again.",
+ "language": "Language",
+ "signOut": "Sign out"
},
"admin": {
"dashboardTitle": "System Dashboard",
diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json
index 2969640..33bf425 100644
--- a/services/webmail/messages/es.json
+++ b/services/webmail/messages/es.json
@@ -51,7 +51,9 @@
"enableMfa": "Activar 2FA",
"generateAppPassword": "Generar Contraseña de Aplicación",
"recoveryCodesTitle": "Códigos de Recuperación",
- "saveRecoveryCodes": "Guarde estos códigos de recuperación en un lugar seguro. No se volverán a mostrar."
+ "saveRecoveryCodes": "Guarde estos códigos de recuperación en un lugar seguro. No se volverán a mostrar.",
+ "language": "Idioma",
+ "signOut": "Cerrar sesión"
},
"admin": {
"dashboardTitle": "Panel del Sistema",
diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json
index 4512217..00573da 100644
--- a/services/webmail/messages/pt-BR.json
+++ b/services/webmail/messages/pt-BR.json
@@ -51,7 +51,9 @@
"enableMfa": "Ativar 2FA",
"generateAppPassword": "Gerar Senha de Aplicativo",
"recoveryCodesTitle": "Códigos de Recuperação",
- "saveRecoveryCodes": "Guarde estes códigos de recuperação em local seguro. Eles não serão exibidos novamente."
+ "saveRecoveryCodes": "Guarde estes códigos de recuperação em local seguro. Eles não serão exibidos novamente.",
+ "language": "Idioma",
+ "signOut": "Sair"
},
"admin": {
"dashboardTitle": "Painel do Sistema",
diff --git a/services/webmail/package.json b/services/webmail/package.json
index a6c7ec8..5c84532 100644
--- a/services/webmail/package.json
+++ b/services/webmail/package.json
@@ -6,7 +6,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start -p 3000",
- "test": "vitest run"
+ "test": "vitest run",
+ "lint": "tsc --noEmit"
},
"dependencies": {
"next": "15.5.21",
diff --git a/services/webmail/src/app/(admin)/admin/layout.tsx b/services/webmail/src/app/(admin)/admin/layout.tsx
index 20b9330..593ffcf 100644
--- a/services/webmail/src/app/(admin)/admin/layout.tsx
+++ b/services/webmail/src/app/(admin)/admin/layout.tsx
@@ -1,7 +1,12 @@
+"use client";
+
import React from "react";
import Link from "next/link";
+import { useTranslations } from "../../../i18n/provider";
+import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
+ const t = useTranslations();
return (
{/* Executive Sidebar */}
@@ -14,7 +19,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
LambdaMail Admin
-
Management Console
+
{t("common.adminPortal")}
@@ -29,46 +34,61 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
href="/admin/dashboard"
className="flex items-center gap-2.5 px-3 py-2 rounded-lg bg-emerald-600/10 text-emerald-300 border border-emerald-500/20"
>
- 📊 Dashboard
+ 📊 {t("admin.dashboardTitle")}
- 🌐 Domains & DNS (13)
+ 🌐 {t("admin.domainsTitle")}
- 👤 Mailboxes & Quotas
+ 👤 {t("admin.mailboxesTitle")}
- 📮 Delivery Queue
+ 📮 {t("admin.queueTitle")}
- 📈 DMARC Analytics
+ 📈 {t("admin.dmarcTitle")}
- 🛡 Audit Logs & Preflight
+ 🛡 {t("admin.securityTitle")}
+ {/* Language and session controls */}
+
+
+
+
{/* Operator Profile Footer */}
diff --git a/services/webmail/src/app/(auth)/admin/login/page.tsx b/services/webmail/src/app/(auth)/admin/login/page.tsx
index 9d7f3b5..d4ac59c 100644
--- a/services/webmail/src/app/(auth)/admin/login/page.tsx
+++ b/services/webmail/src/app/(auth)/admin/login/page.tsx
@@ -2,8 +2,10 @@
import React, { useState } from "react";
import Link from "next/link";
+import { useTranslations } from "../../../../i18n/provider";
export default function AdminLoginPage() {
+ const t = useTranslations();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [mfaCode, setMfaCode] = useState("");
@@ -59,7 +61,7 @@ export default function AdminLoginPage() {
⚙
-
Management Console
+
{t("auth.adminLoginTitle")}
Surface: /admin/* (Cookie: Path=/admin, MFA Mandatory)
@@ -74,7 +76,7 @@ export default function AdminLoginPage() {
{!challengeToken ? (
<>
-
+
-
+
- Mandatory Admin 2FA Requirement (PLAN.md ADR-008). Enter your 6-digit TOTP code.
+ {t("auth.mfaRequired")}
-
+
- {loading ? "Verifying Admin Credentials..." : challengeToken ? "Verify Admin 2FA" : "Sign In to Admin Console"}
+ {loading ? t("common.loading") : challengeToken ? t("auth.verifyCodeButton") : t("auth.signInButton")}
diff --git a/services/webmail/src/app/(auth)/user/login/page.tsx b/services/webmail/src/app/(auth)/user/login/page.tsx
index 69a9e13..825c258 100644
--- a/services/webmail/src/app/(auth)/user/login/page.tsx
+++ b/services/webmail/src/app/(auth)/user/login/page.tsx
@@ -2,8 +2,10 @@
import React, { useState } from "react";
import Link from "next/link";
+import { useTranslations } from "../../../../i18n/provider";
export default function UserLoginPage() {
+ const t = useTranslations();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [mfaCode, setMfaCode] = useState("");
@@ -59,8 +61,8 @@ export default function UserLoginPage() {
@
-
LambdaMail Webmail
-
Surface: /user/* (Cookie: Path=/user)
+
{t("auth.userLoginTitle")}
+
{t("common.appName")} · {t("common.userPortal")}
@@ -74,7 +76,7 @@ export default function UserLoginPage() {
{!challengeToken ? (
<>
-
+
-
+
- Two-Factor Authentication is enabled for this account. Enter the 6-digit code from your authenticator app.
+ {t("auth.mfaRequired")}
-
+
- {loading ? "Authenticating..." : challengeToken ? "Verify 2FA Code" : "Sign In to Webmail"}
+ {loading ? t("common.loading") : challengeToken ? t("auth.verifyCodeButton") : t("auth.signInButton")}
diff --git a/services/webmail/src/app/(user)/user/layout.tsx b/services/webmail/src/app/(user)/user/layout.tsx
index e90d31a..d15d45d 100644
--- a/services/webmail/src/app/(user)/user/layout.tsx
+++ b/services/webmail/src/app/(user)/user/layout.tsx
@@ -1,7 +1,12 @@
+"use client";
+
import React from "react";
import Link from "next/link";
+import { useTranslations } from "../../../i18n/provider";
+import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
+ const t = useTranslations();
return (
{/* Sidebar Navigation */}
@@ -14,7 +19,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
LambdaMail
-
Webmail Surface
+
{t("common.userPortal")}
@@ -22,7 +27,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
href="/user/compose"
className="flex items-center justify-center gap-2 w-full py-2.5 px-4 mb-6 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-medium text-sm transition-colors shadow-lg shadow-indigo-600/20"
>
-
+ Write Email
+
+ {t("mail.compose")}
{/* Folder Navigation List */}
@@ -32,7 +37,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
className="flex items-center justify-between px-3 py-2 rounded-lg bg-indigo-600/10 text-indigo-300 border border-indigo-500/20"
>
- 📬 Inbox
+ 📬 {t("mail.inbox")}
3
@@ -42,7 +47,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
className="flex items-center justify-between px-3 py-2 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800/50 transition-colors"
>
- 📪 Sent
+ 📪 {t("mail.sent")}
@@ -51,7 +56,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
className="flex items-center justify-between px-3 py-2 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800/50 transition-colors"
>
- 📝 Drafts
+ 📝 {t("mail.drafts")}
1
@@ -86,6 +91,21 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
{/* User Settings & Account Footer */}
+ {/* Language and session controls */}
+
+
+
+
(null);
const [qrUri, setQrUri] = useState
(null);
const [totpCode, setTotpCode] = useState("");
const [recoveryCodes, setRecoveryCodes] = useState(null);
- const [appPasswords, setAppPasswords] = useState<{ id: string; label: string; created: string }[]>([]);
+ const [appPasswords, setAppPasswords] = useState<{ id: string; label: string; created_at?: string }[]>([]);
const [newAppPassLabel, setNewAppPassLabel] = useState("");
const [generatedAppPass, setGeneratedAppPass] = useState(null);
const [message, setMessage] = useState(null);
@@ -30,7 +32,7 @@ export default function UserSettingsPage() {
const res = await fetch("/api/v1/user/mfa/totp/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ secret: qrSecret, code: totpCode }),
+ body: JSON.stringify({ code: totpCode }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || "TOTP verification failed");
@@ -44,20 +46,62 @@ export default function UserSettingsPage() {
}
};
- const createNewAppPassword = (e: React.FormEvent) => {
+ const loadAppPasswords = useCallback(async () => {
+ try {
+ const res = await fetch("/api/v1/user/app-passwords");
+ if (res.ok) setAppPasswords(await res.json());
+ } catch {
+ // Leaving the list as it is beats replacing it with an empty one.
+ }
+ }, []);
+
+ const loadProfile = useCallback(async () => {
+ try {
+ const res = await fetch("/api/v1/user/me");
+ if (res.ok) {
+ const data = await res.json();
+ setMfaEnabled(Boolean(data.mfa_enrolled));
+ }
+ } catch {
+ // Non-fatal: the enrollment panel simply starts collapsed.
+ }
+ }, []);
+
+ useEffect(() => {
+ void loadProfile();
+ void loadAppPasswords();
+ }, [loadProfile, loadAppPasswords]);
+
+ const createNewAppPassword = async (e: React.FormEvent) => {
e.preventDefault();
if (!newAppPassLabel) return;
- const rawPass = `lmp_${Math.random().toString(36).substring(2, 10)}-${Math.random().toString(36).substring(2, 10)}`;
- setAppPasswords([...appPasswords, { id: Date.now().toString(), label: newAppPassLabel, created: "Just now" }]);
- setGeneratedAppPass(rawPass);
- setNewAppPassLabel("");
+ try {
+ const res = await fetch("/api/v1/user/app-passwords", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ label: newAppPassLabel }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.message || "Could not create app password");
+ // Shown once; the server keeps only an Argon2id hash of it.
+ setGeneratedAppPass(data.password);
+ setNewAppPassLabel("");
+ await loadAppPasswords();
+ } catch (err: unknown) {
+ setMessage(err instanceof Error ? err.message : "Error creating app password");
+ }
+ };
+
+ const revokeAppPassword = async (id: string) => {
+ await fetch(`/api/v1/user/app-passwords/${id}`, { method: "DELETE" });
+ await loadAppPasswords();
};
return (
-
Security & Account Settings
+
{t("settings.title")}
Manage 2FA TOTP (RFC 6238), App Passwords, and active webmail sessions.
@@ -177,9 +221,17 @@ export default function UserSettingsPage() {
{ap.label}
- Created: {ap.created}
+
+ {ap.created_at ? new Date(ap.created_at).toLocaleDateString() : ""}
+
-
Revoke
+
))
)}
diff --git a/services/webmail/src/app/api/v1/[...path]/route.ts b/services/webmail/src/app/api/v1/[...path]/route.ts
new file mode 100644
index 0000000..bcb3838
--- /dev/null
+++ b/services/webmail/src/app/api/v1/[...path]/route.ts
@@ -0,0 +1,100 @@
+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";
+
+// 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
{
+ const target = `${AUTH_SERVICE_URL}/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: "Authentication 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 {
+ 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";
diff --git a/services/webmail/src/app/layout.tsx b/services/webmail/src/app/layout.tsx
index c0e743e..b064d0d 100644
--- a/services/webmail/src/app/layout.tsx
+++ b/services/webmail/src/app/layout.tsx
@@ -1,16 +1,35 @@
import "./globals.css";
import React from "react";
+import { cookies, headers } from "next/headers";
+import { I18nProvider } from "../i18n/provider";
+import { LOCALE_COOKIE, isLocale, negotiateLocale } from "../i18n/config";
export const metadata = {
title: "LambdaMail - Secure Self-Hosted Webmail & Admin Console",
description: "Modern, open-source email server web interface with 2FA TOTP and surface isolation.",
};
-export default function RootLayout({ children }: { children: React.ReactNode }) {
+/**
+ * The locale is resolved on the server so the first paint is already in the
+ * right language: an explicit choice wins, otherwise the browser's
+ * Accept-Language decides.
+ */
+async function resolveLocale() {
+ const cookieStore = await cookies();
+ const chosen = cookieStore.get(LOCALE_COOKIE)?.value;
+ if (isLocale(chosen)) return chosen;
+
+ const headerStore = await headers();
+ return negotiateLocale(headerStore.get("accept-language"));
+}
+
+export default async function RootLayout({ children }: { children: React.ReactNode }) {
+ const locale = await resolveLocale();
+
return (
-
+
- {children}
+ {children}
);
diff --git a/services/webmail/src/i18n.test.ts b/services/webmail/src/i18n.test.ts
index 4d59442..6cb63bb 100644
--- a/services/webmail/src/i18n.test.ts
+++ b/services/webmail/src/i18n.test.ts
@@ -38,3 +38,43 @@ describe("i18n message bundles", () => {
expect(esKeys).toEqual(enKeys);
});
});
+
+// Key parity alone passed while the interface was hardcoded English and the
+// bundles were never imported. These tests check the wiring instead.
+describe("i18n is actually wired into the app", () => {
+ it("resolves a key through the same helper the components use", async () => {
+ const { MESSAGES, translate } = await import("./i18n/config.js");
+ expect(translate(MESSAGES["pt-BR"], "auth.signInButton")).not.toBe("auth.signInButton");
+ expect(translate(MESSAGES["pt-BR"], "auth.signInButton")).not.toBe(
+ translate(MESSAGES.en, "auth.signInButton"),
+ );
+ });
+
+ it("falls back to the key rather than throwing on an unknown lookup", async () => {
+ const { MESSAGES, translate } = await import("./i18n/config.js");
+ expect(translate(MESSAGES.en, "nope.missing")).toBe("nope.missing");
+ });
+
+ it("negotiates a locale from Accept-Language, honouring q-values and bare tags", async () => {
+ const { negotiateLocale } = await import("./i18n/config.js");
+ expect(negotiateLocale("pt-BR,pt;q=0.9,en;q=0.8")).toBe("pt-BR");
+ expect(negotiateLocale("pt")).toBe("pt-BR");
+ expect(negotiateLocale("es-AR,es;q=0.9")).toBe("es");
+ expect(negotiateLocale("en-US")).toBe("en");
+ expect(negotiateLocale("de-DE")).toBe("en");
+ expect(negotiateLocale(null)).toBe("en");
+ // A low-quality preferred tag must not beat a higher-quality supported one.
+ expect(negotiateLocale("de;q=1.0,es;q=0.9")).toBe("es");
+ });
+
+ it("keeps every UI string reachable: the layouts import the provider", async () => {
+ const { readFileSync } = await import("node:fs");
+ for (const file of [
+ "src/app/layout.tsx",
+ "src/app/(user)/user/layout.tsx",
+ "src/app/(admin)/admin/layout.tsx",
+ ]) {
+ expect(readFileSync(file, "utf8")).toMatch(/i18n\/(provider|config)/);
+ }
+ });
+});
diff --git a/services/webmail/src/i18n/LanguageSwitcher.tsx b/services/webmail/src/i18n/LanguageSwitcher.tsx
new file mode 100644
index 0000000..aca9693
--- /dev/null
+++ b/services/webmail/src/i18n/LanguageSwitcher.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import { LOCALES, LOCALE_LABELS, type Locale } from "./config";
+import { useI18n } from "./provider";
+
+/** Language selector, rendered in both surface layouts (PLAN.md section 21.2). */
+export function LanguageSwitcher({ className = "" }: { className?: string }) {
+ const { locale, setLocale, t } = useI18n();
+
+ return (
+
+ );
+}
diff --git a/services/webmail/src/i18n/config.ts b/services/webmail/src/i18n/config.ts
new file mode 100644
index 0000000..3fbc24a
--- /dev/null
+++ b/services/webmail/src/i18n/config.ts
@@ -0,0 +1,71 @@
+import en from "../../messages/en.json";
+import ptBR from "../../messages/pt-BR.json";
+import es from "../../messages/es.json";
+
+export const LOCALES = ["en", "pt-BR", "es"] as const;
+export type Locale = (typeof LOCALES)[number];
+
+export const DEFAULT_LOCALE: Locale = "en";
+export const LOCALE_COOKIE = "lm_locale";
+
+export const LOCALE_LABELS: Record = {
+ en: "English",
+ "pt-BR": "Português (Brasil)",
+ es: "Español",
+};
+
+// Bundled at build time rather than fetched: these are small, needed on first
+// paint, and a network round trip would flash untranslated text.
+export const MESSAGES: Record = {
+ en: en as Messages,
+ "pt-BR": ptBR as Messages,
+ es: es as Messages,
+};
+
+export type Messages = Record>;
+
+export function isLocale(value: string | undefined | null): value is Locale {
+ return !!value && (LOCALES as readonly string[]).includes(value);
+}
+
+/**
+ * Picks the best supported locale from an Accept-Language header.
+ * Quality values are honoured, and a bare "pt" matches "pt-BR" so a Brazilian
+ * browser that only advertises "pt" is not silently served English.
+ */
+export function negotiateLocale(acceptLanguage: string | null): Locale {
+ if (!acceptLanguage) return DEFAULT_LOCALE;
+
+ const ranked = acceptLanguage
+ .split(",")
+ .map((part) => {
+ const [tag, ...params] = part.trim().split(";");
+ const q = params.find((p) => p.trim().startsWith("q="));
+ return { tag: tag.trim().toLowerCase(), q: q ? Number(q.split("=")[1]) || 0 : 1 };
+ })
+ .sort((a, b) => b.q - a.q);
+
+ for (const { tag } of ranked) {
+ const exact = LOCALES.find((l) => l.toLowerCase() === tag);
+ if (exact) return exact;
+ const base = tag.split("-")[0];
+ const prefixed = LOCALES.find((l) => l.toLowerCase().split("-")[0] === base);
+ if (prefixed) return prefixed;
+ }
+ return DEFAULT_LOCALE;
+}
+
+/**
+ * Resolves a dotted key such as "auth.signInButton".
+ *
+ * A missing key falls back to English and, failing that, returns the key
+ * itself: a screen showing "settings.title" is a visible bug report, whereas
+ * throwing would blank the page over a typo in a label.
+ */
+export function translate(messages: Messages, key: string): string {
+ const [namespace, name] = key.split(".");
+ const value = messages?.[namespace]?.[name];
+ if (typeof value === "string") return value;
+ const fallback = MESSAGES[DEFAULT_LOCALE]?.[namespace]?.[name];
+ return typeof fallback === "string" ? fallback : key;
+}
diff --git a/services/webmail/src/i18n/provider.tsx b/services/webmail/src/i18n/provider.tsx
new file mode 100644
index 0000000..e3473c0
--- /dev/null
+++ b/services/webmail/src/i18n/provider.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
+import { LOCALE_COOKIE, MESSAGES, DEFAULT_LOCALE, translate, type Locale } from "./config";
+
+interface I18nContextValue {
+ locale: Locale;
+ setLocale: (locale: Locale) => void;
+ t: (key: string) => string;
+}
+
+const I18nContext = createContext(null);
+
+export function I18nProvider({ locale: initialLocale, children }: { locale: Locale; children: React.ReactNode }) {
+ const [locale, setLocaleState] = useState(initialLocale);
+
+ const setLocale = useCallback((next: Locale) => {
+ setLocaleState(next);
+ // Persisted for the next server render, so the page does not come back in
+ // the previous language on reload. Not HttpOnly: this is a display
+ // preference the client itself sets, carrying nothing sensitive.
+ document.cookie = `${LOCALE_COOKIE}=${next}; Path=/; Max-Age=31536000; SameSite=Lax`;
+ // Best effort: keep the account's stored locale in step for anything the
+ // server sends by mail. A failure here must not break the UI switch.
+ void fetch("/api/v1/user/locale", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ locale: next }),
+ }).catch(() => undefined);
+ document.documentElement.lang = next;
+ }, []);
+
+ const value = useMemo(() => {
+ const messages = MESSAGES[locale] ?? MESSAGES[DEFAULT_LOCALE];
+ return { locale, setLocale, t: (key: string) => translate(messages, key) };
+ }, [locale, setLocale]);
+
+ return {children};
+}
+
+export function useI18n(): I18nContextValue {
+ const ctx = useContext(I18nContext);
+ if (!ctx) {
+ throw new Error("useI18n must be used inside ");
+ }
+ return ctx;
+}
+
+/** Shorthand for components that only need to translate. */
+export function useTranslations(): (key: string) => string {
+ return useI18n().t;
+}
diff --git a/services/webmail/src/middleware.ts b/services/webmail/src/middleware.ts
new file mode 100644
index 0000000..8cdab4a
--- /dev/null
+++ b/services/webmail/src/middleware.ts
@@ -0,0 +1,80 @@
+import { NextResponse, type NextRequest } from "next/server";
+
+/**
+ * Gate for the two web surfaces.
+ *
+ * Without this, /admin/* rendered for anyone who typed the URL: the console
+ * was reachable unauthenticated and only its data calls failed. The check here
+ * is deliberately shallow - it reads the session cookie and its audience, and
+ * does not verify the signature, because the signing key belongs to the auth
+ * service and must not be copied into the browser-facing bundle.
+ *
+ * That is sound because this is not the security boundary: every /api/v1/*
+ * request is verified for real by the auth service, which owns the key. This
+ * only stops an unauthenticated visitor from loading a shell that cannot work,
+ * and sends them somewhere useful instead.
+ */
+
+const USER_COOKIE = "lm_user_session";
+const ADMIN_COOKIE = "lm_admin_session";
+
+interface JwtClaims {
+ aud?: string;
+ surface?: string;
+ purpose?: string;
+ exp?: number;
+}
+
+/** Reads the claims without verifying the signature. See the note above. */
+function readClaims(token: string | undefined): JwtClaims | null {
+ if (!token) return null;
+ const parts = token.split(".");
+ if (parts.length !== 3) return null;
+ try {
+ const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
+ const json = atob(payload.padEnd(payload.length + ((4 - (payload.length % 4)) % 4), "="));
+ return JSON.parse(json) as JwtClaims;
+ } catch {
+ return null;
+ }
+}
+
+function isUsable(claims: JwtClaims | null, expectedAud: string): boolean {
+ if (!claims) return false;
+ // A challenge token is issued between the password and the second factor;
+ // it must not open either surface.
+ if (claims.purpose !== "session") return false;
+ if (claims.aud !== expectedAud) return false;
+ if (typeof claims.exp === "number" && claims.exp * 1000 <= Date.now()) return false;
+ return true;
+}
+
+export function middleware(req: NextRequest) {
+ const { pathname, search } = req.nextUrl;
+
+ const isAdminArea = pathname.startsWith("/admin") && !pathname.startsWith("/admin/login");
+ const isUserArea = pathname.startsWith("/user") && !pathname.startsWith("/user/login");
+ if (!isAdminArea && !isUserArea) {
+ return NextResponse.next();
+ }
+
+ const surface = isAdminArea ? "admin" : "user";
+ const cookieName = isAdminArea ? ADMIN_COOKIE : USER_COOKIE;
+ const claims = readClaims(req.cookies.get(cookieName)?.value);
+
+ if (isUsable(claims, `lambdamail:${surface}`)) {
+ return NextResponse.next();
+ }
+
+ // The original destination travels along so the login can return there,
+ // rather than always dropping the user on a default page.
+ const login = req.nextUrl.clone();
+ login.pathname = `/${surface}/login`;
+ login.search = "";
+ login.searchParams.set("next", `${pathname}${search}`);
+ return NextResponse.redirect(login);
+}
+
+export const config = {
+ matcher: ["/admin/:path*", "/user/:path*"],
+};