diff --git a/services/auth/src/router.test.ts b/services/auth/src/router.test.ts index 4008b83..a6c3d40 100644 --- a/services/auth/src/router.test.ts +++ b/services/auth/src/router.test.ts @@ -168,12 +168,16 @@ describeDb("auth API against a real database", () => { }); // The second headline regression: any non-empty MFA code used to work. + // The challenge comes from the admin sign-in, which is the surface that + // collects a second factor. The webmail no longer does - reading your own + // mail is behind your own password - so this used to obtain its challenge + // from a path that has none. it("rejects a wrong TOTP code and accepts only the real one", { timeout: 60000 }, async () => { - const login = await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD }); - const secret = await enrollTotp(USER_ID, login.body.token as unknown as string); + const login = await post("/api/v1/auth/user/login", { email: adminEmail(), password: PASSWORD }); + await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [ADMIN_ID]); + const secret = await enrollTotp(ADMIN_ID, login.body.token as unknown as string); - // With a factor enrolled, login now demands it. - const challenge = await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD }); + const challenge = await post("/api/v1/auth/admin/login", { email: adminEmail(), password: PASSWORD }); expect(challenge.body.mfa_required).toBe(true); const wrong = await post("/api/v1/auth/mfa/verify", { @@ -306,7 +310,11 @@ describeDb("auth API against a real database", () => { }, 90000); it("grants the admin console after a real second factor", { timeout: 60000 }, async () => { - // Enrollment happens on the user surface, which the admin also owns. + // Enrollment happens on the user surface, which the admin also owns. The + // existing factor is cleared first, as adminSession does: enrolling over a + // confirmed one leaves two secrets on the account and the code generated + // here is then checked against the wrong one. + await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [ADMIN_ID]); const userLogin = await post("/api/v1/auth/user/login", { email: adminEmail(), password: PASSWORD }); const secret = await enrollTotp(ADMIN_ID, userLogin.body.token as unknown as string); @@ -360,12 +368,14 @@ describeDb("auth API against a real database", () => { expect(res.body.error).toBe("ACCOUNT_LOCKED"); }); + // Redeemed at the admin sign-in, which is where a second factor is collected + // now. The account is an admin for the same reason. it("consumes a recovery code exactly once", { timeout: 60000 }, async () => { const email = `rec@authtest-${DOMAIN_ID}.invalid`; const id = crypto.randomUUID(); await query( - `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) - VALUES ($1, $2, 'rec', $3, $4)`, + `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, role) + VALUES ($1, $2, 'rec', $3, $4, 'SUPER_ADMIN')`, [id, DOMAIN_ID, email, await hashPassword(PASSWORD)], ); @@ -378,14 +388,14 @@ describeDb("auth API against a real database", () => { const confirmed = await post("/api/v1/user/mfa/totp/confirm", { code }, login.body.token); const recoveryCode = (confirmed.body.recovery_codes as unknown as string[])[0]; - const challenge = await post("/api/v1/auth/user/login", { email, password: PASSWORD }); + const challenge = await post("/api/v1/auth/admin/login", { email, password: PASSWORD }); const first = await post("/api/v1/auth/mfa/verify", { challenge_token: challenge.body.challenge_token, code: recoveryCode, }); expect(first.status).toBe(200); - const challenge2 = await post("/api/v1/auth/user/login", { email, password: PASSWORD }); + const challenge2 = await post("/api/v1/auth/admin/login", { email, password: PASSWORD }); const second = await post("/api/v1/auth/mfa/verify", { challenge_token: challenge2.body.challenge_token, code: recoveryCode, @@ -663,6 +673,191 @@ describe("surface routing", () => { }); }); +/** + * What each surface costs to open, and what a session grants once opened. + * + * Two rules made the app tiresome in a way no unit test could see. Reading your + * own mail demanded a second factor, which the mailbox does not require - only + * the console does. And a session issued at the admin sign-in carried the admin + * audience alone, so leaving the console landed on the webmail's login screen, + * asking for a password from somebody who had just proven a password and a + * second factor a moment earlier. + */ +describeDb("moving between the surfaces", () => { + const stepUpEmail = () => `stepup@authtest-${DOMAIN_ID}.invalid`; + const plainEmail = () => `plainuser@authtest-${DOMAIN_ID}.invalid`; + let stepUpId = ""; + + /** Reads the Set-Cookie headers, which is where the two sessions land. */ + async function postForCookies(path: string, body: unknown, token?: string) { + const res = await fetch(`${base}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(body), + }); + return { + status: res.status, + cookies: res.headers.getSetCookie(), + body: (await res.json().catch(() => ({}))) as Record, + }; + } + + const cookieNames = (cookies: string[]) => cookies.map((c) => c.split("=")[0]).sort(); + + /** + * One session per account, taken once. + * + * Every login costs an Argon2 verify, and this service deliberately bounds + * how many of those run at a time, so a test file that signs in per assertion + * spends its time queueing behind itself. A session does not expire during + * the run, so reusing it tests the same thing and leaves the budget for the + * assertions that need a fresh sign-in. + */ + let stepUpToken = ""; + let plainToken = ""; + let plainId = ""; + + beforeAll(async () => { + await startServer(); + await seed(); + const hash = await hashPassword(PASSWORD); + for (const [local, role] of [["stepup", "SUPER_ADMIN"], ["plainuser", "USER"]] as const) { + await query( + `INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash, role) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (domain_id, local_part) DO NOTHING`, + [DOMAIN_ID, local, `${local}@authtest-${DOMAIN_ID}.invalid`, hash, role], + ); + } + stepUpId = (await query<{ id: string }>( + `SELECT id FROM mailboxes WHERE email_address = $1`, [stepUpEmail()]))[0].id; + plainId = (await query<{ id: string }>( + `SELECT id FROM mailboxes WHERE email_address = $1`, [plainEmail()]))[0].id; + + stepUpToken = (await post("/api/v1/auth/user/login", + { email: stepUpEmail(), password: PASSWORD })).body.token as unknown as string; + plainToken = (await post("/api/v1/auth/user/login", + { email: plainEmail(), password: PASSWORD })).body.token as unknown as string; + }, 120000); + + afterAll(async () => { + server?.close(); + await query(`DELETE FROM domains WHERE id = $1`, [DOMAIN_ID]).catch(() => undefined); + await closePool(); + }); + + // The mailbox is one account's own mail behind that account's own password. + // Demanding a code as well meant an enrolled account could not read its mail + // without its phone. + it("opens the webmail on the password alone, even with a factor enrolled", async () => { + await enrollTotp(stepUpId, stepUpToken); + + const login = await post("/api/v1/auth/user/login", { email: stepUpEmail(), password: PASSWORD }); + expect(login.status).toBe(200); + expect(login.body.mfa_required).toBeUndefined(); + expect(login.body.token).toBeDefined(); + + // And the session it hands out is one the API actually accepts. + expect((await get("/api/v1/user/me", login.body.token)).status).toBe(200); + }, 120000); + + // A webmail session is still not an admin session; that is the isolation the + // whole audience split exists for. + it("does not let the webmail session open the console by itself", async () => { + expect((await get("/api/v1/admin/dashboard", stepUpToken)).status).toBe(401); + }); + + // The sign-in checked the password and the second factor and never the role, + // so an ordinary account that had enrolled a factor - which any account may + // do from its own settings - could sign in at /admin/login and be handed an + // admin-audience session. The step-up checks the role; this path did not. + it("refuses an ordinary account at the admin sign-in, factor or no factor", async () => { + await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [plainId]); + const secret = await enrollTotp(plainId, plainToken); + + const attempt = await post("/api/v1/auth/admin/login", { email: plainEmail(), password: PASSWORD }); + expect(attempt.status).toBe(403); + expect(attempt.body.error).toBe("FORBIDDEN"); + + // And no challenge came back that could be verified into one anyway. + expect(attempt.body.challenge_token).toBeUndefined(); + void secret; + }, 120000); + + it("refuses a step-up without a webmail session behind it", async () => { + expect((await post("/api/v1/auth/admin/step-up", { code: "123456" })).status).toBe(401); + }); + + it("refuses a step-up with the wrong code, and never on the password alone", async () => { + const res = await post("/api/v1/auth/admin/step-up", { code: "000000" }, stepUpToken); + expect(res.status).toBe(401); + expect(res.body.error).toBe("INVALID_MFA_CODE"); + }); + + // The role is read from the database rather than from the token, so an + // ordinary account cannot cross over however valid its session is. + it("refuses a step-up from an account that may not open the console", async () => { + const res = await post("/api/v1/auth/admin/step-up", { code: "123456" }, plainToken); + expect(res.status).toBe(403); + }); + + // The point of the step-up: the second factor, and not the password, which + // the session in hand proved already. + it("crosses into the console on the second factor alone", async () => { + await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [stepUpId]); + const secret = await enrollTotp(stepUpId, stepUpToken); + + const stepUp = await postForCookies( + "/api/v1/auth/admin/step-up", + { code: generateHotp(base32Decode(secret), getCurrentStep()) }, + stepUpToken, + ); + expect(stepUp.status).toBe(200); + expect((await get("/api/v1/admin/dashboard", stepUp.body.token)).status).toBe(200); + + // Both sessions come back, which is what makes leaving the console free. + expect(cookieNames(stepUp.cookies)).toEqual(["lm_admin_session", "lm_user_session"]); + }, 120000); + + // Proving a password and a second factor is strictly more than the webmail + // asks for, so it must not leave the operator without a webmail session. + it("issues a webmail session at the admin sign-in too", async () => { + await query(`DELETE FROM mfa_totp WHERE mailbox_id = $1`, [stepUpId]); + const secret = await enrollTotp(stepUpId, stepUpToken); + + const challenge = await post("/api/v1/auth/admin/login", { email: stepUpEmail(), password: PASSWORD }); + expect(challenge.body.mfa_required).toBe(true); + + const verified = await postForCookies("/api/v1/auth/mfa/verify", { + challenge_token: challenge.body.challenge_token, + code: generateHotp(base32Decode(secret), getCurrentStep()), + }); + expect(verified.status).toBe(200); + expect(cookieNames(verified.cookies)).toEqual(["lm_admin_session", "lm_user_session"]); + + // The webmail cookie has to be a webmail token, not the admin one copied + // across: an admin-audience token is refused by every /user route. + const userCookie = verified.cookies.find((c) => c.startsWith("lm_user_session="))!; + const userToken = userCookie.slice("lm_user_session=".length).split(";")[0]; + expect((await get("/api/v1/user/me", userToken)).status).toBe(200); + }, 120000); + + // Signing in twice in the same second produced a byte-identical token, which + // collided with the unique index on web_sessions.refresh_token_hash and + // answered 500 to a correct password. A double-clicked sign-in was enough. + it("survives two sign-ins in the same second", async () => { + const [first, second] = await Promise.all([ + post("/api/v1/auth/user/login", { email: plainEmail(), password: PASSWORD }), + post("/api/v1/auth/user/login", { email: plainEmail(), password: PASSWORD }), + ]); + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(first.body.token).not.toBe(second.body.token); + }, 60000); +}); + if (!hasDatabase) { // eslint-disable-next-line no-console console.warn("TEST_DATABASE_URL not set - auth API integration tests skipped"); diff --git a/services/auth/src/router.ts b/services/auth/src/router.ts index c5905ab..19c84bc 100644 --- a/services/auth/src/router.ts +++ b/services/auth/src/router.ts @@ -3,6 +3,13 @@ import crypto from "node:crypto"; import { generateTotpSecret, generateTotpUri } from "./totp.js"; import { generateRecoveryCodes, generateAppPassword } from "./mfaHelpers.js"; import { createJwt, verifyJwt, isSurfaceAuthorized, type SessionTokenPayload } from "./session.js"; +import { + secondFactorRequired, + surfacesGrantedBy, + cookieNameFor, + stepUpNeedsSecondFactor, + type Surface, +} from "./surfaceAccess.js"; import * as repo from "./repository.js"; const CHALLENGE_TTL_SECONDS = 300; @@ -125,6 +132,10 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr if (url === "/api/v1/auth/user/login" && method === "POST") return login(req, res, "user"); if (url === "/api/v1/auth/admin/login" && method === "POST") return login(req, res, "admin"); if (url === "/api/v1/auth/mfa/verify" && method === "POST") return mfaVerify(req, res); + // Under /auth/ rather than /admin/: the caller does not hold an admin + // session yet - obtaining one is the whole point - so the admin guard above + // must not stand in front of it. + if (url === "/api/v1/auth/admin/step-up" && method === "POST") return adminStepUp(req, res); if (url === "/api/v1/auth/logout" && method === "POST") return logout(res); if (url === "/api/v1/user/me" && method === "GET") return me(res, session!); @@ -230,30 +241,35 @@ async function login(req: IncomingMessage, res: ServerResponse, surface: "user" const { mailbox, mfaRequired } = outcome; - // An admin whose second factor is not enrolled must not fall through to a - // full session: the admin surface requires MFA unconditionally. - if (surface === "admin" && !(await repo.hasConfirmedTotp(mailbox.id))) { - const enrolmentToken = createJwt( - { - sub: mailbox.id, - email: mailbox.email_address, - role: mailbox.role, - domainId: mailbox.domain_id, - surface: "user", - aud: "lambdamail:user", - mfaSatisfied: false, - purpose: "mfa_enrollment", - }, - ENROLMENT_TTL_SECONDS, - ); + // The console is for the roles that may operate it. This checked the password + // and the second factor and never the role, so an ordinary account that had + // enrolled a factor - which any account may do from its own settings - was + // handed an admin-audience session, and the routes that do not re-check the + // role opened for it. Refused before the challenge, so no token exists that + // could be verified into one. + if (surface === "admin" && mailbox.role !== "SUPER_ADMIN" && mailbox.role !== "DOMAIN_ADMIN") { return sendJson(res, 403, { - error: "MFA_ENROLLMENT_REQUIRED", - message: "The admin console requires a second factor. Enrol one to continue.", - enrollment_token: enrolmentToken, + error: "FORBIDDEN", + message: "This account may not open the admin console", }); } - if (mfaRequired) { + // An admin whose second factor is not enrolled must not fall through to a + // full session: the admin surface requires MFA unconditionally. + if (surface === "admin" && !(await repo.hasConfirmedTotp(mailbox.id))) { + return sendJson(res, 403, { + error: "MFA_ENROLLMENT_REQUIRED", + message: "The admin console requires a second factor. Enrol one to continue.", + enrollment_token: enrolmentGrant(mailbox), + }); + } + + // Only the console collects a second factor at sign-in. The webmail is one + // account's own mail behind that account's own password, and demanding a code + // to read it made every message cost a phone - while the surface that can + // rewrite other people's mailboxes is the one that needs it. An enrolled + // factor is still asked for on the way into the console, by step-up. + if (mfaRequired && secondFactorRequired(surface)) { const challengeToken = createJwt( { sub: mailbox.id, @@ -270,48 +286,144 @@ async function login(req: IncomingMessage, res: ServerResponse, surface: "user" return sendJson(res, 200, { mfa_required: true, challenge_token: challengeToken }); } - // No factor enrolled yet. The session is issued so the user can reach the - // settings screen and enroll; when policy obliges them to, the response says - // so and the UI keeps them on that screen. + // The session is issued so the user can reach the settings screen and enroll; + // when policy obliges them to, the response says so and the UI keeps them on + // that screen. await issueSession(req, res, mailbox, surface, repo.isMfaEnrollmentRequired(mailbox)); } +/** + * Crosses a signed-in webmail session over into the console. + * + * The password is not asked for again: the session presented here already + * proved it, minutes ago. What the console adds over the webmail is the second + * factor, so that is all this collects. Without this endpoint the only way over + * was the admin sign-in form, which is why crossing surfaces used to mean + * typing the password a second time. + */ +async function adminStepUp(req: IncomingMessage, res: ServerResponse): Promise { + const token = extractToken(req, "/api/v1/user/"); + if (!stepUpNeedsSecondFactor(token)) { + return sendJson(res, 401, { error: "UNAUTHORIZED", message: "A signed-in webmail session is required" }); + } + const payload = verifyJwt(token)!; + + const mailbox = await repo.findMailboxByEmail(payload.email); + if (!mailbox || !mailbox.is_active) { + return sendJson(res, 401, { error: "INVALID_CREDENTIALS", message: "Account unavailable" }); + } + // Role is read from the database rather than from the token: a role changed + // since sign-in must take effect on the surface that acts on it. + if (mailbox.role !== "SUPER_ADMIN" && mailbox.role !== "DOMAIN_ADMIN") { + return sendJson(res, 403, { error: "FORBIDDEN", message: "This account may not open the admin console" }); + } + + // No factor to ask for yet. The same grant the admin sign-in hands out is + // returned, so the caller can enrol one and come straight back. + if (!(await repo.hasConfirmedTotp(mailbox.id))) { + return sendJson(res, 403, { + error: "MFA_ENROLLMENT_REQUIRED", + message: "The admin console requires a second factor. Enrol one to continue.", + enrollment_token: enrolmentGrant(mailbox), + }); + } + + const body = await parseJsonBody(req); + const code = typeof body?.code === "string" ? body.code.trim() : ""; + if (!code) { + return sendJson(res, 400, { error: "CODE_REQUIRED", message: "Verification code required" }); + } + + const accepted = /^\d{6}$/.test(code) + ? await repo.verifyTotpForLogin(mailbox.id, code) + : await repo.consumeRecoveryCode(mailbox.id, code, clientIp(req)); + if (!accepted) { + return sendJson(res, 401, { error: "INVALID_MFA_CODE", message: "Invalid verification code" }); + } + + await issueSession(req, res, mailbox, "admin"); +} + +/** The short-lived grant that permits enrolling a factor and nothing else. */ +function enrolmentGrant(mailbox: repo.MailboxRecord): string { + return createJwt( + { + sub: mailbox.id, + email: mailbox.email_address, + role: mailbox.role, + domainId: mailbox.domain_id, + surface: "user", + aud: "lambdamail:user", + mfaSatisfied: false, + purpose: "mfa_enrollment", + }, + ENROLMENT_TTL_SECONDS, + ); +} + +/** + * Issues the sessions a sign-in on `surface` earns, and sets their cookies. + * + * An admin sign-in yields a webmail session as well. The two audiences are + * separate tokens - a token minted for the console is refused by every /user + * route, by design - so granting both means minting both. Doing so is what + * turns leaving the console into a plain link: whoever proved a password and a + * second factor has more than the webmail asks for, and being sent back to a + * password prompt on the way out was the app forgetting that. + * + * mfaSatisfied records what was actually proven, so it is true only where a + * factor was collected. isSurfaceAuthorized requires it for the console alone. + */ async function issueSession( req: IncomingMessage, res: ServerResponse, mailbox: repo.MailboxRecord, - surface: "user" | "admin", + surface: Surface, enrollmentRequired = false, + // Whether a second factor was actually collected on the way here. It defaults + // to what the surface demands; mfaVerify passes it explicitly, because + // recording a proven factor as unproven would make isStepUpMfaRequired fire + // straight away. + factorProven = secondFactorRequired(surface), ): Promise { - const sessionToken = createJwt({ - sub: mailbox.id, - email: mailbox.email_address, - role: mailbox.role, - domainId: mailbox.domain_id, - surface, - aud: surface === "admin" ? "lambdamail:admin" : "lambdamail:user", - mfaSatisfied: true, - mfaSatisfiedAt: Date.now(), - purpose: "session", - }); + const cookies: string[] = []; + let primaryToken = ""; - await repo.recordSession( - mailbox.id, - surface, - crypto.createHash("sha256").update(sessionToken).digest("hex"), - new Date(Date.now() + SESSION_TTL_SECONDS * 1000), - clientIp(req), - req.headers["user-agent"] ?? null, - ); + for (const granted of surfacesGrantedBy(surface)) { + const token = createJwt({ + sub: mailbox.id, + email: mailbox.email_address, + role: mailbox.role, + domainId: mailbox.domain_id, + surface: granted, + aud: granted === "admin" ? "lambdamail:admin" : "lambdamail:user", + mfaSatisfied: factorProven, + ...(factorProven ? { mfaSatisfiedAt: Date.now() } : {}), + purpose: "session", + }); + if (!primaryToken) primaryToken = token; - const cookieName = surface === "admin" ? "lm_admin_session" : "lm_user_session"; - res.setHeader("Set-Cookie", sessionCookie(cookieName, sessionToken, SESSION_TTL_SECONDS, isSecureRequest())); + // Each token is recorded separately, or "sign out this device" would revoke + // one audience and leave the other working. + await repo.recordSession( + mailbox.id, + granted, + crypto.createHash("sha256").update(token).digest("hex"), + new Date(Date.now() + SESSION_TTL_SECONDS * 1000), + clientIp(req), + req.headers["user-agent"] ?? null, + ); + + cookies.push(sessionCookie(cookieNameFor(granted), token, SESSION_TTL_SECONDS, isSecureRequest())); + } + + res.setHeader("Set-Cookie", cookies); sendJson(res, 200, { - token: sessionToken, + token: primaryToken, surface, role: mailbox.role, locale: mailbox.locale, - mfa_satisfied: true, + mfa_satisfied: factorProven, mfa_enrollment_required: enrollmentRequired, }); } @@ -349,7 +461,7 @@ async function mfaVerify(req: IncomingMessage, res: ServerResponse): Promise, expiresInSeconds = 28800): string { +export function createJwt( + payload: Omit, + expiresInSeconds = 28800, +): string { const header = { alg: "HS256", typ: "JWT" }; const now = Math.floor(Date.now() / 1000); const fullPayload: SessionTokenPayload = { ...payload, + // Minted here rather than by each caller, so no path can forget it and + // reintroduce the collision described on the field. + jti: crypto.randomUUID(), iat: now, exp: now + expiresInSeconds, }; diff --git a/services/auth/src/surfaceAccess.test.ts b/services/auth/src/surfaceAccess.test.ts new file mode 100644 index 0000000..38f372b --- /dev/null +++ b/services/auth/src/surfaceAccess.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from "vitest"; + +process.env.JWT_SECRET ||= "a-test-signing-secret-long-enough-for-the-guard"; + +const { createJwt, verifyJwt, isSurfaceAuthorized } = await import("./session.js"); +const { secondFactorRequired, surfacesGrantedBy, cookieNameFor, stepUpNeedsSecondFactor } = + await import("./surfaceAccess.js"); + +/** + * Which surface a login opens, and what it costs to cross between them. + * + * The rules used to be spread through the login handler and got two things + * wrong that made the app tiresome to use. Reading mail demanded a second + * factor, which the mailbox does not require - only the console does. And a + * session issued at the admin sign-in granted the admin audience alone, so + * leaving the console landed on the webmail's login screen even though the + * operator had authenticated a moment earlier, with a stronger factor. + */ + +describe("what each surface costs to open", () => { + // The webmail is the account's own mail behind the account's own password. + it("does not demand a second factor to read mail", () => { + expect(secondFactorRequired("user")).toBe(false); + }); + + // The console changes other people's mailboxes, domains and DNS. + it("always demands one for the admin console", () => { + expect(secondFactorRequired("admin")).toBe(true); + }); +}); + +describe("what a session grants", () => { + it("gives a webmail sign-in the webmail and nothing more", () => { + expect(surfacesGrantedBy("user")).toEqual(["user"]); + }); + + // Proving a second factor is strictly more than the webmail asks for, so + // being sent back to a password prompt on the way out of the console was + // asking for something already provided. + it("gives an admin sign-in the webmail as well, since it costs strictly more", () => { + expect(surfacesGrantedBy("admin")).toEqual(["admin", "user"]); + }); + + it("names a separate cookie per surface, so neither overwrites the other", () => { + expect(cookieNameFor("user")).toBe("lm_user_session"); + expect(cookieNameFor("admin")).toBe("lm_admin_session"); + }); +}); + +describe("crossing from the webmail into the console", () => { + const userSession = () => + createJwt({ + sub: "1", email: "a@b.test", role: "SUPER_ADMIN", domainId: "d", + surface: "user", aud: "lambdamail:user", mfaSatisfied: false, purpose: "session", + }); + + // The point of the step-up: the password was already proven by the session in + // hand, so only the factor the console adds is asked for. + it("asks for the second factor and not for the password again", () => { + const decision = stepUpNeedsSecondFactor(userSession()); + expect(decision).toBe(true); + }); + + it("refuses to step up from a challenge token, which proves only a password", () => { + const challenge = createJwt({ + sub: "1", email: "a@b.test", role: "SUPER_ADMIN", domainId: "d", + surface: "user", aud: "lambdamail:user", mfaSatisfied: false, purpose: "mfa_challenge", + }); + expect(stepUpNeedsSecondFactor(challenge)).toBe(false); + }); + + it("refuses to step up from nothing at all", () => { + expect(stepUpNeedsSecondFactor(null)).toBe(false); + expect(stepUpNeedsSecondFactor("not-a-token")).toBe(false); + }); +}); + +describe("every issued session is its own session", () => { + const claims = { + sub: "1", email: "a@b.test", role: "USER" as const, domainId: "d", + surface: "user" as const, aud: "lambdamail:user" as const, + mfaSatisfied: false, purpose: "session" as const, + }; + + /** + * Signing in twice in the same second used to mint the identical token. + * + * Nothing in the payload varied below one second - iat and exp are in + * seconds - so two sessions hashed to the same value and the second insert + * hit the unique index on web_sessions.refresh_token_hash. The user saw a + * 500 from a correct password, which is what a double-clicked sign-in does. + */ + it("mints a different token for two sign-ins in the same second", () => { + expect(createJwt(claims)).not.toBe(createJwt(claims)); + }); + + // The two audiences an admin sign-in issues are recorded as separate rows, + // so they must not collide with each other either. + it("mints a different token per surface within one sign-in", () => { + const admin = createJwt({ ...claims, surface: "admin", aud: "lambdamail:admin" }); + expect(admin).not.toBe(createJwt(claims)); + }); + + // The identity has to survive verification, or a revoked session could not + // be told apart from a reissued one. + it("carries a distinct identifier through verification", () => { + const first = verifyJwt(createJwt(claims)); + const second = verifyJwt(createJwt(claims)); + expect(first?.jti).toBeTruthy(); + expect(first?.jti).not.toBe(second?.jti); + }); +}); + +describe("the guard on each surface", () => { + const session = (surface: "user" | "admin", mfaSatisfied: boolean) => + createJwt({ + sub: "1", email: "a@b.test", role: "SUPER_ADMIN", domainId: "d", + surface, + aud: surface === "admin" ? "lambdamail:admin" : "lambdamail:user", + mfaSatisfied, + purpose: "session", + }); + + // The webmail guard must agree with the login it issues, or a password-only + // sign-in would produce a session its own API refuses. + it("admits a webmail session that never proved a second factor", () => { + const payload = JSON.parse( + Buffer.from(session("user", false).split(".")[1], "base64url").toString(), + ); + expect(isSurfaceAuthorized(payload, "user")).toBe(true); + }); + + it("still refuses the console without one", () => { + const payload = JSON.parse( + Buffer.from(session("admin", false).split(".")[1], "base64url").toString(), + ); + expect(isSurfaceAuthorized(payload, "admin")).toBe(false); + }); +}); diff --git a/services/auth/src/surfaceAccess.ts b/services/auth/src/surfaceAccess.ts new file mode 100644 index 0000000..d9448bc --- /dev/null +++ b/services/auth/src/surfaceAccess.ts @@ -0,0 +1,61 @@ +import { verifyJwt } from "./session.js"; + +/** + * Which surface a login opens, and what crossing between them costs. + * + * These rules were spread through the login handler, where two of them were + * wrong in a way no test could reach: + * + * - Reading your own mail demanded a second factor. The mailbox is already + * behind the account's password; it is the console that needs more. + * - A session issued at the admin sign-in carried the admin audience alone, so + * leaving the console landed on the webmail's login screen - asking for a + * password from somebody who had just proven a password *and* a second + * factor. Proving more must never grant less. + */ + +export type Surface = "user" | "admin"; + +/** + * Whether the surface may only open once a second factor has been proven. + * + * The console can change any mailbox, any domain and the DNS records the mail + * flow depends on, which is why PLAN.md section 14.5 makes it unconditional + * there. The webmail is one account's own mail, so the password that owns it is + * the bar. An account may still enrol a factor and be asked for it on the way + * into the console. + */ +export function secondFactorRequired(surface: Surface): boolean { + return surface === "admin"; +} + +/** + * The surfaces a sign-in on `surface` should hand out sessions for. + * + * An admin sign-in also yields a webmail session because it satisfied strictly + * more than the webmail asks for. Issuing both at once is what makes leaving + * the console a plain link instead of another sign-in. + */ +export function surfacesGrantedBy(surface: Surface): Surface[] { + return surface === "admin" ? ["admin", "user"] : ["user"]; +} + +/** Cookies are per surface, so an admin session cannot displace a webmail one. */ +export function cookieNameFor(surface: Surface): string { + return surface === "admin" ? "lm_admin_session" : "lm_user_session"; +} + +/** + * Whether this token is a webmail session that may be stepped up to the + * console by presenting a second factor and nothing else. + * + * A challenge or enrolment token is refused: neither proves more than that a + * password was typed, and accepting one here would turn the step-up into a + * second way of skipping the factor it exists to collect. + */ +export function stepUpNeedsSecondFactor(token: string | null | undefined): boolean { + if (!token) return false; + const payload = verifyJwt(token); + if (!payload) return false; + return payload.purpose === "session" && payload.aud === "lambdamail:user"; +} diff --git a/services/protocols/internal/application/usecase/message_render.go b/services/protocols/internal/application/usecase/message_render.go index 1fe71ee..1871015 100644 --- a/services/protocols/internal/application/usecase/message_render.go +++ b/services/protocols/internal/application/usecase/message_render.go @@ -2,6 +2,7 @@ package usecase import ( "bytes" + "encoding/base64" "io" "strings" @@ -15,6 +16,12 @@ import ( // an out-of-memory kill. const maxRenderedPartBytes = 2 << 20 // 2 MiB +// maxInlineImageBytes bounds one inline image. Inline parts travel to the +// browser as data: URIs inside the JSON body, so an unbounded one would be +// held in memory here, base64-expanded on the wire and held again in the tab. +// Past this size the reader shows the alt text, which beats a stalled pane. +const maxInlineImageBytes = 2 << 20 // 2 MiB + // RenderedMessage is a message prepared for display: headers the reader shows // and the two body flavours, already decoded from their transfer encoding. type RenderedMessage struct { @@ -27,6 +34,15 @@ type RenderedMessage struct { Text string `json:"text"` HTML string `json:"html"` Attachments []string `json:"attachments"` + // InlineImages maps a part's Content-ID - without the angle brackets, the + // way the HTML body spells it in src="cid:..." - to a self-contained + // data: URI. + // + // Without this the reader had nothing to resolve cid: against, so every + // message written by a normal mail client displayed broken images. They + // are inlined rather than served from an endpoint so that the reader pane + // stays a sandboxed frame with no network identity of its own. + InlineImages map[string]string `json:"inline_images"` } // RenderMessage turns raw RFC 5322 bytes into what the reader pane needs. @@ -37,7 +53,10 @@ type RenderedMessage struct { // still yields its raw text rather than an error - unreadable formatting beats // an inbox that refuses to open a message. func RenderMessage(raw []byte, uid uint32) RenderedMessage { - out := RenderedMessage{UID: uid, To: []string{}, Cc: []string{}, Attachments: []string{}} + out := RenderedMessage{ + UID: uid, To: []string{}, Cc: []string{}, + Attachments: []string{}, InlineImages: map[string]string{}, + } entity, err := message.Read(bytes.NewReader(raw)) if err != nil { @@ -89,8 +108,25 @@ func collectParts(entity *message.Entity, out *RenderedMessage) { mediaType = "text/plain" } - if disposition, params, err := entity.Header.ContentDisposition(); err == nil && disposition == "attachment" { - name := params["filename"] + disposition, dispParams, dispErr := entity.Header.ContentDisposition() + if dispErr != nil { + disposition = "" + } + + // An inline image is part of the body, not a file to download: it is + // resolved into the HTML and deliberately left out of the attachment list, + // or every message with a logo in its signature grows a download chip. + if isInlineImageCandidate(disposition, mediaType, entity.Header.Get("Content-ID")) { + if collectInlineImage(entity, mediaType, out) { + return + } + } + + // "inline" counts as an attachment for anything that is not an inline + // image: a client that attaches a PDF without saying "attachment" still + // sent a file the reader has to be able to fetch. + if disposition == "attachment" || (disposition == "inline" && dispParams["filename"] != "") { + name := dispParams["filename"] if name == "" { name = "attachment" } @@ -111,6 +147,42 @@ func collectParts(entity *message.Entity, out *RenderedMessage) { } } +// isInlineImageCandidate reports whether this part is an image the body can +// reference. A Content-ID is what makes it referenceable; image/svg+xml is +// excluded because an SVG is a document that carries script, and it is the one +// image type a data: URI must never smuggle into the reader pane. +func isInlineImageCandidate(disposition, mediaType, contentID string) bool { + if contentID == "" && disposition != "inline" { + return false + } + if !strings.HasPrefix(strings.ToLower(mediaType), "image/") { + return false + } + return !strings.EqualFold(mediaType, "image/svg+xml") +} + +// collectInlineImage records one part as a data: URI, and reports whether it +// took ownership of the part. An image too large to inline is claimed as well: +// falling through would list it as an attachment, which is not what the sender +// meant by embedding it in the body. +func collectInlineImage(entity *message.Entity, mediaType string, out *RenderedMessage) bool { + contentID := strings.Trim(strings.TrimSpace(entity.Header.Get("Content-ID")), "<>") + if contentID == "" { + return false + } + + // One byte past the limit is read so that an oversized part is recognised + // as oversized rather than silently truncated into a corrupt image. + body, err := io.ReadAll(io.LimitReader(entity.Body, maxInlineImageBytes+1)) + if err != nil || len(body) > maxInlineImageBytes { + return true + } + + out.InlineImages[contentID] = "data:" + strings.ToLower(mediaType) + ";base64," + + base64.StdEncoding.EncodeToString(body) + return true +} + // ExtractAttachment walks the MIME tree and returns bytes and content-type for a named attachment. func ExtractAttachment(entity *message.Entity, targetName string) ([]byte, string) { if multipart := entity.MultipartReader(); multipart != nil { diff --git a/services/protocols/internal/application/usecase/message_render_test.go b/services/protocols/internal/application/usecase/message_render_test.go index 72977e4..cc55543 100644 --- a/services/protocols/internal/application/usecase/message_render_test.go +++ b/services/protocols/internal/application/usecase/message_render_test.go @@ -1,6 +1,11 @@ package usecase -import "testing" +import ( + "bytes" + "testing" + + "github.com/emersion/go-message" +) func TestRenderMessage_ExtractsHeadersAndText(t *testing.T) { raw := "From: Alice \r\n" + @@ -57,3 +62,110 @@ func TestRenderMessage_FallsBackToRawOnParseFailure(t *testing.T) { t.Error("a malformed message produced no body at all") } } + +// An inline image is referenced from the HTML body as cid:. Neither +// the part nor its identifier used to leave this function, so every message +// written by a normal mail client rendered with broken image placeholders and +// no way for the reader to resolve them. +func TestRenderMessage_ReturnsInlineImagesKeyedByContentID(t *testing.T) { + // "iVBORw0KGgo=" is the leading bytes of a PNG, base64 encoded. + raw := "From: a@b.test\r\nSubject: s\r\n" + + "Content-Type: multipart/related; boundary=B\r\n\r\n" + + "--B\r\nContent-Type: text/html\r\n\r\n

\r\n" + + "--B\r\nContent-Type: image/png\r\n" + + "Content-ID: \r\n" + + "Content-Disposition: inline; filename=\"logo.png\"\r\n" + + "Content-Transfer-Encoding: base64\r\n\r\niVBORw0KGgo=\r\n--B--\r\n" + + got := RenderMessage([]byte(raw), 1) + + // Keyed without the angle brackets, which is how the HTML spells it. + src, ok := got.InlineImages["logo@x"] + if !ok { + t.Fatalf("InlineImages = %v, want an entry for logo@x", got.InlineImages) + } + if src != "data:image/png;base64,iVBORw0KGgo=" { + t.Errorf("InlineImages[logo@x] = %q", src) + } +} + +// An inline image already shown in the body is not a file the reader has to +// download; listing it as an attachment puts a chip under every signature. +func TestRenderMessage_DoesNotListInlineImagesAsAttachments(t *testing.T) { + raw := "From: a@b.test\r\nSubject: s\r\n" + + "Content-Type: multipart/related; boundary=B\r\n\r\n" + + "--B\r\nContent-Type: text/html\r\n\r\n\r\n" + + "--B\r\nContent-Type: image/gif\r\n" + + "Content-ID: \r\nContent-Disposition: inline; filename=\"sig.gif\"\r\n" + + "Content-Transfer-Encoding: base64\r\n\r\nR0lGOD==\r\n--B--\r\n" + + if got := RenderMessage([]byte(raw), 1); len(got.Attachments) != 0 { + t.Errorf("Attachments = %v, want none", got.Attachments) + } +} + +// A file sent inline that is not an image - a PDF a client attached without a +// disposition of "attachment" - is still something the reader must be able to +// download, so it belongs in the attachment list. +func TestRenderMessage_ListsNonImageInlinePartsAsAttachments(t *testing.T) { + raw := "From: a@b.test\r\nSubject: s\r\n" + + "Content-Type: multipart/mixed; boundary=B\r\n\r\n" + + "--B\r\nContent-Type: text/plain\r\n\r\nbody\r\n" + + "--B\r\nContent-Type: application/pdf; name=\"report.pdf\"\r\n" + + "Content-Disposition: inline; filename=\"report.pdf\"\r\n\r\nPDFDATA\r\n--B--\r\n" + + got := RenderMessage([]byte(raw), 1) + if len(got.Attachments) != 1 || got.Attachments[0] != "report.pdf" { + t.Errorf("Attachments = %v, want [report.pdf]", got.Attachments) + } +} + +// An image big enough to matter is not worth inlining into a JSON response +// that the browser holds in memory; the reader shows the alt text instead. +func TestRenderMessage_SkipsOversizedInlineImages(t *testing.T) { + huge := make([]byte, maxInlineImageBytes+1024) + for i := range huge { + huge[i] = 'A' + } + raw := "From: a@b.test\r\nSubject: s\r\n" + + "Content-Type: multipart/related; boundary=B\r\n\r\n" + + "--B\r\nContent-Type: text/html\r\n\r\n\r\n" + + "--B\r\nContent-Type: image/png\r\nContent-ID: \r\n\r\n" + string(huge) + "\r\n--B--\r\n" + + if got := RenderMessage([]byte(raw), 1); len(got.InlineImages) != 0 { + t.Errorf("InlineImages has %d entries, want none", len(got.InlineImages)) + } +} + +// image/svg+xml is a document: it carries script and is the one image type a +// data: URI must never smuggle onto the reader pane. +func TestRenderMessage_RefusesToInlineSvg(t *testing.T) { + raw := "From: a@b.test\r\nSubject: s\r\n" + + "Content-Type: multipart/related; boundary=B\r\n\r\n" + + "--B\r\nContent-Type: text/html\r\n\r\n\r\n" + + "--B\r\nContent-Type: image/svg+xml\r\nContent-ID: \r\n\r\n\r\n--B--\r\n" + + if got := RenderMessage([]byte(raw), 1); len(got.InlineImages) != 0 { + t.Errorf("InlineImages = %v, want svg refused", got.InlineImages) + } +} + +// The chip the reader draws for an inline PDF links at the download endpoint, +// which resolves the name through here. Matching only on a disposition of +// "attachment" made every one of those chips 404. +func TestExtractAttachment_FindsInlineNamedParts(t *testing.T) { + raw := "From: a@b.test\r\nSubject: s\r\n" + + "Content-Type: multipart/mixed; boundary=B\r\n\r\n" + + "--B\r\nContent-Type: text/plain\r\n\r\nbody\r\n" + + "--B\r\nContent-Type: application/pdf\r\n" + + "Content-Disposition: inline; filename=\"report.pdf\"\r\n\r\nPDFDATA\r\n--B--\r\n" + + entity, err := message.Read(bytes.NewReader([]byte(raw))) + if err != nil { + t.Fatalf("parse: %v", err) + } + data, ctype := ExtractAttachment(entity, "report.pdf") + if string(data) != "PDFDATA" || ctype != "application/pdf" { + t.Errorf("ExtractAttachment = %q/%q", data, ctype) + } +} diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index 7062c81..60a82ef 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -89,11 +89,15 @@ "genericError": "Something went wrong", "invalidCredentials": "Invalid email or password", "mfaRequired": "Enter the six-digit code from your authenticator app.", + "orRecoveryCode": "Lost your phone? A recovery code works here too.", "passwordLabel": "Password", "qrCodeAlt": "QR code for two-factor setup", "scanQr": "Scan this code with Google Authenticator, Aegis, Authy or 1Password.", "signInButton": "Sign in", "signOutOfAdmin": "Leave admin", + "stepUpIntro": "You are already signed in, so only your second factor is needed. Your password is not asked for again.", + "stepUpSignedInAs": "Signed in as {email}", + "stepUpTitle": "Open the admin console", "totpCodeLabel": "Authentication code", "userLoginTitle": "Sign in to your mailbox", "verificationFailed": "Verification failed", @@ -145,7 +149,9 @@ "draftSaved": "Draft saved automatically", "draftSaving": "Saving draft...", "drafts": "Drafts", + "emptyBody": "This message has no text body.", "forward": "Forward", + "imagesEnabled": "Images loaded", "hideCcBcc": "- Hide Cc/Bcc", "inbox": "Inbox", "insertLink": "Insert link", @@ -153,9 +159,18 @@ "italic": "Italic", "junk": "Spam", "loadImages": "Load images", + "markRead": "Mark as read", + "markUnread": "Mark as unread", + "messagesInFolder": "{count} messages", + "noMatches": "Nothing matched that search", "noMessages": "No messages", + "noSubject": "(No subject)", "numberedList": "Numbered list", + "remoteContentBlocked": "Remote images were blocked so this message cannot track you.", "reply": "Reply", + "searchMatches": "{count} found", + "searchPlaceholder": "Search (from:, subject:)...", + "selectAMessage": "Select a message to read it", "sendMessage": "Send message", "sending": "Sending...", "sent": "Sent", @@ -232,6 +247,7 @@ "appPasswordsSection": "App passwords", "attempts": "Attempts", "bounceRate": "Bounce rate", + "ccRecipients": "Cc", "changePassword": "Change password", "clientIp": "Client IP", "copyPasswordNow": "Copy it now. It is not shown again.", diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index fe00849..a4bd6dc 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -89,11 +89,15 @@ "genericError": "Algo salió mal", "invalidCredentials": "Correo o contraseña inválidos", "mfaRequired": "Introduzca el código de seis dígitos de su aplicación de autenticación.", + "orRecoveryCode": "¿Perdiste el teléfono? Aquí también sirve un código de recuperación.", "passwordLabel": "Contraseña", "qrCodeAlt": "Código QR para configurar la verificación en dos pasos", "scanQr": "Escanee este código con Google Authenticator, Aegis, Authy o 1Password.", "signInButton": "Iniciar sesión", "signOutOfAdmin": "Salir de administración", + "stepUpIntro": "Ya has iniciado sesión, así que solo hace falta tu segundo factor. No se te pide la contraseña otra vez.", + "stepUpSignedInAs": "Sesión iniciada como {email}", + "stepUpTitle": "Abrir la consola de administración", "totpCodeLabel": "Código de autenticación", "userLoginTitle": "Acceda a su buzón", "verificationFailed": "Fallo en la verificación", @@ -145,7 +149,9 @@ "draftSaved": "Borrador guardado automáticamente", "draftSaving": "Guardando borrador...", "drafts": "Borradores", + "emptyBody": "Este mensaje no tiene cuerpo de texto.", "forward": "Reenviar", + "imagesEnabled": "Imágenes cargadas", "hideCcBcc": "- Ocultar Cc/Cco", "inbox": "Bandeja de entrada", "insertLink": "Insertar enlace", @@ -153,9 +159,18 @@ "italic": "Cursiva", "junk": "Spam", "loadImages": "Cargar imágenes", + "markRead": "Marcar como leído", + "markUnread": "Marcar como no leído", + "messagesInFolder": "{count} mensajes", + "noMatches": "Nada coincide con esa búsqueda", "noMessages": "Sin mensajes", + "noSubject": "(Sin asunto)", "numberedList": "Lista numerada", + "remoteContentBlocked": "Se bloquearon las imágenes remotas para que este mensaje no pueda rastrearte.", "reply": "Responder", + "searchMatches": "{count} encontrados", + "searchPlaceholder": "Buscar (from:, subject:)...", + "selectAMessage": "Selecciona un mensaje para leerlo", "sendMessage": "Enviar mensaje", "sending": "Enviando...", "sent": "Enviados", @@ -232,6 +247,7 @@ "appPasswordsSection": "Contraseñas de aplicación", "attempts": "Intentos", "bounceRate": "Tasa de rebote", + "ccRecipients": "Cc", "changePassword": "Cambiar contraseña", "clientIp": "IP del cliente", "copyPasswordNow": "Cópiela ahora. No se mostrará de nuevo.", diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index 90bd292..1ae1bd0 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -89,11 +89,15 @@ "genericError": "Algo deu errado", "invalidCredentials": "E-mail ou senha inválidos", "mfaRequired": "Digite o código de seis dígitos do seu aplicativo autenticador.", + "orRecoveryCode": "Perdeu o celular? Um código de recuperação também funciona aqui.", "passwordLabel": "Senha", "qrCodeAlt": "Código QR para configurar a verificação em duas etapas", "scanQr": "Escaneie este código com o Google Authenticator, Aegis, Authy ou 1Password.", "signInButton": "Entrar", "signOutOfAdmin": "Sair da administração", + "stepUpIntro": "Você já está autenticado, então basta o segundo fator. A senha não é pedida de novo.", + "stepUpSignedInAs": "Autenticado como {email}", + "stepUpTitle": "Abrir o console administrativo", "totpCodeLabel": "Código de autenticação", "userLoginTitle": "Entre na sua caixa de e-mail", "verificationFailed": "Falha na verificação", @@ -145,7 +149,9 @@ "draftSaved": "Rascunho salvo automaticamente", "draftSaving": "Salvando rascunho...", "drafts": "Rascunhos", + "emptyBody": "Esta mensagem não tem corpo de texto.", "forward": "Encaminhar", + "imagesEnabled": "Imagens carregadas", "hideCcBcc": "- Ocultar Cc/Cco", "inbox": "Caixa de entrada", "insertLink": "Inserir link", @@ -153,9 +159,18 @@ "italic": "Itálico", "junk": "Spam", "loadImages": "Carregar imagens", + "markRead": "Marcar como lida", + "markUnread": "Marcar como não lida", + "messagesInFolder": "{count} mensagens", + "noMatches": "Nada corresponde a essa busca", "noMessages": "Nenhuma mensagem", + "noSubject": "(Sem assunto)", "numberedList": "Lista numerada", + "remoteContentBlocked": "As imagens remotas foram bloqueadas para que esta mensagem não possa rastrear você.", "reply": "Responder", + "searchMatches": "{count} encontradas", + "searchPlaceholder": "Buscar (from:, subject:)...", + "selectAMessage": "Selecione uma mensagem para ler", "sendMessage": "Enviar mensagem", "sending": "Enviando...", "sent": "Enviados", @@ -232,6 +247,7 @@ "appPasswordsSection": "Senhas de aplicativo", "attempts": "Tentativas", "bounceRate": "Taxa de devolução", + "ccRecipients": "Cc", "changePassword": "Alterar senha", "clientIp": "IP do cliente", "copyPasswordNow": "Copie agora. Ela não será exibida novamente.", diff --git a/services/webmail/src/app/(auth)/admin/step-up/page.tsx b/services/webmail/src/app/(auth)/admin/step-up/page.tsx new file mode 100644 index 0000000..2a9d64c --- /dev/null +++ b/services/webmail/src/app/(auth)/admin/step-up/page.tsx @@ -0,0 +1,180 @@ +"use client"; + +import React, { useState } from "react"; +import Link from "next/link"; +import { KeyRound, ShieldCheck, AlertCircle, Sliders } from "lucide-react"; +import { useTranslations } from "../../../../i18n/provider"; +import { useAccount } from "../../../../lib/useAccount"; +import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment"; + +/** + * Crossing from the webmail into the console. + * + * The password is not asked for again - the session that got here proved it - + * so this collects the one thing the console adds over the webmail: the second + * factor. Sending an operator who was already signed in to a full sign-in form + * was the app forgetting what it had already been told. + * + * An account with no factor yet is walked through enrolling one here rather + * than being sent off to find the settings screen. + */ +export default function AdminStepUpPage() { + const t = useTranslations(); + const account = useAccount("user"); + + const [code, setCode] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [enrolmentToken, setEnrolmentToken] = useState(null); + const [enrolSecret, setEnrolSecret] = useState(null); + const [enrolUri, setEnrolUri] = useState(null); + const [recoveryCodes, setRecoveryCodes] = useState(null); + + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setLoading(true); + + try { + // Confirming a fresh enrolment, using the grant the step-up handed back. + if (enrolmentToken) { + const res = await fetch("/api/v1/user/mfa/totp/confirm", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${enrolmentToken}` }, + body: JSON.stringify({ code }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.message || t("auth.verificationFailed")); + setRecoveryCodes(data.recovery_codes ?? []); + setEnrolmentToken(null); + setEnrolSecret(null); + setCode(""); + return; + } + + const res = await fetch("/api/v1/auth/admin/step-up", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code }), + }); + const data = await res.json(); + + // No factor enrolled. The grant that comes back permits enrolling one and + // nothing else, so the operator can do it without leaving this screen. + if (res.status === 403 && data.error === "MFA_ENROLLMENT_REQUIRED" && data.enrollment_token) { + const enroll = await fetch("/api/v1/user/mfa/totp/enroll", { + method: "POST", + headers: { Authorization: `Bearer ${data.enrollment_token}` }, + }); + const enrolData = await enroll.json(); + if (!enroll.ok) throw new Error(enrolData.message || t("auth.enrolmentFailed")); + setEnrolmentToken(data.enrollment_token); + setEnrolSecret(enrolData.secret); + setEnrolUri(enrolData.uri); + setCode(""); + return; + } + + // A session with no webmail session behind it cannot step up; the sign-in + // is where that starts. + if (res.status === 401) { + window.location.href = "/user/login?next=/admin/step-up"; + return; + } + if (!res.ok) throw new Error(data.message || t("auth.verificationFailed")); + + window.location.href = "/admin/dashboard"; + } catch (err: unknown) { + setError(err instanceof Error ? err.message : t("auth.genericError")); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+ +
+
+

{t("auth.stepUpTitle")}

+

+ {account?.email ? t("auth.stepUpSignedInAs", { email: account.email }) : t("common.loading")} +

+
+
+ + {error && ( +
+ + {error} +
+ )} + + {recoveryCodes ? ( + setRecoveryCodes(null)} + continueLabel={t("common.continue")} + /> + ) : ( +
+ {enrolSecret ? ( +
+
+ {t("auth.enrolmentIntro")} +
+ +
+ ) : ( +
+ + {t("auth.stepUpIntro")} +
+ )} + +
+ +
+ + setCode(e.target.value.replace(/\s/g, ""))} + placeholder="123456" + required + className="w-full rounded-xl border border-slate-800 bg-slate-900 pl-10 pr-4 py-3 text-center font-mono text-lg tracking-widest text-white focus:border-emerald-500 focus:outline-none" + /> +
+ {/* A recovery code is the way in when the phone is gone, and + nothing on the old screens said so. */} +

{t("auth.orRecoveryCode")}

+
+ + +
+ )} + +
+ + {t("admin.backToWebmail")} + +
+
+
+ ); +} diff --git a/services/webmail/src/app/(auth)/user/login/page.tsx b/services/webmail/src/app/(auth)/user/login/page.tsx index 5ecc471..c3299bc 100644 --- a/services/webmail/src/app/(auth)/user/login/page.tsx +++ b/services/webmail/src/app/(auth)/user/login/page.tsx @@ -1,14 +1,7 @@ "use client"; import React, { useState } from "react"; -import Link from "next/link"; -import { - Mail, - Lock, - ShieldCheck, - KeyRound, - AlertCircle, -} from "lucide-react"; +import { Mail, Lock, AlertCircle } from "lucide-react"; import { useTranslations } from "../../../../i18n/provider"; import { Card } from "../../../../components/ui/Card"; import { Button } from "../../../../components/ui/Button"; @@ -17,46 +10,40 @@ export default function UserLoginPage() { const t = useTranslations(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - const [mfaCode, setMfaCode] = useState(""); - const [challengeToken, setChallengeToken] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + /** + * One step, because the mailbox is one step. + * + * Reading your own mail is behind your own password; the second factor guards + * the console, and is collected on the way in there. This screen used to have + * a code step as well, so an account that had enrolled a factor could not open + * its inbox without its phone. + */ const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); setError(null); setLoading(true); try { - if (challengeToken) { - // Step 2: Verify TOTP code - const res = await fetch("/api/v1/auth/mfa/verify", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ challenge_token: challengeToken, code: mfaCode }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.message || "Two-factor verification failed"); - window.location.href = "/user/mail/inbox"; - return; - } - - // Step 1: Password authentication const res = await fetch("/api/v1/auth/user/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); const data = await res.json(); - if (!res.ok) throw new Error(data.message || "Invalid credentials"); + if (!res.ok) throw new Error(data.message || t("auth.invalidCredentials")); - if (data.mfa_required) { - setChallengeToken(data.challenge_token); - } else { - window.location.href = "/user/mail/inbox"; - } + // The destination the middleware attached when it sent an unauthenticated + // visitor here, so a deep link survives the sign-in. + // A path on this origin only: "//evil.test" also begins with a slash, and + // following it would turn the sign-in into an open redirect. + const next = new URLSearchParams(window.location.search).get("next"); + const safe = next && next.startsWith("/") && !next.startsWith("//"); + window.location.href = safe ? next : "/user/mail/inbox"; } catch (err: unknown) { - setError(err instanceof Error ? err.message : "Ocorreu um erro ao autenticar"); + setError(err instanceof Error ? err.message : t("auth.genericError")); } finally { setLoading(false); } @@ -87,68 +74,46 @@ export default function UserLoginPage() { )}
- {!challengeToken ? ( - <> -
- -
- - setEmail(e.target.value)} - placeholder="user@domain.com" - required - className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors" - /> -
-
- -
- -
- - setPassword(e.target.value)} - placeholder="************" - required - className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors" - /> -
-
- - ) : ( -
-
- - {t("auth.mfaRequired")} -
- -
- - setMfaCode(e.target.value.replace(/\D/g, "").slice(0, 6))} - placeholder="123456" - maxLength={7} - required - className="w-full pl-10 pr-4 py-3 rounded-xl bg-slate-900/90 border border-slate-800 text-white text-center tracking-widest text-lg font-mono focus:outline-none focus:border-indigo-500 transition-colors" - /> -
+
+ +
+ + setEmail(e.target.value)} + placeholder="user@domain.com" + required + className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors" + />
- )} +
- diff --git a/services/webmail/src/app/(user)/user/layout.tsx b/services/webmail/src/app/(user)/user/layout.tsx index 647ba1f..c81f0e7 100644 --- a/services/webmail/src/app/(user)/user/layout.tsx +++ b/services/webmail/src/app/(user)/user/layout.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { motion, AnimatePresence } from "framer-motion"; @@ -24,29 +24,17 @@ import { import { useTranslations } from "../../../i18n/provider"; import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher"; import { useAccount, isAdminRole } from "../../../lib/useAccount"; - -interface FolderCount { - special_use: string; - name: string; - unread_count: number; -} +import { useFolders } from "../../../lib/useFolders"; +import { badgeCount, folderMetrics } from "../../../lib/mailCounts"; export default function UserWebmailLayout({ children }: { children: React.ReactNode }) { const t = useTranslations(); const pathname = usePathname(); - const [folders, setFolders] = useState([]); const [mobileOpen, setMobileOpen] = useState(false); const account = useAccount("user"); - - useEffect(() => { - void fetch("/api/v1/mail/folders") - .then((res) => (res.ok ? res.json() : [])) - .then(setFolders) - .catch(() => undefined); - }, []); - - const unreadFor = (role: string) => - folders.find((f) => f.special_use === role || f.name.toLowerCase() === role)?.unread_count ?? 0; + // Live, not fetched once: these badges used to freeze at the value they had + // when the tab was opened. + const { folders } = useFolders(); const navItems = [ { href: "/user/mail/inbox", label: t("mail.inbox"), icon: Inbox, role: "inbox" }, @@ -93,31 +81,43 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN {navItems.map((item) => { const Icon = item.icon; const isActive = pathname === item.href || (item.role === "inbox" && pathname === "/user/mail"); - const unread = unreadFor(item.role); + // Drafts counts what is waiting rather than what is unread; see + // badgeCount, which is where that rule is tested. + const badge = badgeCount(folders, item.role); + const { total } = folderMetrics(folders, item.role); return ( 0 ? t("mail.messagesInFolder", { count: total }) : item.label} className={`flex items-center justify-between px-3.5 py-2.5 rounded-xl transition-all duration-150 ${ isActive ? "bg-indigo-600/20 text-indigo-300 border border-indigo-500/30 shadow-sm" : "text-slate-400 hover:text-slate-200 hover:bg-slate-800/40 border border-transparent" }`} > -
- - {item.label} +
+ + {item.label} +
+
+ {/* The folder's size, shown quietly beside the badge: the + sidebar reported unread only, so there was nowhere in the + interface that said how much mail a folder holds. */} + {total > 0 && ( + {total} + )} + {badge > 0 && ( + + {badge} + + )}
- {unread > 0 && ( - - {unread} - - )} ); })} @@ -139,13 +139,13 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
{/* The console is reachable from inside the app for the accounts that - may open it, which is why there is no longer a surface chooser in - front of the sign-in. It is a plain link to the admin sign-in, not a - direct jump: the admin audience is a separate token, so crossing - over means proving the second factor again. */} + may open it. It goes to the step-up rather than to the admin sign-in: + the admin audience is a separate token, so crossing over costs a + second factor - but not the password, which the session in hand + proved already. */} {isAdminRole(account?.role) && ( diff --git a/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx b/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx index b16cf56..84ca9b4 100644 --- a/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx +++ b/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { use, useCallback, useEffect, useState } from "react"; +import React, { use, useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { motion, AnimatePresence } from "framer-motion"; import { @@ -9,17 +9,26 @@ import { Reply, Forward, Inbox, - Filter, Image as ImageIcon, Mail, Clock, - User, - ExternalLink, + MailOpen, + Download, + X, } from "lucide-react"; -import { useTranslations } from "../../../../../i18n/provider"; +import { useI18n } from "../../../../../i18n/provider"; import { useMailEvents } from "../../../../../lib/useMailEvents"; +import { useFolders, notifyMailStateChanged } from "../../../../../lib/useFolders"; +import { + folderMetrics, + filterMessages, + messageCounts, + listHeaderCount, + applySeen, + type ListFilter, +} from "../../../../../lib/mailCounts"; import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer"; -import { Badge } from "../../../../../components/ui/Badge"; +import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody"; import { Button } from "../../../../../components/ui/Button"; interface MessageSummary { @@ -46,19 +55,69 @@ interface RenderedMessage { text: string; html: string; attachments: string[]; + // The message's own pictures, keyed by Content-ID. Without these every + // cid: reference in the body is unresolvable and renders as a broken icon. + inline_images: InlineImages; +} + +/** The initials shown in the sender avatar, from a display name or an address. */ +function initials(name: string, address: string): string { + const source = (name || address || "?").trim(); + const parts = source.replace(/[<>"]/g, "").split(/[\s.@_-]+/).filter(Boolean); + const letters = parts.slice(0, 2).map((p) => p[0]); + return (letters.join("") || "?").toUpperCase(); +} + +/** + * A timestamp as a mail client shows one: the time for today, the date for + * anything older. A fixed time-only format made every message in the list look + * like it arrived today. + */ +function listTimestamp(iso: string, locale: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ""; + const now = new Date(); + const sameDay = + date.getDate() === now.getDate() && + date.getMonth() === now.getMonth() && + date.getFullYear() === now.getFullYear(); + return sameDay + ? date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }) + : date.toLocaleDateString(locale, { day: "2-digit", month: "short" }); +} + +function formatBytes(bytes: number): string { + if (!bytes || bytes < 0) return ""; + const units = ["B", "KB", "MB", "GB"]; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`; } export default function MailFolderPage({ params }: { params: Promise<{ folder: string }> }) { const { folder } = use(params); - const t = useTranslations(); + // The locale is needed alongside the translator: timestamps are formatted + // against it, and a fixed format is why the list rendered every date the same + // way whatever language the interface was in. + const { t, locale } = useI18n(); const [messages, setMessages] = useState([]); const [selected, setSelected] = useState(null); const [loadRemoteImages, setLoadRemoteImages] = useState(false); const [searchQuery, setSearchQuery] = useState(""); - const [activeFilter, setActiveFilter] = useState(null); + const [activeFilter, setActiveFilter] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // The frame is sized from the message rather than pinned to a guess: 500px + // clipped every long message and left white space under every short one. + const [readerHeight, setReaderHeight] = useState(420); + + const { folders } = useFolders(); + const metrics = folderMetrics(folders, folder); const loadMessages = useCallback( async (search: string) => { @@ -66,7 +125,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s setError(null); try { const query = new URLSearchParams({ folder, ...(search ? { q: search } : {}) }); - const res = await fetch(`/api/v1/mail/messages?${query}`); + const res = await fetch(`/api/v1/mail/messages?${query}`, { cache: "no-store" }); if (res.status === 401) { window.location.href = "/user/login"; return; @@ -82,10 +141,6 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s [folder, t] ); - useEffect(() => { - void loadMessages(""); - }, [loadMessages]); - useMailEvents( useCallback( (event) => { @@ -97,87 +152,168 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s ) ); + // One effect drives both the first load and the search, debounced. Having a + // separate mount effect as well meant every folder change fired two requests + // and the later one could be the stale answer. useEffect(() => { - const handle = setTimeout(() => void loadMessages(searchQuery), 250); + const handle = setTimeout(() => void loadMessages(searchQuery), searchQuery ? 250 : 0); return () => clearTimeout(handle); }, [searchQuery, loadMessages]); + // The reader frame reports its own height; nothing outside a sandboxed frame + // can measure it. + useEffect(() => { + const onMessage = (event: MessageEvent) => { + const data = event.data as { type?: string; height?: number } | null; + if (data?.type !== "lm:reader-height" || typeof data.height !== "number") return; + // Clamped so a message that reports something absurd cannot produce a + // frame kilometres tall or one collapsed to nothing. + setReaderHeight(Math.min(20000, Math.max(200, Math.ceil(data.height)))); + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, []); + + /** + * Records a read-state change on the server and everywhere it is displayed. + * + * Opening a message already marked it read server-side, but the list held its + * own optimistic copy and the sidebar badge held another, so the three + * disagreed until the page was reloaded. + */ + const setSeen = useCallback( + async (uid: number, seen: boolean, { persist }: { persist: boolean }) => { + setMessages((current) => applySeen(current, uid, seen)); + notifyMailStateChanged(); + if (!persist) return; + try { + await fetch("/api/v1/mail/seen", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ folder, uid, seen }), + }); + } catch { + // The server is the authority; the next push or reload corrects this. + } finally { + notifyMailStateChanged(); + } + }, + [folder] + ); + const openMessage = async (uid: number) => { setLoadRemoteImages(false); + setReaderHeight(420); try { - const res = await fetch(`/api/v1/mail/message/${uid}?folder=${encodeURIComponent(folder)}`); + const res = await fetch(`/api/v1/mail/message/${uid}?folder=${encodeURIComponent(folder)}`, { + cache: "no-store", + }); if (!res.ok) throw new Error(t("errors.serverError")); - const data = await res.json(); - setSelected(data); - setMessages((current) => current.map((m) => (m.uid === uid ? { ...m, seen: true } : m))); + setSelected(await res.json()); + // Fetching the message is what marks it read on the server, so this only + // brings the list and the badges into line with what already happened. + void setSeen(uid, true, { persist: false }); } catch (err) { setError(err instanceof Error ? err.message : t("errors.serverError")); } }; - const filteredMessages = messages.filter((msg) => { - if (activeFilter === "unread") return !msg.seen; - if (activeFilter === "attachment") return msg.has_attachments; - return true; + const filteredMessages = filterMessages(messages, activeFilter); + const counts = messageCounts(messages); + const header = listHeaderCount({ + folder: metrics, + loaded: counts.loaded, + searching: searchQuery.trim().length > 0, }); + const selectedRow = messages.find((m) => m.uid === selected?.uid); - const sanitizedHtml = selected?.html ? sanitizeEmailHtml(selected.html) : null; - const bodyHtml = sanitizedHtml - ? loadRemoteImages - ? unblockRemoteImages(sanitizedHtml) - : blockRemoteImages(sanitizedHtml) - : null; + /** + * The document the reader frame renders. + * + * Order matters: sanitise first so nothing downstream handles untrusted + * markup, then resolve the message's own inline parts, then block or restore + * remote sources - which is the only part the reader chooses. + */ + const readerDocument = useMemo(() => { + if (!selected) return null; + if (!selected.html) return null; + const sanitized = sanitizeEmailHtml(selected.html); + if (!sanitized) return null; + const withInline = resolveInlineImages(sanitized, selected.inline_images ?? {}); + const body = loadRemoteImages ? unblockRemoteImages(withInline) : blockRemoteImages(withInline); + return buildReaderDocument(body); + }, [selected, loadRemoteImages]); + + // Whether anything was actually held back, so the privacy banner is not shown + // over a message that has no remote content at all. + const hasBlockedContent = useMemo(() => { + if (!selected?.html) return false; + const sanitized = resolveInlineImages( + sanitizeEmailHtml(selected.html), + selected.inline_images ?? {} + ); + return blockRemoteImages(sanitized) !== sanitized; + }, [selected]); + + const chips: { key: ListFilter; label: string; count: number }[] = [ + { key: null, label: t("mail.allMessages"), count: header.count }, + { key: "unread", label: t("mail.unreadFilter"), count: counts.unread }, + { key: "attachment", label: t("mail.attachmentsFilter"), count: counts.withAttachments }, + ]; return (
{/* Messages List Column */}
+ {/* Folder heading, with the counts that belong to the folder rather + than to the page of it that happens to be loaded. */} +
+

{folder}

+ + {header.isFolderTotal + ? t("mail.messagesInFolder", { count: header.count }) + : t("mail.searchMatches", { count: header.count })} + {metrics.unread > 0 && ( + · {t("mail.unreadCount", { count: metrics.unread })} + )} + +
+ {/* Search Bar & Filter Chips */}
setSearchQuery(e.target.value)} - placeholder={`${t("common.search")} (from:, subject:)...`} + placeholder={t("mail.searchPlaceholder")} + aria-label={t("common.search")} className="w-full pl-9 pr-3 py-2 text-xs rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors" />
- - - + {chips.map((chip) => ( + + ))}
@@ -193,9 +329,9 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s {error}
) : filteredMessages.length === 0 ? ( -
+
- {t("mail.noMessages")} + {searchQuery ? t("mail.noMatches") : t("mail.noMessages")}
) : ( filteredMessages.map((msg) => { @@ -204,43 +340,54 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s ); }) @@ -250,142 +397,179 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s {/* Message Reader Column */}
- {selected && ( - - )} {selected ? ( - - {/* Header & Reply/Forward Actions */} -
-
-

- {selected.subject || "(Sem assunto)"} -

+ {/* Toolbar: closing, replying and read state all in one row, so + the reader is not the only pane with no way out of itself. */} +
+ - {/* Action Buttons */} -
- - - - - - - -
+
+ + + + + {/* Forward carries the subject but no recipient: it used to + prefill the original sender, which sends the message + straight back to whoever wrote it. */} + + +
+
-
-
-
- -
-
-
{selected.from}
- {selected.to.length > 0 && ( -
- Para: {selected.to.join(", ")} -
- )} -
+
+

+ {selected.subject || t("mail.noSubject")} +

+ +
+
+ {initials(selectedRow?.from_display_name ?? "", selected.from)}
-
- {selected.date ? new Date(selected.date).toLocaleString() : ""} +
+
+ {selected.from} + + {selected.date + ? new Date(selected.date).toLocaleString(locale) + : selectedRow + ? new Date(selectedRow.received_at).toLocaleString(locale) + : ""} + +
+ {/* Recipients are labelled with translated headings; they + used to be introduced by a Portuguese word regardless of + the interface language. */} + {selected.to.length > 0 && ( +
+ {t("ui.toRecipients")}: + {selected.to.join(", ")} +
+ )} + {selected.cc.length > 0 && ( +
+ {t("ui.ccRecipients")}: + {selected.cc.join(", ")} +
+ )} + {selectedRow && selectedRow.size_bytes > 0 && ( +
+ {formatBytes(selectedRow.size_bytes)} +
+ )}
{/* Attachment Download Chips */} {selected.attachments.length > 0 && ( -
+
{selected.attachments.map((filename, idx) => ( - - {filename} - + + {filename} + ))}
)} -
+
- {/* Remote Images Privacy Banner */} - {selected.html && ( -
-
- - {t("mail.loadImages")} (sanitised) -
+ {/* Remote content banner, shown only when something was actually + held back rather than over every message. */} + {hasBlockedContent && ( +
+ + + {t("mail.remoteContentBlocked")} +
)} - {/* Message Body Container */} - {bodyHtml ? ( -
+ {/* Message Body */} + {readerDocument ? ( +