#34: fix the mail counters, the reader pane, and the state between surfaces
The counts were computed inline in JSX where nothing could test them. The
sidebar matched a folder by a special-use role folders do not always carry, its
badges were fetched once and then froze - new mail did not raise them and
reading a message did not lower it - and the list header printed the size of
the loaded page, capped at 50, as the size of the folder. The rules now live in
mailCounts.ts behind tests, useFolders keeps them current from the server's
push and from this tab's own changes, and each filter chip carries its number.
Inline images never left the server: RenderMessage dropped every part a body
references as cid:, so mail written by any normal client rendered with broken
icons. They now come back as bounded data URIs, refusing image/svg+xml, and the
reader resolves them, sizes its frame from the message instead of a fixed
500px, and renders on the light surface mail assumes.
Reading mail no longer asks for a second factor - the mailbox is behind the
account's own password - while the console still requires one unconditionally,
now collected by a step-up that does not ask again for the password the session
in hand just proved. An admin sign-in issues the webmail session too, so
leaving the console is a link rather than another login.
Two defects found while testing the above:
- The admin sign-in checked the password and the second factor and never the
role, so any account that had enrolled a factor from its own settings could
obtain an admin-audience session.
- Session tokens carried nothing that varied below one second, so signing in
twice within the same second minted the identical token and collided with
the unique index on web_sessions.refresh_token_hash. A correct password
answered 500, and a double-clicked sign-in was enough to do it. Tokens now
carry a jti.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e2ea58a4c9
commit
8b69521c76
25 files changed
+2209
-313
No files matched your search
@@ -168,12 +168,16 @@ describeDb("auth API against a real database", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// The second headline regression: any non-empty MFA code used to work.
|
// 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 () => {
|
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 login = await post("/api/v1/auth/user/login", { email: adminEmail(), password: PASSWORD });
|
||||||
const secret = await enrollTotp(USER_ID, login.body.token as unknown as string);
|
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/admin/login", { email: adminEmail(), password: PASSWORD });
|
||||||
const challenge = await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD });
|
|
||||||
expect(challenge.body.mfa_required).toBe(true);
|
expect(challenge.body.mfa_required).toBe(true);
|
||||||
|
|
||||||
const wrong = await post("/api/v1/auth/mfa/verify", {
|
const wrong = await post("/api/v1/auth/mfa/verify", {
|
||||||
@@ -306,7 +310,11 @@ describeDb("auth API against a real database", () => {
|
|||||||
}, 90000);
|
}, 90000);
|
||||||
|
|
||||||
it("grants the admin console after a real second factor", { timeout: 60000 }, async () => {
|
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 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);
|
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");
|
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 () => {
|
it("consumes a recovery code exactly once", { timeout: 60000 }, async () => {
|
||||||
const email = `rec@authtest-${DOMAIN_ID}.invalid`;
|
const email = `rec@authtest-${DOMAIN_ID}.invalid`;
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash)
|
`INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash, role)
|
||||||
VALUES ($1, $2, 'rec', $3, $4)`,
|
VALUES ($1, $2, 'rec', $3, $4, 'SUPER_ADMIN')`,
|
||||||
[id, DOMAIN_ID, email, await hashPassword(PASSWORD)],
|
[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 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 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", {
|
const first = await post("/api/v1/auth/mfa/verify", {
|
||||||
challenge_token: challenge.body.challenge_token,
|
challenge_token: challenge.body.challenge_token,
|
||||||
code: recoveryCode,
|
code: recoveryCode,
|
||||||
});
|
});
|
||||||
expect(first.status).toBe(200);
|
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", {
|
const second = await post("/api/v1/auth/mfa/verify", {
|
||||||
challenge_token: challenge2.body.challenge_token,
|
challenge_token: challenge2.body.challenge_token,
|
||||||
code: recoveryCode,
|
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<string, never>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
if (!hasDatabase) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn("TEST_DATABASE_URL not set - auth API integration tests skipped");
|
console.warn("TEST_DATABASE_URL not set - auth API integration tests skipped");
|
||||||
|
|||||||
+154
-42
@@ -3,6 +3,13 @@ import crypto from "node:crypto";
|
|||||||
import { generateTotpSecret, generateTotpUri } from "./totp.js";
|
import { generateTotpSecret, generateTotpUri } from "./totp.js";
|
||||||
import { generateRecoveryCodes, generateAppPassword } from "./mfaHelpers.js";
|
import { generateRecoveryCodes, generateAppPassword } from "./mfaHelpers.js";
|
||||||
import { createJwt, verifyJwt, isSurfaceAuthorized, type SessionTokenPayload } from "./session.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";
|
import * as repo from "./repository.js";
|
||||||
|
|
||||||
const CHALLENGE_TTL_SECONDS = 300;
|
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/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/admin/login" && method === "POST") return login(req, res, "admin");
|
||||||
if (url === "/api/v1/auth/mfa/verify" && method === "POST") return mfaVerify(req, res);
|
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/auth/logout" && method === "POST") return logout(res);
|
||||||
|
|
||||||
if (url === "/api/v1/user/me" && method === "GET") return me(res, session!);
|
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;
|
const { mailbox, mfaRequired } = outcome;
|
||||||
|
|
||||||
// An admin whose second factor is not enrolled must not fall through to a
|
// The console is for the roles that may operate it. This checked the password
|
||||||
// full session: the admin surface requires MFA unconditionally.
|
// and the second factor and never the role, so an ordinary account that had
|
||||||
if (surface === "admin" && !(await repo.hasConfirmedTotp(mailbox.id))) {
|
// enrolled a factor - which any account may do from its own settings - was
|
||||||
const enrolmentToken = createJwt(
|
// 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
|
||||||
sub: mailbox.id,
|
// could be verified into one.
|
||||||
email: mailbox.email_address,
|
if (surface === "admin" && mailbox.role !== "SUPER_ADMIN" && mailbox.role !== "DOMAIN_ADMIN") {
|
||||||
role: mailbox.role,
|
|
||||||
domainId: mailbox.domain_id,
|
|
||||||
surface: "user",
|
|
||||||
aud: "lambdamail:user",
|
|
||||||
mfaSatisfied: false,
|
|
||||||
purpose: "mfa_enrollment",
|
|
||||||
},
|
|
||||||
ENROLMENT_TTL_SECONDS,
|
|
||||||
);
|
|
||||||
return sendJson(res, 403, {
|
return sendJson(res, 403, {
|
||||||
error: "MFA_ENROLLMENT_REQUIRED",
|
error: "FORBIDDEN",
|
||||||
message: "The admin console requires a second factor. Enrol one to continue.",
|
message: "This account may not open the admin console",
|
||||||
enrollment_token: enrolmentToken,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
const challengeToken = createJwt(
|
||||||
{
|
{
|
||||||
sub: mailbox.id,
|
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 });
|
return sendJson(res, 200, { mfa_required: true, challenge_token: challengeToken });
|
||||||
}
|
}
|
||||||
|
|
||||||
// No factor enrolled yet. The session is issued so the user can reach the
|
// The session is issued so the user can reach the settings screen and enroll;
|
||||||
// settings screen and enroll; when policy obliges them to, the response says
|
// when policy obliges them to, the response says so and the UI keeps them on
|
||||||
// so and the UI keeps them on that screen.
|
// that screen.
|
||||||
await issueSession(req, res, mailbox, surface, repo.isMfaEnrollmentRequired(mailbox));
|
await issueSession(req, res, mailbox, surface, repo.isMfaEnrollmentRequired(mailbox));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function issueSession(
|
/**
|
||||||
req: IncomingMessage,
|
* Crosses a signed-in webmail session over into the console.
|
||||||
res: ServerResponse,
|
*
|
||||||
mailbox: repo.MailboxRecord,
|
* The password is not asked for again: the session presented here already
|
||||||
surface: "user" | "admin",
|
* proved it, minutes ago. What the console adds over the webmail is the second
|
||||||
enrollmentRequired = false,
|
* factor, so that is all this collects. Without this endpoint the only way over
|
||||||
): Promise<void> {
|
* was the admin sign-in form, which is why crossing surfaces used to mean
|
||||||
const sessionToken = createJwt({
|
* typing the password a second time.
|
||||||
|
*/
|
||||||
|
async function adminStepUp(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||||
|
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,
|
sub: mailbox.id,
|
||||||
email: mailbox.email_address,
|
email: mailbox.email_address,
|
||||||
role: mailbox.role,
|
role: mailbox.role,
|
||||||
domainId: mailbox.domain_id,
|
domainId: mailbox.domain_id,
|
||||||
surface,
|
surface: "user",
|
||||||
aud: surface === "admin" ? "lambdamail:admin" : "lambdamail:user",
|
aud: "lambdamail:user",
|
||||||
mfaSatisfied: true,
|
mfaSatisfied: false,
|
||||||
mfaSatisfiedAt: Date.now(),
|
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: 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<void> {
|
||||||
|
const cookies: string[] = [];
|
||||||
|
let primaryToken = "";
|
||||||
|
|
||||||
|
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",
|
purpose: "session",
|
||||||
});
|
});
|
||||||
|
if (!primaryToken) primaryToken = token;
|
||||||
|
|
||||||
|
// Each token is recorded separately, or "sign out this device" would revoke
|
||||||
|
// one audience and leave the other working.
|
||||||
await repo.recordSession(
|
await repo.recordSession(
|
||||||
mailbox.id,
|
mailbox.id,
|
||||||
surface,
|
granted,
|
||||||
crypto.createHash("sha256").update(sessionToken).digest("hex"),
|
crypto.createHash("sha256").update(token).digest("hex"),
|
||||||
new Date(Date.now() + SESSION_TTL_SECONDS * 1000),
|
new Date(Date.now() + SESSION_TTL_SECONDS * 1000),
|
||||||
clientIp(req),
|
clientIp(req),
|
||||||
req.headers["user-agent"] ?? null,
|
req.headers["user-agent"] ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const cookieName = surface === "admin" ? "lm_admin_session" : "lm_user_session";
|
cookies.push(sessionCookie(cookieNameFor(granted), token, SESSION_TTL_SECONDS, isSecureRequest()));
|
||||||
res.setHeader("Set-Cookie", sessionCookie(cookieName, sessionToken, SESSION_TTL_SECONDS, isSecureRequest()));
|
}
|
||||||
|
|
||||||
|
res.setHeader("Set-Cookie", cookies);
|
||||||
sendJson(res, 200, {
|
sendJson(res, 200, {
|
||||||
token: sessionToken,
|
token: primaryToken,
|
||||||
surface,
|
surface,
|
||||||
role: mailbox.role,
|
role: mailbox.role,
|
||||||
locale: mailbox.locale,
|
locale: mailbox.locale,
|
||||||
mfa_satisfied: true,
|
mfa_satisfied: factorProven,
|
||||||
mfa_enrollment_required: enrollmentRequired,
|
mfa_enrollment_required: enrollmentRequired,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -349,7 +461,7 @@ async function mfaVerify(req: IncomingMessage, res: ServerResponse): Promise<voi
|
|||||||
return sendJson(res, 401, { error: "INVALID_CREDENTIALS", message: "Account unavailable" });
|
return sendJson(res, 401, { error: "INVALID_CREDENTIALS", message: "Account unavailable" });
|
||||||
}
|
}
|
||||||
|
|
||||||
await issueSession(req, res, mailbox, payload.surface);
|
await issueSession(req, res, mailbox, payload.surface, false, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout(res: ServerResponse): void {
|
function logout(res: ServerResponse): void {
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ export interface SessionTokenPayload {
|
|||||||
// exists yet. It permits enrolling one and nothing else, which is what turns
|
// exists yet. It permits enrolling one and nothing else, which is what turns
|
||||||
// the admin console's "enroll first" from a dead end into a step.
|
// the admin console's "enroll first" from a dead end into a step.
|
||||||
purpose: "mfa_challenge" | "mfa_enrollment" | "session";
|
purpose: "mfa_challenge" | "mfa_enrollment" | "session";
|
||||||
|
// A unique identity per token (RFC 7519 "jti").
|
||||||
|
//
|
||||||
|
// Without it nothing in the payload varied below one second - iat and exp
|
||||||
|
// are in seconds - so signing in twice in the same second produced the
|
||||||
|
// identical token, which hashes to the identical value and collides with the
|
||||||
|
// unique index on web_sessions.refresh_token_hash. A correct password
|
||||||
|
// answered 500, and a double-clicked sign-in was enough to do it.
|
||||||
|
jti: string;
|
||||||
iat: number;
|
iat: number;
|
||||||
exp: number;
|
exp: number;
|
||||||
}
|
}
|
||||||
@@ -51,11 +59,17 @@ function base64UrlDecode(str: string): string {
|
|||||||
return Buffer.from(base64, "base64").toString("utf8");
|
return Buffer.from(base64, "base64").toString("utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createJwt(payload: Omit<SessionTokenPayload, "iat" | "exp">, expiresInSeconds = 28800): string {
|
export function createJwt(
|
||||||
|
payload: Omit<SessionTokenPayload, "jti" | "iat" | "exp">,
|
||||||
|
expiresInSeconds = 28800,
|
||||||
|
): string {
|
||||||
const header = { alg: "HS256", typ: "JWT" };
|
const header = { alg: "HS256", typ: "JWT" };
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const fullPayload: SessionTokenPayload = {
|
const fullPayload: SessionTokenPayload = {
|
||||||
...payload,
|
...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,
|
iat: now,
|
||||||
exp: now + expiresInSeconds,
|
exp: now + expiresInSeconds,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package usecase
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -15,6 +16,12 @@ import (
|
|||||||
// an out-of-memory kill.
|
// an out-of-memory kill.
|
||||||
const maxRenderedPartBytes = 2 << 20 // 2 MiB
|
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
|
// RenderedMessage is a message prepared for display: headers the reader shows
|
||||||
// and the two body flavours, already decoded from their transfer encoding.
|
// and the two body flavours, already decoded from their transfer encoding.
|
||||||
type RenderedMessage struct {
|
type RenderedMessage struct {
|
||||||
@@ -27,6 +34,15 @@ type RenderedMessage struct {
|
|||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
HTML string `json:"html"`
|
HTML string `json:"html"`
|
||||||
Attachments []string `json:"attachments"`
|
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.
|
// 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
|
// still yields its raw text rather than an error - unreadable formatting beats
|
||||||
// an inbox that refuses to open a message.
|
// an inbox that refuses to open a message.
|
||||||
func RenderMessage(raw []byte, uid uint32) RenderedMessage {
|
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))
|
entity, err := message.Read(bytes.NewReader(raw))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -89,8 +108,25 @@ func collectParts(entity *message.Entity, out *RenderedMessage) {
|
|||||||
mediaType = "text/plain"
|
mediaType = "text/plain"
|
||||||
}
|
}
|
||||||
|
|
||||||
if disposition, params, err := entity.Header.ContentDisposition(); err == nil && disposition == "attachment" {
|
disposition, dispParams, dispErr := entity.Header.ContentDisposition()
|
||||||
name := params["filename"]
|
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 == "" {
|
if name == "" {
|
||||||
name = "attachment"
|
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.
|
// ExtractAttachment walks the MIME tree and returns bytes and content-type for a named attachment.
|
||||||
func ExtractAttachment(entity *message.Entity, targetName string) ([]byte, string) {
|
func ExtractAttachment(entity *message.Entity, targetName string) ([]byte, string) {
|
||||||
if multipart := entity.MultipartReader(); multipart != nil {
|
if multipart := entity.MultipartReader(); multipart != nil {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package usecase
|
package usecase
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/emersion/go-message"
|
||||||
|
)
|
||||||
|
|
||||||
func TestRenderMessage_ExtractsHeadersAndText(t *testing.T) {
|
func TestRenderMessage_ExtractsHeadersAndText(t *testing.T) {
|
||||||
raw := "From: Alice <alice@remote.test>\r\n" +
|
raw := "From: Alice <alice@remote.test>\r\n" +
|
||||||
@@ -57,3 +62,110 @@ func TestRenderMessage_FallsBackToRawOnParseFailure(t *testing.T) {
|
|||||||
t.Error("a malformed message produced no body at all")
|
t.Error("a malformed message produced no body at all")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An inline image is referenced from the HTML body as cid:<Content-ID>. 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<p><img src=\"cid:logo@x\"></p>\r\n" +
|
||||||
|
"--B\r\nContent-Type: image/png\r\n" +
|
||||||
|
"Content-ID: <logo@x>\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<img src=\"cid:sig\">\r\n" +
|
||||||
|
"--B\r\nContent-Type: image/gif\r\n" +
|
||||||
|
"Content-ID: <sig>\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<img src=\"cid:big\">\r\n" +
|
||||||
|
"--B\r\nContent-Type: image/png\r\nContent-ID: <big>\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<img src=\"cid:x\">\r\n" +
|
||||||
|
"--B\r\nContent-Type: image/svg+xml\r\nContent-ID: <x>\r\n\r\n<svg onload=\"alert(1)\"/>\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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,11 +89,15 @@
|
|||||||
"genericError": "Something went wrong",
|
"genericError": "Something went wrong",
|
||||||
"invalidCredentials": "Invalid email or password",
|
"invalidCredentials": "Invalid email or password",
|
||||||
"mfaRequired": "Enter the six-digit code from your authenticator app.",
|
"mfaRequired": "Enter the six-digit code from your authenticator app.",
|
||||||
|
"orRecoveryCode": "Lost your phone? A recovery code works here too.",
|
||||||
"passwordLabel": "Password",
|
"passwordLabel": "Password",
|
||||||
"qrCodeAlt": "QR code for two-factor setup",
|
"qrCodeAlt": "QR code for two-factor setup",
|
||||||
"scanQr": "Scan this code with Google Authenticator, Aegis, Authy or 1Password.",
|
"scanQr": "Scan this code with Google Authenticator, Aegis, Authy or 1Password.",
|
||||||
"signInButton": "Sign in",
|
"signInButton": "Sign in",
|
||||||
"signOutOfAdmin": "Leave admin",
|
"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",
|
"totpCodeLabel": "Authentication code",
|
||||||
"userLoginTitle": "Sign in to your mailbox",
|
"userLoginTitle": "Sign in to your mailbox",
|
||||||
"verificationFailed": "Verification failed",
|
"verificationFailed": "Verification failed",
|
||||||
@@ -145,7 +149,9 @@
|
|||||||
"draftSaved": "Draft saved automatically",
|
"draftSaved": "Draft saved automatically",
|
||||||
"draftSaving": "Saving draft...",
|
"draftSaving": "Saving draft...",
|
||||||
"drafts": "Drafts",
|
"drafts": "Drafts",
|
||||||
|
"emptyBody": "This message has no text body.",
|
||||||
"forward": "Forward",
|
"forward": "Forward",
|
||||||
|
"imagesEnabled": "Images loaded",
|
||||||
"hideCcBcc": "- Hide Cc/Bcc",
|
"hideCcBcc": "- Hide Cc/Bcc",
|
||||||
"inbox": "Inbox",
|
"inbox": "Inbox",
|
||||||
"insertLink": "Insert link",
|
"insertLink": "Insert link",
|
||||||
@@ -153,9 +159,18 @@
|
|||||||
"italic": "Italic",
|
"italic": "Italic",
|
||||||
"junk": "Spam",
|
"junk": "Spam",
|
||||||
"loadImages": "Load images",
|
"loadImages": "Load images",
|
||||||
|
"markRead": "Mark as read",
|
||||||
|
"markUnread": "Mark as unread",
|
||||||
|
"messagesInFolder": "{count} messages",
|
||||||
|
"noMatches": "Nothing matched that search",
|
||||||
"noMessages": "No messages",
|
"noMessages": "No messages",
|
||||||
|
"noSubject": "(No subject)",
|
||||||
"numberedList": "Numbered list",
|
"numberedList": "Numbered list",
|
||||||
|
"remoteContentBlocked": "Remote images were blocked so this message cannot track you.",
|
||||||
"reply": "Reply",
|
"reply": "Reply",
|
||||||
|
"searchMatches": "{count} found",
|
||||||
|
"searchPlaceholder": "Search (from:, subject:)...",
|
||||||
|
"selectAMessage": "Select a message to read it",
|
||||||
"sendMessage": "Send message",
|
"sendMessage": "Send message",
|
||||||
"sending": "Sending...",
|
"sending": "Sending...",
|
||||||
"sent": "Sent",
|
"sent": "Sent",
|
||||||
@@ -232,6 +247,7 @@
|
|||||||
"appPasswordsSection": "App passwords",
|
"appPasswordsSection": "App passwords",
|
||||||
"attempts": "Attempts",
|
"attempts": "Attempts",
|
||||||
"bounceRate": "Bounce rate",
|
"bounceRate": "Bounce rate",
|
||||||
|
"ccRecipients": "Cc",
|
||||||
"changePassword": "Change password",
|
"changePassword": "Change password",
|
||||||
"clientIp": "Client IP",
|
"clientIp": "Client IP",
|
||||||
"copyPasswordNow": "Copy it now. It is not shown again.",
|
"copyPasswordNow": "Copy it now. It is not shown again.",
|
||||||
|
|||||||
@@ -89,11 +89,15 @@
|
|||||||
"genericError": "Algo salió mal",
|
"genericError": "Algo salió mal",
|
||||||
"invalidCredentials": "Correo o contraseña inválidos",
|
"invalidCredentials": "Correo o contraseña inválidos",
|
||||||
"mfaRequired": "Introduzca el código de seis dígitos de su aplicación de autenticación.",
|
"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",
|
"passwordLabel": "Contraseña",
|
||||||
"qrCodeAlt": "Código QR para configurar la verificación en dos pasos",
|
"qrCodeAlt": "Código QR para configurar la verificación en dos pasos",
|
||||||
"scanQr": "Escanee este código con Google Authenticator, Aegis, Authy o 1Password.",
|
"scanQr": "Escanee este código con Google Authenticator, Aegis, Authy o 1Password.",
|
||||||
"signInButton": "Iniciar sesión",
|
"signInButton": "Iniciar sesión",
|
||||||
"signOutOfAdmin": "Salir de administració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",
|
"totpCodeLabel": "Código de autenticación",
|
||||||
"userLoginTitle": "Acceda a su buzón",
|
"userLoginTitle": "Acceda a su buzón",
|
||||||
"verificationFailed": "Fallo en la verificación",
|
"verificationFailed": "Fallo en la verificación",
|
||||||
@@ -145,7 +149,9 @@
|
|||||||
"draftSaved": "Borrador guardado automáticamente",
|
"draftSaved": "Borrador guardado automáticamente",
|
||||||
"draftSaving": "Guardando borrador...",
|
"draftSaving": "Guardando borrador...",
|
||||||
"drafts": "Borradores",
|
"drafts": "Borradores",
|
||||||
|
"emptyBody": "Este mensaje no tiene cuerpo de texto.",
|
||||||
"forward": "Reenviar",
|
"forward": "Reenviar",
|
||||||
|
"imagesEnabled": "Imágenes cargadas",
|
||||||
"hideCcBcc": "- Ocultar Cc/Cco",
|
"hideCcBcc": "- Ocultar Cc/Cco",
|
||||||
"inbox": "Bandeja de entrada",
|
"inbox": "Bandeja de entrada",
|
||||||
"insertLink": "Insertar enlace",
|
"insertLink": "Insertar enlace",
|
||||||
@@ -153,9 +159,18 @@
|
|||||||
"italic": "Cursiva",
|
"italic": "Cursiva",
|
||||||
"junk": "Spam",
|
"junk": "Spam",
|
||||||
"loadImages": "Cargar imágenes",
|
"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",
|
"noMessages": "Sin mensajes",
|
||||||
|
"noSubject": "(Sin asunto)",
|
||||||
"numberedList": "Lista numerada",
|
"numberedList": "Lista numerada",
|
||||||
|
"remoteContentBlocked": "Se bloquearon las imágenes remotas para que este mensaje no pueda rastrearte.",
|
||||||
"reply": "Responder",
|
"reply": "Responder",
|
||||||
|
"searchMatches": "{count} encontrados",
|
||||||
|
"searchPlaceholder": "Buscar (from:, subject:)...",
|
||||||
|
"selectAMessage": "Selecciona un mensaje para leerlo",
|
||||||
"sendMessage": "Enviar mensaje",
|
"sendMessage": "Enviar mensaje",
|
||||||
"sending": "Enviando...",
|
"sending": "Enviando...",
|
||||||
"sent": "Enviados",
|
"sent": "Enviados",
|
||||||
@@ -232,6 +247,7 @@
|
|||||||
"appPasswordsSection": "Contraseñas de aplicación",
|
"appPasswordsSection": "Contraseñas de aplicación",
|
||||||
"attempts": "Intentos",
|
"attempts": "Intentos",
|
||||||
"bounceRate": "Tasa de rebote",
|
"bounceRate": "Tasa de rebote",
|
||||||
|
"ccRecipients": "Cc",
|
||||||
"changePassword": "Cambiar contraseña",
|
"changePassword": "Cambiar contraseña",
|
||||||
"clientIp": "IP del cliente",
|
"clientIp": "IP del cliente",
|
||||||
"copyPasswordNow": "Cópiela ahora. No se mostrará de nuevo.",
|
"copyPasswordNow": "Cópiela ahora. No se mostrará de nuevo.",
|
||||||
|
|||||||
@@ -89,11 +89,15 @@
|
|||||||
"genericError": "Algo deu errado",
|
"genericError": "Algo deu errado",
|
||||||
"invalidCredentials": "E-mail ou senha inválidos",
|
"invalidCredentials": "E-mail ou senha inválidos",
|
||||||
"mfaRequired": "Digite o código de seis dígitos do seu aplicativo autenticador.",
|
"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",
|
"passwordLabel": "Senha",
|
||||||
"qrCodeAlt": "Código QR para configurar a verificação em duas etapas",
|
"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.",
|
"scanQr": "Escaneie este código com o Google Authenticator, Aegis, Authy ou 1Password.",
|
||||||
"signInButton": "Entrar",
|
"signInButton": "Entrar",
|
||||||
"signOutOfAdmin": "Sair da administração",
|
"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",
|
"totpCodeLabel": "Código de autenticação",
|
||||||
"userLoginTitle": "Entre na sua caixa de e-mail",
|
"userLoginTitle": "Entre na sua caixa de e-mail",
|
||||||
"verificationFailed": "Falha na verificação",
|
"verificationFailed": "Falha na verificação",
|
||||||
@@ -145,7 +149,9 @@
|
|||||||
"draftSaved": "Rascunho salvo automaticamente",
|
"draftSaved": "Rascunho salvo automaticamente",
|
||||||
"draftSaving": "Salvando rascunho...",
|
"draftSaving": "Salvando rascunho...",
|
||||||
"drafts": "Rascunhos",
|
"drafts": "Rascunhos",
|
||||||
|
"emptyBody": "Esta mensagem não tem corpo de texto.",
|
||||||
"forward": "Encaminhar",
|
"forward": "Encaminhar",
|
||||||
|
"imagesEnabled": "Imagens carregadas",
|
||||||
"hideCcBcc": "- Ocultar Cc/Cco",
|
"hideCcBcc": "- Ocultar Cc/Cco",
|
||||||
"inbox": "Caixa de entrada",
|
"inbox": "Caixa de entrada",
|
||||||
"insertLink": "Inserir link",
|
"insertLink": "Inserir link",
|
||||||
@@ -153,9 +159,18 @@
|
|||||||
"italic": "Itálico",
|
"italic": "Itálico",
|
||||||
"junk": "Spam",
|
"junk": "Spam",
|
||||||
"loadImages": "Carregar imagens",
|
"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",
|
"noMessages": "Nenhuma mensagem",
|
||||||
|
"noSubject": "(Sem assunto)",
|
||||||
"numberedList": "Lista numerada",
|
"numberedList": "Lista numerada",
|
||||||
|
"remoteContentBlocked": "As imagens remotas foram bloqueadas para que esta mensagem não possa rastrear você.",
|
||||||
"reply": "Responder",
|
"reply": "Responder",
|
||||||
|
"searchMatches": "{count} encontradas",
|
||||||
|
"searchPlaceholder": "Buscar (from:, subject:)...",
|
||||||
|
"selectAMessage": "Selecione uma mensagem para ler",
|
||||||
"sendMessage": "Enviar mensagem",
|
"sendMessage": "Enviar mensagem",
|
||||||
"sending": "Enviando...",
|
"sending": "Enviando...",
|
||||||
"sent": "Enviados",
|
"sent": "Enviados",
|
||||||
@@ -232,6 +247,7 @@
|
|||||||
"appPasswordsSection": "Senhas de aplicativo",
|
"appPasswordsSection": "Senhas de aplicativo",
|
||||||
"attempts": "Tentativas",
|
"attempts": "Tentativas",
|
||||||
"bounceRate": "Taxa de devolução",
|
"bounceRate": "Taxa de devolução",
|
||||||
|
"ccRecipients": "Cc",
|
||||||
"changePassword": "Alterar senha",
|
"changePassword": "Alterar senha",
|
||||||
"clientIp": "IP do cliente",
|
"clientIp": "IP do cliente",
|
||||||
"copyPasswordNow": "Copie agora. Ela não será exibida novamente.",
|
"copyPasswordNow": "Copie agora. Ela não será exibida novamente.",
|
||||||
|
|||||||
@@ -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<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [enrolmentToken, setEnrolmentToken] = useState<string | null>(null);
|
||||||
|
const [enrolSecret, setEnrolSecret] = useState<string | null>(null);
|
||||||
|
const [enrolUri, setEnrolUri] = useState<string | null>(null);
|
||||||
|
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center px-4 bg-slate-950">
|
||||||
|
<div className="glass-panel p-8 rounded-2xl max-w-md w-full border border-emerald-900/40 shadow-2xl">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-emerald-600/20 border border-emerald-500/30 flex items-center justify-center text-emerald-400">
|
||||||
|
<Sliders className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-lg font-bold text-white">{t("auth.stepUpTitle")}</h1>
|
||||||
|
<p className="text-xs text-slate-400 truncate">
|
||||||
|
{account?.email ? t("auth.stepUpSignedInAs", { email: account.email }) : t("common.loading")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 rounded-lg bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recoveryCodes ? (
|
||||||
|
<RecoveryCodes
|
||||||
|
codes={recoveryCodes}
|
||||||
|
onContinue={() => setRecoveryCodes(null)}
|
||||||
|
continueLabel={t("common.continue")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
{enrolSecret ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border border-indigo-500/30 bg-indigo-500/10 p-3 text-xs text-indigo-300">
|
||||||
|
{t("auth.enrolmentIntro")}
|
||||||
|
</div>
|
||||||
|
<TotpEnrolment secret={enrolSecret} uri={enrolUri} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-xs text-emerald-300 flex items-start gap-2">
|
||||||
|
<ShieldCheck className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>{t("auth.stepUpIntro")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="step-up-code" className="mb-1 block text-xs font-medium text-slate-300">
|
||||||
|
{t("auth.totpCodeLabel")}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<KeyRound className="w-4 h-4 absolute left-3.5 top-3.5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
id="step-up-code"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
autoFocus
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* A recovery code is the way in when the phone is gone, and
|
||||||
|
nothing on the old screens said so. */}
|
||||||
|
<p className="mt-1.5 text-[11px] text-slate-500">{t("auth.orRecoveryCode")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-medium transition-colors shadow-lg shadow-emerald-600/20 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? t("common.loading") : t("auth.verifyCodeButton")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 pt-4 border-t border-slate-800 text-center">
|
||||||
|
<Link href="/user/mail/inbox" className="text-xs text-slate-400 hover:text-slate-200 transition-colors">
|
||||||
|
{t("admin.backToWebmail")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import Link from "next/link";
|
import { Mail, Lock, AlertCircle } from "lucide-react";
|
||||||
import {
|
|
||||||
Mail,
|
|
||||||
Lock,
|
|
||||||
ShieldCheck,
|
|
||||||
KeyRound,
|
|
||||||
AlertCircle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useTranslations } from "../../../../i18n/provider";
|
import { useTranslations } from "../../../../i18n/provider";
|
||||||
import { Card } from "../../../../components/ui/Card";
|
import { Card } from "../../../../components/ui/Card";
|
||||||
import { Button } from "../../../../components/ui/Button";
|
import { Button } from "../../../../components/ui/Button";
|
||||||
@@ -17,46 +10,40 @@ export default function UserLoginPage() {
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [mfaCode, setMfaCode] = useState("");
|
|
||||||
const [challengeToken, setChallengeToken] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
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) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
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", {
|
const res = await fetch("/api/v1/auth/user/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
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) {
|
// The destination the middleware attached when it sent an unauthenticated
|
||||||
setChallengeToken(data.challenge_token);
|
// visitor here, so a deep link survives the sign-in.
|
||||||
} else {
|
// A path on this origin only: "//evil.test" also begins with a slash, and
|
||||||
window.location.href = "/user/mail/inbox";
|
// 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) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : "Ocorreu um erro ao autenticar");
|
setError(err instanceof Error ? err.message : t("auth.genericError"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -87,14 +74,16 @@ export default function UserLoginPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleLogin} className="space-y-4 text-xs">
|
<form onSubmit={handleLogin} className="space-y-4 text-xs">
|
||||||
{!challengeToken ? (
|
|
||||||
<>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block font-semibold text-slate-300 mb-1.5">{t("auth.emailLabel")}</label>
|
<label htmlFor="login-email" className="block font-semibold text-slate-300 mb-1.5">
|
||||||
|
{t("auth.emailLabel")}
|
||||||
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail className="w-4 h-4 absolute left-3.5 top-3 text-slate-500" />
|
<Mail className="w-4 h-4 absolute left-3.5 top-3 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
|
id="login-email"
|
||||||
type="email"
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="user@domain.com"
|
placeholder="user@domain.com"
|
||||||
@@ -105,11 +94,15 @@ export default function UserLoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block font-semibold text-slate-300 mb-1.5">{t("auth.passwordLabel")}</label>
|
<label htmlFor="login-password" className="block font-semibold text-slate-300 mb-1.5">
|
||||||
|
{t("auth.passwordLabel")}
|
||||||
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="w-4 h-4 absolute left-3.5 top-3 text-slate-500" />
|
<Lock className="w-4 h-4 absolute left-3.5 top-3 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
|
id="login-password"
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="************"
|
placeholder="************"
|
||||||
@@ -118,37 +111,9 @@ export default function UserLoginPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div className="p-3.5 mb-4 rounded-xl bg-indigo-500/15 border border-indigo-500/30 text-indigo-300 text-xs flex items-center gap-2">
|
|
||||||
<ShieldCheck className="w-4 h-4 text-indigo-400 flex-shrink-0" />
|
|
||||||
<span>{t("auth.mfaRequired")}</span>
|
|
||||||
</div>
|
|
||||||
<label className="block font-semibold text-slate-300 mb-1.5">{t("auth.totpCodeLabel")}</label>
|
|
||||||
<div className="relative">
|
|
||||||
<KeyRound className="w-4 h-4 absolute left-3.5 top-3.5 text-slate-500" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={mfaCode}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button type="submit" variant="primary" size="lg" className="w-full mt-2" disabled={loading}>
|
||||||
type="submit"
|
{loading ? t("common.loading") : t("auth.signInButton")}
|
||||||
variant="primary"
|
|
||||||
size="lg"
|
|
||||||
className="w-full mt-2"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? t("common.loading") : challengeToken ? t("auth.verifyCodeButton") : t("auth.signInButton")}
|
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
@@ -24,29 +24,17 @@ import {
|
|||||||
import { useTranslations } from "../../../i18n/provider";
|
import { useTranslations } from "../../../i18n/provider";
|
||||||
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
|
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
|
||||||
import { useAccount, isAdminRole } from "../../../lib/useAccount";
|
import { useAccount, isAdminRole } from "../../../lib/useAccount";
|
||||||
|
import { useFolders } from "../../../lib/useFolders";
|
||||||
interface FolderCount {
|
import { badgeCount, folderMetrics } from "../../../lib/mailCounts";
|
||||||
special_use: string;
|
|
||||||
name: string;
|
|
||||||
unread_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
|
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [folders, setFolders] = useState<FolderCount[]>([]);
|
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const account = useAccount("user");
|
const account = useAccount("user");
|
||||||
|
// Live, not fetched once: these badges used to freeze at the value they had
|
||||||
useEffect(() => {
|
// when the tab was opened.
|
||||||
void fetch("/api/v1/mail/folders")
|
const { folders } = useFolders();
|
||||||
.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;
|
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/user/mail/inbox", label: t("mail.inbox"), icon: Inbox, role: "inbox" },
|
{ 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) => {
|
{navItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const isActive = pathname === item.href || (item.role === "inbox" && pathname === "/user/mail");
|
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
|
title={total > 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 ${
|
className={`flex items-center justify-between px-3.5 py-2.5 rounded-xl transition-all duration-150 ${
|
||||||
isActive
|
isActive
|
||||||
? "bg-indigo-600/20 text-indigo-300 border border-indigo-500/30 shadow-sm"
|
? "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"
|
: "text-slate-400 hover:text-slate-200 hover:bg-slate-800/40 border border-transparent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<Icon className={`w-4 h-4 ${isActive ? "text-indigo-400" : "text-slate-400"}`} />
|
<Icon className={`w-4 h-4 flex-shrink-0 ${isActive ? "text-indigo-400" : "text-slate-400"}`} />
|
||||||
<span>{item.label}</span>
|
<span className="truncate">{item.label}</span>
|
||||||
</div>
|
</div>
|
||||||
{unread > 0 && (
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{/* 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 && (
|
||||||
|
<span className="text-[10px] font-mono text-slate-500 tabular-nums">{total}</span>
|
||||||
|
)}
|
||||||
|
{badge > 0 && (
|
||||||
<span
|
<span
|
||||||
className={`text-xs px-2 py-0.5 rounded-full font-semibold ${
|
className={`text-xs px-2 py-0.5 rounded-full font-semibold tabular-nums ${
|
||||||
isActive ? "bg-indigo-500/40 text-indigo-200" : "bg-slate-800 text-slate-300"
|
isActive ? "bg-indigo-500/40 text-indigo-200" : "bg-indigo-500/20 text-indigo-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{unread}
|
{badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -139,13 +139,13 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* The console is reachable from inside the app for the accounts that
|
{/* 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
|
may open it. It goes to the step-up rather than to the admin sign-in:
|
||||||
front of the sign-in. It is a plain link to the admin sign-in, not a
|
the admin audience is a separate token, so crossing over costs a
|
||||||
direct jump: the admin audience is a separate token, so crossing
|
second factor - but not the password, which the session in hand
|
||||||
over means proving the second factor again. */}
|
proved already. */}
|
||||||
{isAdminRole(account?.role) && (
|
{isAdminRole(account?.role) && (
|
||||||
<Link
|
<Link
|
||||||
href="/admin/login"
|
href="/admin/step-up"
|
||||||
className="flex items-center gap-2.5 p-2.5 rounded-xl border border-emerald-500/25 bg-emerald-500/10 text-emerald-300 hover:border-emerald-500/50 hover:bg-emerald-500/15 transition-all"
|
className="flex items-center gap-2.5 p-2.5 rounded-xl border border-emerald-500/25 bg-emerald-500/10 text-emerald-300 hover:border-emerald-500/50 hover:bg-emerald-500/15 transition-all"
|
||||||
>
|
>
|
||||||
<Sliders className="w-4 h-4 flex-shrink-0" />
|
<Sliders className="w-4 h-4 flex-shrink-0" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 Link from "next/link";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
@@ -9,17 +9,26 @@ import {
|
|||||||
Reply,
|
Reply,
|
||||||
Forward,
|
Forward,
|
||||||
Inbox,
|
Inbox,
|
||||||
Filter,
|
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Mail,
|
Mail,
|
||||||
Clock,
|
Clock,
|
||||||
User,
|
MailOpen,
|
||||||
ExternalLink,
|
Download,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "../../../../../i18n/provider";
|
import { useI18n } from "../../../../../i18n/provider";
|
||||||
import { useMailEvents } from "../../../../../lib/useMailEvents";
|
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 { 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";
|
import { Button } from "../../../../../components/ui/Button";
|
||||||
|
|
||||||
interface MessageSummary {
|
interface MessageSummary {
|
||||||
@@ -46,19 +55,69 @@ interface RenderedMessage {
|
|||||||
text: string;
|
text: string;
|
||||||
html: string;
|
html: string;
|
||||||
attachments: 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 }> }) {
|
export default function MailFolderPage({ params }: { params: Promise<{ folder: string }> }) {
|
||||||
const { folder } = use(params);
|
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<MessageSummary[]>([]);
|
const [messages, setMessages] = useState<MessageSummary[]>([]);
|
||||||
const [selected, setSelected] = useState<RenderedMessage | null>(null);
|
const [selected, setSelected] = useState<RenderedMessage | null>(null);
|
||||||
const [loadRemoteImages, setLoadRemoteImages] = useState(false);
|
const [loadRemoteImages, setLoadRemoteImages] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [activeFilter, setActiveFilter] = useState<string | null>(null);
|
const [activeFilter, setActiveFilter] = useState<ListFilter>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(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(
|
const loadMessages = useCallback(
|
||||||
async (search: string) => {
|
async (search: string) => {
|
||||||
@@ -66,7 +125,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const query = new URLSearchParams({ folder, ...(search ? { q: search } : {}) });
|
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) {
|
if (res.status === 401) {
|
||||||
window.location.href = "/user/login";
|
window.location.href = "/user/login";
|
||||||
return;
|
return;
|
||||||
@@ -82,10 +141,6 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
[folder, t]
|
[folder, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadMessages("");
|
|
||||||
}, [loadMessages]);
|
|
||||||
|
|
||||||
useMailEvents(
|
useMailEvents(
|
||||||
useCallback(
|
useCallback(
|
||||||
(event) => {
|
(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(() => {
|
useEffect(() => {
|
||||||
const handle = setTimeout(() => void loadMessages(searchQuery), 250);
|
const handle = setTimeout(() => void loadMessages(searchQuery), searchQuery ? 250 : 0);
|
||||||
return () => clearTimeout(handle);
|
return () => clearTimeout(handle);
|
||||||
}, [searchQuery, loadMessages]);
|
}, [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) => {
|
const openMessage = async (uid: number) => {
|
||||||
setLoadRemoteImages(false);
|
setLoadRemoteImages(false);
|
||||||
|
setReaderHeight(420);
|
||||||
try {
|
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"));
|
if (!res.ok) throw new Error(t("errors.serverError"));
|
||||||
const data = await res.json();
|
setSelected(await res.json());
|
||||||
setSelected(data);
|
// Fetching the message is what marks it read on the server, so this only
|
||||||
setMessages((current) => current.map((m) => (m.uid === uid ? { ...m, seen: true } : m)));
|
// brings the list and the badges into line with what already happened.
|
||||||
|
void setSeen(uid, true, { persist: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t("errors.serverError"));
|
setError(err instanceof Error ? err.message : t("errors.serverError"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredMessages = messages.filter((msg) => {
|
const filteredMessages = filterMessages(messages, activeFilter);
|
||||||
if (activeFilter === "unread") return !msg.seen;
|
const counts = messageCounts(messages);
|
||||||
if (activeFilter === "attachment") return msg.has_attachments;
|
const header = listHeaderCount({
|
||||||
return true;
|
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
|
* The document the reader frame renders.
|
||||||
? loadRemoteImages
|
*
|
||||||
? unblockRemoteImages(sanitizedHtml)
|
* Order matters: sanitise first so nothing downstream handles untrusted
|
||||||
: blockRemoteImages(sanitizedHtml)
|
* markup, then resolve the message's own inline parts, then block or restore
|
||||||
: null;
|
* 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 (
|
return (
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* Messages List Column */}
|
{/* Messages List Column */}
|
||||||
<div
|
<div
|
||||||
className={`${selected ? "hidden md:flex" : "flex"} w-full md:w-96 border-r border-slate-800/80 flex-col bg-slate-900/40 backdrop-blur-md md:flex-shrink-0`}
|
className={`${selected ? "hidden md:flex" : "flex"} w-full md:w-[22rem] lg:w-96 border-r border-slate-800/80 flex-col bg-slate-900/40 backdrop-blur-md md:flex-shrink-0`}
|
||||||
>
|
>
|
||||||
|
{/* Folder heading, with the counts that belong to the folder rather
|
||||||
|
than to the page of it that happens to be loaded. */}
|
||||||
|
<div className="px-3.5 pt-3.5 flex items-baseline justify-between gap-2">
|
||||||
|
<h2 className="text-sm font-bold text-slate-100 capitalize truncate">{folder}</h2>
|
||||||
|
<span className="text-[11px] text-slate-400 font-mono tabular-nums flex-shrink-0">
|
||||||
|
{header.isFolderTotal
|
||||||
|
? t("mail.messagesInFolder", { count: header.count })
|
||||||
|
: t("mail.searchMatches", { count: header.count })}
|
||||||
|
{metrics.unread > 0 && (
|
||||||
|
<span className="text-indigo-300"> · {t("mail.unreadCount", { count: metrics.unread })}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Search Bar & Filter Chips */}
|
{/* Search Bar & Filter Chips */}
|
||||||
<div className="p-3.5 border-b border-slate-800 space-y-2.5">
|
<div className="p-3.5 border-b border-slate-800 space-y-2.5">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="w-4 h-4 absolute left-3 top-3 text-slate-400" />
|
<Search className="w-4 h-4 absolute left-3 top-3 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="search"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => 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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 text-[11px] overflow-x-auto pb-0.5">
|
<div className="flex items-center gap-1.5 text-[11px] overflow-x-auto pb-0.5">
|
||||||
|
{chips.map((chip) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveFilter(null)}
|
key={chip.label}
|
||||||
className={`px-2.5 py-1 rounded-lg font-medium transition-all ${
|
type="button"
|
||||||
activeFilter === null
|
aria-pressed={activeFilter === chip.key}
|
||||||
|
onClick={() => setActiveFilter(chip.key)}
|
||||||
|
className={`px-2.5 py-1 rounded-lg font-medium transition-all whitespace-nowrap flex items-center gap-1.5 ${
|
||||||
|
activeFilter === chip.key
|
||||||
? "bg-indigo-600 text-white shadow-sm"
|
? "bg-indigo-600 text-white shadow-sm"
|
||||||
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
|
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t("mail.allMessages")} ({messages.length})
|
<span>{chip.label}</span>
|
||||||
</button>
|
{/* Every chip carries its own number: two of the three used to
|
||||||
<button
|
filter to a count nothing on screen disclosed. */}
|
||||||
onClick={() => setActiveFilter("unread")}
|
<span className="tabular-nums opacity-80">{chip.count}</span>
|
||||||
className={`px-2.5 py-1 rounded-lg font-medium transition-all ${
|
|
||||||
activeFilter === "unread"
|
|
||||||
? "bg-indigo-600 text-white shadow-sm"
|
|
||||||
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t("mail.unreadFilter")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveFilter("attachment")}
|
|
||||||
className={`px-2.5 py-1 rounded-lg font-medium transition-all ${
|
|
||||||
activeFilter === "attachment"
|
|
||||||
? "bg-indigo-600 text-white shadow-sm"
|
|
||||||
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t("mail.attachmentsFilter")}
|
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -193,9 +329,9 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : filteredMessages.length === 0 ? (
|
) : filteredMessages.length === 0 ? (
|
||||||
<div className="p-8 text-center text-xs text-slate-500 italic flex flex-col items-center gap-2">
|
<div className="p-8 text-center text-xs text-slate-500 flex flex-col items-center gap-2">
|
||||||
<Inbox className="w-8 h-8 text-slate-600" />
|
<Inbox className="w-8 h-8 text-slate-600" />
|
||||||
<span>{t("mail.noMessages")}</span>
|
<span>{searchQuery ? t("mail.noMatches") : t("mail.noMessages")}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredMessages.map((msg) => {
|
filteredMessages.map((msg) => {
|
||||||
@@ -204,43 +340,54 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
<button
|
<button
|
||||||
key={msg.uid}
|
key={msg.uid}
|
||||||
onClick={() => void openMessage(msg.uid)}
|
onClick={() => void openMessage(msg.uid)}
|
||||||
className={`w-full text-left p-4 transition-all duration-150 block relative ${
|
className={`w-full text-left p-3.5 transition-all duration-150 flex gap-3 relative ${
|
||||||
isSelected
|
isSelected ? "bg-indigo-600/15" : "hover:bg-slate-800/30"
|
||||||
? "bg-indigo-600/15 border-l-4 border-indigo-500"
|
|
||||||
: "hover:bg-slate-800/30"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
{isSelected && <span className="absolute left-0 top-0 bottom-0 w-[3px] bg-indigo-500" />}
|
||||||
|
{/* An unread row is marked by a dot in a reserved column, so
|
||||||
|
the subject does not shift sideways when it is read. */}
|
||||||
|
<span className="mt-1.5 w-2 flex-shrink-0">
|
||||||
|
{!msg.seen && <span className="block w-2 h-2 rounded-full bg-indigo-400" />}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-semibold truncate max-w-[170px] ${
|
className={`mt-0.5 w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center text-[10px] font-bold ${
|
||||||
msg.seen ? "text-slate-300" : "text-white font-bold"
|
msg.seen
|
||||||
|
? "bg-slate-800 text-slate-400"
|
||||||
|
: "bg-indigo-500/25 text-indigo-200 border border-indigo-500/30"
|
||||||
|
}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{initials(msg.from_display_name, msg.sender_address)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="flex items-center justify-between gap-2 mb-0.5">
|
||||||
|
<span
|
||||||
|
className={`text-xs truncate ${
|
||||||
|
msg.seen ? "text-slate-300 font-medium" : "text-white font-bold"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{msg.from_display_name || msg.sender_address}
|
{msg.from_display_name || msg.sender_address}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-slate-500 font-mono">
|
<span className="text-[10px] text-slate-500 font-mono flex-shrink-0 tabular-nums">
|
||||||
{new Date(msg.received_at).toLocaleTimeString([], {
|
{listTimestamp(msg.received_at, locale)}
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</span>
|
||||||
<div
|
<span
|
||||||
className={`text-xs truncate mb-1 flex items-center gap-1.5 ${
|
className={`text-xs truncate flex items-center gap-1.5 mb-0.5 ${
|
||||||
msg.seen ? "text-slate-300" : "font-bold text-slate-100"
|
msg.seen ? "text-slate-300" : "font-semibold text-slate-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{!msg.seen && (
|
<span className="truncate">{msg.subject || t("mail.noSubject")}</span>
|
||||||
<span className="w-2 h-2 rounded-full bg-indigo-500 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="truncate">{msg.subject || "(Sem assunto)"}</span>
|
|
||||||
{msg.has_attachments && (
|
{msg.has_attachments && (
|
||||||
<Paperclip className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
<Paperclip className="w-3 h-3 text-slate-400 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</span>
|
||||||
<div className="text-[11px] text-slate-400 truncate leading-relaxed">
|
<span className="block text-[11px] text-slate-500 truncate leading-relaxed">
|
||||||
{msg.snippet}
|
{msg.snippet}
|
||||||
</div>
|
</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -250,142 +397,179 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
|
|
||||||
{/* Message Reader Column */}
|
{/* Message Reader Column */}
|
||||||
<div
|
<div
|
||||||
className={`${selected ? "flex" : "hidden md:flex"} flex-1 flex-col bg-dark-bg overflow-y-auto p-4 sm:p-6 md:p-8`}
|
className={`${selected ? "flex" : "hidden md:flex"} flex-1 flex-col bg-dark-bg overflow-y-auto`}
|
||||||
>
|
>
|
||||||
{selected && (
|
<AnimatePresence mode="wait">
|
||||||
|
{selected ? (
|
||||||
|
<motion.article
|
||||||
|
key={selected.uid}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="w-full max-w-3xl mx-auto px-4 sm:px-6 py-5 space-y-5"
|
||||||
|
>
|
||||||
|
{/* Toolbar: closing, replying and read state all in one row, so
|
||||||
|
the reader is not the only pane with no way out of itself. */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelected(null)}
|
onClick={() => setSelected(null)}
|
||||||
className="md:hidden mb-4 self-start rounded-lg border border-slate-700 px-3 py-1.5 text-xs text-slate-300"
|
className="flex items-center gap-1.5 rounded-lg border border-slate-800 bg-slate-900/70 px-2.5 py-1.5 text-xs text-slate-300 hover:text-white hover:border-slate-700 transition-colors"
|
||||||
>
|
>
|
||||||
← {t("mail.inbox")}
|
<X className="w-3.5 h-3.5" />
|
||||||
|
<span>{t("mail.backToList")}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
{selected ? (
|
|
||||||
<motion.div
|
|
||||||
key={selected.uid}
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="max-w-4xl w-full mx-auto space-y-6"
|
|
||||||
>
|
|
||||||
{/* Header & Reply/Forward Actions */}
|
|
||||||
<div className="glass-panel p-6 rounded-2xl border border-slate-800 space-y-4">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800/80 pb-4">
|
|
||||||
<h1 className="text-xl md:text-2xl font-extrabold text-white tracking-tight">
|
|
||||||
{selected.subject || "(Sem assunto)"}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const nowSeen = !(selectedRow?.seen ?? true);
|
||||||
|
void setSeen(selected.uid, nowSeen, { persist: true });
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-slate-800 bg-slate-900/70 px-2.5 py-1.5 text-slate-300 hover:text-white hover:border-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
<MailOpen className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
{selectedRow?.seen === false ? t("mail.markRead") : t("mail.markUnread")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href={`/user/compose?replyTo=${encodeURIComponent(
|
href={`/user/compose?replyTo=${encodeURIComponent(selected.from)}&subject=${encodeURIComponent(selected.subject)}`}
|
||||||
selected.from
|
|
||||||
)}&subject=${encodeURIComponent(selected.subject)}`}
|
|
||||||
>
|
>
|
||||||
<Button variant="primary" size="sm">
|
<Button variant="primary" size="sm">
|
||||||
<Reply className="w-3.5 h-3.5" />
|
<Reply className="w-3.5 h-3.5" />
|
||||||
<span>{t("mail.reply")}</span>
|
<span>{t("mail.reply")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
{/* Forward carries the subject but no recipient: it used to
|
||||||
<Link
|
prefill the original sender, which sends the message
|
||||||
href={`/user/compose?replyTo=${encodeURIComponent(
|
straight back to whoever wrote it. */}
|
||||||
selected.from
|
<Link href={`/user/compose?subject=${encodeURIComponent(selected.subject)}`}>
|
||||||
)}&subject=${encodeURIComponent(selected.subject)}`}
|
|
||||||
>
|
|
||||||
<Button variant="secondary" size="sm">
|
<Button variant="secondary" size="sm">
|
||||||
<Forward className="w-3.5 h-3.5" />
|
<Forward className="w-3.5 h-3.5" />
|
||||||
<span>{t("mail.forward")}</span>
|
<span className="hidden sm:inline">{t("mail.forward")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
<header className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<h1 className="text-lg md:text-xl font-bold text-white tracking-tight leading-snug break-words">
|
||||||
<div className="w-8 h-8 rounded-full bg-indigo-500/20 border border-indigo-500/30 text-indigo-400 flex items-center justify-center font-bold">
|
{selected.subject || t("mail.noSubject")}
|
||||||
<User className="w-4 h-4" />
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-gradient-to-tr from-indigo-500/30 to-cyan-500/20 border border-indigo-500/30 text-indigo-200 flex items-center justify-center text-[11px] font-bold flex-shrink-0">
|
||||||
|
{initials(selectedRow?.from_display_name ?? "", selected.from)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0 flex-1 text-xs">
|
||||||
<div className="font-semibold text-slate-100 text-xs">{selected.from}</div>
|
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||||
|
<span className="font-semibold text-slate-100 break-all">{selected.from}</span>
|
||||||
|
<span className="text-[11px] text-slate-500 font-mono">
|
||||||
|
{selected.date
|
||||||
|
? new Date(selected.date).toLocaleString(locale)
|
||||||
|
: selectedRow
|
||||||
|
? new Date(selectedRow.received_at).toLocaleString(locale)
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Recipients are labelled with translated headings; they
|
||||||
|
used to be introduced by a Portuguese word regardless of
|
||||||
|
the interface language. */}
|
||||||
{selected.to.length > 0 && (
|
{selected.to.length > 0 && (
|
||||||
<div className="text-[10px] text-slate-400">
|
<div className="text-[11px] text-slate-400 break-all">
|
||||||
Para: {selected.to.join(", ")}
|
<span className="text-slate-500">{t("ui.toRecipients")}: </span>
|
||||||
|
{selected.to.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{selected.cc.length > 0 && (
|
||||||
|
<div className="text-[11px] text-slate-400 break-all">
|
||||||
|
<span className="text-slate-500">{t("ui.ccRecipients")}: </span>
|
||||||
|
{selected.cc.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedRow && selectedRow.size_bytes > 0 && (
|
||||||
|
<div className="text-[11px] text-slate-500 font-mono">
|
||||||
|
{formatBytes(selectedRow.size_bytes)}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-slate-400 text-[11px]">
|
)}
|
||||||
{selected.date ? new Date(selected.date).toLocaleString() : ""}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Attachment Download Chips */}
|
{/* Attachment Download Chips */}
|
||||||
{selected.attachments.length > 0 && (
|
{selected.attachments.length > 0 && (
|
||||||
<div className="pt-3 border-t border-slate-800/80 flex flex-wrap gap-2 text-xs">
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
{selected.attachments.map((filename, idx) => (
|
{selected.attachments.map((filename, idx) => (
|
||||||
<a
|
<a
|
||||||
key={idx}
|
key={`${filename}-${idx}`}
|
||||||
href={`/api/v1/mail/message/${selected.uid}/attachment/${encodeURIComponent(
|
href={`/api/v1/mail/message/${selected.uid}/attachment/${encodeURIComponent(filename)}?folder=${encodeURIComponent(folder)}`}
|
||||||
filename
|
|
||||||
)}`}
|
|
||||||
download={filename}
|
download={filename}
|
||||||
className="px-3 py-1.5 rounded-xl bg-slate-900/90 border border-slate-800 hover:border-indigo-500 text-indigo-300 text-xs font-mono flex items-center gap-2 transition-all shadow-sm"
|
className="group max-w-full px-3 py-2 rounded-xl bg-slate-900/80 border border-slate-800 hover:border-indigo-500/60 hover:bg-slate-900 text-slate-200 flex items-center gap-2 transition-all"
|
||||||
>
|
>
|
||||||
<Paperclip className="w-3.5 h-3.5 text-indigo-400" />
|
<Paperclip className="w-3.5 h-3.5 text-indigo-400 flex-shrink-0" />
|
||||||
<span>{filename}</span>
|
<span className="truncate font-mono text-[11px]">{filename}</span>
|
||||||
<ExternalLink className="w-3 h-3 text-slate-500" />
|
<Download className="w-3.5 h-3.5 text-slate-500 group-hover:text-indigo-300 flex-shrink-0" />
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
{/* Remote Images Privacy Banner */}
|
{/* Remote content banner, shown only when something was actually
|
||||||
{selected.html && (
|
held back rather than over every message. */}
|
||||||
<div className="p-3.5 rounded-xl bg-amber-500/10 border border-amber-500/25 flex items-center justify-between text-xs text-amber-300">
|
{hasBlockedContent && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/25 flex flex-wrap items-center justify-between gap-2 text-xs text-amber-200">
|
||||||
<ImageIcon className="w-4 h-4 text-amber-400" />
|
<span className="flex items-center gap-2">
|
||||||
<span>{t("mail.loadImages")} (sanitised)</span>
|
<ImageIcon className="w-4 h-4 text-amber-400 flex-shrink-0" />
|
||||||
</div>
|
<span>{t("mail.remoteContentBlocked")}</span>
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setLoadRemoteImages(!loadRemoteImages)}
|
onClick={() => setLoadRemoteImages(!loadRemoteImages)}
|
||||||
className="px-3 py-1 rounded-lg bg-amber-500/20 hover:bg-amber-500/30 text-amber-200 font-medium transition-colors"
|
className="px-3 py-1 rounded-lg bg-amber-500/20 hover:bg-amber-500/30 text-amber-100 font-medium transition-colors"
|
||||||
>
|
>
|
||||||
{loadRemoteImages ? "Imagens Habilitadas" : t("mail.loadImages")}
|
{loadRemoteImages ? t("mail.imagesEnabled") : t("mail.loadImages")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Message Body Container */}
|
{/* Message Body */}
|
||||||
{bodyHtml ? (
|
{readerDocument ? (
|
||||||
<div className="bg-white rounded-2xl overflow-hidden shadow-2xl border border-slate-800 min-h-[350px]">
|
<div className="bg-white rounded-2xl overflow-hidden border border-slate-800 shadow-xl">
|
||||||
<iframe
|
<iframe
|
||||||
title={t("ui.messageBody")}
|
title={t("ui.messageBody")}
|
||||||
srcDoc={bodyHtml}
|
srcDoc={readerDocument}
|
||||||
sandbox=""
|
/* allow-scripts, and deliberately not allow-same-origin: the
|
||||||
className="w-full h-[500px] border-0"
|
frame stays on an opaque origin - it cannot read a cookie
|
||||||
|
or touch this document - while still running the few lines
|
||||||
|
that report its height and open links outward. Message
|
||||||
|
markup is sanitised before it gets here. */
|
||||||
|
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
style={{ height: `${readerHeight}px` }}
|
||||||
|
className="w-full border-0 block"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
|
<div className="rounded-2xl border border-slate-800 bg-slate-900/60 p-5">
|
||||||
<pre className="whitespace-pre-wrap break-words text-sm text-slate-200 font-sans leading-relaxed">
|
<pre className="whitespace-pre-wrap break-words text-sm text-slate-200 font-sans leading-relaxed">
|
||||||
{selected.text}
|
{selected.text || t("mail.emptyBody")}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.article>
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
className="flex-1 flex flex-col items-center justify-center text-slate-500 text-sm gap-3 py-20"
|
className="flex-1 flex flex-col items-center justify-center text-slate-500 text-sm gap-3 py-20 px-6 text-center"
|
||||||
>
|
>
|
||||||
<Mail className="w-12 h-12 text-slate-700 stroke-1" />
|
<Mail className="w-12 h-12 text-slate-700 stroke-1" />
|
||||||
<span>{t("mail.noMessages")}</span>
|
{/* This pane used to say "No messages" even with a full list
|
||||||
|
beside it: the empty reader is not an empty folder. */}
|
||||||
|
<span>{t("mail.selectAMessage")}</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { sanitizeEmailHtml, blockRemoteImages } from "./lib/sanitizer.js";
|
||||||
|
import { resolveInlineImages, buildReaderDocument } from "./lib/emailBody.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reader pane showed broken image icons for every message written by a
|
||||||
|
* normal mail client. Three separate causes, one per group below: inline parts
|
||||||
|
* were referenced as cid: and never resolved, embedded data: images were
|
||||||
|
* stripped by the sanitizer, and the frame the body was dropped into had no
|
||||||
|
* styles, so even a message that did render was unreadable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("inline (cid:) images", () => {
|
||||||
|
it("resolves a cid reference against the parts the server returned", () => {
|
||||||
|
const html = `<p>hi</p><img src="cid:logo@x" alt="logo">`;
|
||||||
|
const out = resolveInlineImages(html, { "logo@x": "data:image/png;base64,AAA" });
|
||||||
|
expect(out).toContain(`src="data:image/png;base64,AAA"`);
|
||||||
|
expect(out).not.toContain("cid:");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mail clients are inconsistent about the angle brackets, and a reader that
|
||||||
|
// only understands one spelling shows a broken icon for the other.
|
||||||
|
it("matches a cid whether or not the reference carries angle brackets", () => {
|
||||||
|
const out = resolveInlineImages(`<img src="cid:<sig>">`, { sig: "data:image/gif;base64,BBB" });
|
||||||
|
expect(out).toContain("data:image/gif;base64,BBB");
|
||||||
|
});
|
||||||
|
|
||||||
|
// A missing part must not leave src="cid:..." on the page: the browser
|
||||||
|
// renders that as a broken-image icon, which reads as a bug in the client.
|
||||||
|
it("drops the source when no such part was delivered", () => {
|
||||||
|
const out = resolveInlineImages(`<img src="cid:missing" alt="chart">`, {});
|
||||||
|
expect(out).not.toContain("cid:missing");
|
||||||
|
expect(out).not.toMatch(/\ssrc=/);
|
||||||
|
expect(out).toContain('alt="chart"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves ordinary remote and data sources untouched", () => {
|
||||||
|
const html = `<img src="https://cdn.test/a.png"><img src="data:image/png;base64,CCC">`;
|
||||||
|
expect(resolveInlineImages(html, {})).toBe(html);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("embedded images survive sanitising", () => {
|
||||||
|
// The URI allow-list had no data:, so DOMPurify removed the src from every
|
||||||
|
// embedded image - including the ones this app itself inlines for cid:.
|
||||||
|
it("keeps a data: image, which is how an inline part is delivered", () => {
|
||||||
|
const out = sanitizeEmailHtml(`<img src="data:image/png;base64,iVBORw0KGgo=">`);
|
||||||
|
expect(out).toContain("data:image/png;base64,iVBORw0KGgo=");
|
||||||
|
});
|
||||||
|
|
||||||
|
// An SVG is a document that carries script; it is the one image type that
|
||||||
|
// must not come in through a data: URI.
|
||||||
|
it("still refuses a data: SVG and a data: document", () => {
|
||||||
|
expect(sanitizeEmailHtml(`<img src="data:image/svg+xml;base64,PHN2Zz4=">`)).not.toContain("data:");
|
||||||
|
expect(sanitizeEmailHtml(`<a href="data:text/html;base64,PHN2Zz4=">x</a>`)).not.toContain("data:");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Blocking exists to stop a remote tracking pixel. An embedded image made no
|
||||||
|
// network request, so blocking it only breaks the message.
|
||||||
|
it("does not block an image that is already embedded", () => {
|
||||||
|
const blocked = blockRemoteImages(`<img src="data:image/png;base64,DDD">`);
|
||||||
|
expect(blocked).toContain(`src="data:image/png;base64,DDD"`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the document the reader frame is given", () => {
|
||||||
|
const doc = buildReaderDocument("<p>Hello</p>");
|
||||||
|
|
||||||
|
it("is a complete document, not a bare fragment", () => {
|
||||||
|
expect(doc).toMatch(/^<!doctype html>/i);
|
||||||
|
expect(doc).toContain("<p>Hello</p>");
|
||||||
|
expect(doc).toContain("charset");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mail is written against desktop widths; without this a newsletter forces
|
||||||
|
// the frame into a horizontal scrollbar over its own content.
|
||||||
|
it("constrains oversized content instead of letting it overflow", () => {
|
||||||
|
expect(doc).toContain("max-width:100%");
|
||||||
|
expect(doc).toContain("overflow-wrap");
|
||||||
|
});
|
||||||
|
|
||||||
|
// A frame with sandbox="" has an opaque origin, so a link opening in place
|
||||||
|
// strands the reader on a blank pane they cannot navigate back from.
|
||||||
|
it("opens links in a new tab and sends no referrer", () => {
|
||||||
|
expect(doc).toContain('target="_blank"');
|
||||||
|
expect(doc).toContain("noreferrer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders on a light surface whatever the app theme is", () => {
|
||||||
|
// Messages assume a white page; painting one behind them stops dark text
|
||||||
|
// on a dark background, which is what made half the mail unreadable.
|
||||||
|
expect(doc).toContain("#ffffff");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The frame is sized from its content, so the page must be able to report
|
||||||
|
// its own height back out.
|
||||||
|
it("reports its content height to the host page", () => {
|
||||||
|
expect(doc).toContain("postMessage");
|
||||||
|
expect(doc).toContain("lm:reader-height");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -137,10 +137,13 @@ describe("surface routing", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The destination is the step-up rather than the admin sign-in: the console
|
||||||
|
// costs a second factor, but not the password the session in hand just
|
||||||
|
// proved. mail-state-integrity.test.ts covers the rest of that crossing.
|
||||||
it("offers the console from inside the app, gated on the account's role", () => {
|
it("offers the console from inside the app, gated on the account's role", () => {
|
||||||
const layout = readFileSync(resolve(process.cwd(), "src/app/(user)/user/layout.tsx"), "utf8");
|
const layout = readFileSync(resolve(process.cwd(), "src/app/(user)/user/layout.tsx"), "utf8");
|
||||||
expect(layout).toContain("isAdminRole");
|
expect(layout).toContain("isAdminRole");
|
||||||
expect(layout).toContain("/admin/login");
|
expect(layout).toContain("/admin/step-up");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers a plain way back to webmail from the console", () => {
|
it("offers a plain way back to webmail from the console", () => {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Turns a message's sanitized HTML into something a frame can actually show.
|
||||||
|
*
|
||||||
|
* Two things were missing and both looked like "images are broken": a message
|
||||||
|
* written by any normal mail client references its own pictures as
|
||||||
|
* src="cid:...", which resolves against nothing in a browser; and the frame was
|
||||||
|
* handed a bare fragment with no document around it, so mail written for a
|
||||||
|
* white page rendered as dark text on a dark surface with no width limit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The parts the API returns alongside the body, keyed by Content-ID. */
|
||||||
|
export type InlineImages = Record<string, string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces src="cid:x" with the data: URI the server delivered for that part.
|
||||||
|
*
|
||||||
|
* A reference with no matching part loses its source entirely rather than
|
||||||
|
* keeping a cid: the browser cannot fetch: an unresolvable src draws a
|
||||||
|
* broken-image icon, while no src at all falls back to the alt text.
|
||||||
|
*/
|
||||||
|
export function resolveInlineImages(html: string, images: InlineImages): string {
|
||||||
|
if (!html) return "";
|
||||||
|
|
||||||
|
return html.replace(
|
||||||
|
/(<img\b[^>]*?)\ssrc\s*=\s*("|')cid:([^"']*)\2/gi,
|
||||||
|
(_match, head: string, _quote: string, reference: string) => {
|
||||||
|
// Clients disagree about whether the reference keeps the angle brackets
|
||||||
|
// the Content-ID header carries, so both spellings resolve.
|
||||||
|
const key = decodeURIComponent(reference.trim()).replace(/^<|>$/g, "");
|
||||||
|
const resolved = images[key];
|
||||||
|
return resolved ? `${head} src="${resolved}"` : head;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a message body in the document the reader frame renders.
|
||||||
|
*
|
||||||
|
* The frame is sandboxed with an opaque origin, so it cannot inherit anything
|
||||||
|
* from the app: every style, the light surface mail assumes, and the height
|
||||||
|
* report the host page sizes the frame from all have to travel inside it.
|
||||||
|
*/
|
||||||
|
export function buildReaderDocument(bodyHtml: string): string {
|
||||||
|
return `<!doctype html>
|
||||||
|
<html><head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<!-- Every link leaves this document: it has an opaque origin, so navigating in
|
||||||
|
place strands the reader on a blank pane with no way back. Declared here
|
||||||
|
rather than only in script so it holds even if script does not run. -->
|
||||||
|
<base target="_blank">
|
||||||
|
<!-- Any remote resource the reader opted into must not carry the message's URL
|
||||||
|
with it, and this holds without script. -->
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<style>
|
||||||
|
html { background:#ffffff; }
|
||||||
|
body {
|
||||||
|
margin:0; padding:20px; background:#ffffff; color:#1e293b;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
font-size:14px; line-height:1.6;
|
||||||
|
/* Mail is written against desktop widths. Without these a newsletter
|
||||||
|
pushes a horizontal scrollbar across its own content. */
|
||||||
|
overflow-wrap:anywhere; word-break:break-word;
|
||||||
|
}
|
||||||
|
img, video, table, pre { max-width:100% !important; }
|
||||||
|
img { height:auto; }
|
||||||
|
table { border-collapse:collapse; }
|
||||||
|
/* A layout table with a fixed pixel width is the usual overflow culprit. */
|
||||||
|
table[width] { width:auto !important; max-width:100% !important; }
|
||||||
|
blockquote {
|
||||||
|
margin:0 0 0 4px; padding-left:12px;
|
||||||
|
border-left:3px solid #cbd5e1; color:#475569;
|
||||||
|
}
|
||||||
|
a { color:#4f46e5; }
|
||||||
|
pre { white-space:pre-wrap; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${bodyHtml}
|
||||||
|
<script>
|
||||||
|
// <base> already sends the click to a new tab; this adds the rel that keeps
|
||||||
|
// the destination from learning where the click came from.
|
||||||
|
for (const link of document.querySelectorAll("a[href]")) {
|
||||||
|
link.setAttribute("rel", "noopener noreferrer");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The host sizes the frame from this rather than guessing: a fixed height
|
||||||
|
// either clips a long message or leaves a slab of empty white under a short
|
||||||
|
// one, and a sandboxed frame cannot be measured from outside.
|
||||||
|
const report = () => parent.postMessage(
|
||||||
|
{ type: "lm:reader-height", height: document.documentElement.scrollHeight }, "*");
|
||||||
|
report();
|
||||||
|
window.addEventListener("load", report);
|
||||||
|
if (typeof ResizeObserver === "function") new ResizeObserver(report).observe(document.body);
|
||||||
|
</script>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* The arithmetic behind every number the mail screens display.
|
||||||
|
*
|
||||||
|
* It lives here, apart from the components, because each of these rules was
|
||||||
|
* got wrong inside a JSX expression where nothing could test it: the sidebar
|
||||||
|
* resolved a folder by a role that folders do not always carry, the list header
|
||||||
|
* printed the length of the page it had loaded as though it were the size of
|
||||||
|
* the folder, and marking a message read moved no counter anywhere.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A folder as /api/v1/mail/folders returns it. */
|
||||||
|
export interface FolderSummary {
|
||||||
|
special_use: string;
|
||||||
|
name: string;
|
||||||
|
unread_count: number;
|
||||||
|
total_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fields of a message row these counts depend on. */
|
||||||
|
export interface MessageSummary {
|
||||||
|
uid: number;
|
||||||
|
seen: boolean;
|
||||||
|
has_attachments: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FolderMetrics {
|
||||||
|
unread: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListFilter = "unread" | "attachment" | null;
|
||||||
|
|
||||||
|
/** Clamps a counter to something displayable: no negatives, no NaN. */
|
||||||
|
function counter(value: unknown): number {
|
||||||
|
const numeric = typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||||
|
return Math.max(0, Math.trunc(numeric));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The counts for one folder, addressed the way the URL addresses it.
|
||||||
|
*
|
||||||
|
* A folder is matched on its special-use role or, failing that, its name: a
|
||||||
|
* folder an IMAP client created has no role, and matching only on the role left
|
||||||
|
* every one of them without a badge. The name comparison is case-insensitive
|
||||||
|
* because IMAP spells the inbox INBOX and the route spells it inbox.
|
||||||
|
*/
|
||||||
|
export function folderMetrics(
|
||||||
|
folders: FolderSummary[] | undefined | null,
|
||||||
|
route: string,
|
||||||
|
): FolderMetrics {
|
||||||
|
const wanted = route.trim().toLowerCase();
|
||||||
|
const match = (folders ?? []).find(
|
||||||
|
(f) => f.special_use?.toLowerCase() === wanted || f.name?.toLowerCase() === wanted,
|
||||||
|
);
|
||||||
|
if (!match) return { unread: 0, total: 0 };
|
||||||
|
return { unread: counter(match.unread_count), total: counter(match.total_count) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number a sidebar folder shows, or 0 for no badge.
|
||||||
|
*
|
||||||
|
* Drafts is the exception: nothing ever marks a draft read, so its unread count
|
||||||
|
* is permanently zero and the badge never appeared - while the number that
|
||||||
|
* matters there, how many unfinished messages are waiting, was already on hand.
|
||||||
|
*/
|
||||||
|
export function badgeCount(folders: FolderSummary[] | undefined | null, route: string): number {
|
||||||
|
const { unread, total } = folderMetrics(folders, route);
|
||||||
|
return route.trim().toLowerCase() === "drafts" ? total : unread;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What is on the page that has been loaded, as opposed to what is in the folder. */
|
||||||
|
export function messageCounts(messages: MessageSummary[]): {
|
||||||
|
loaded: number;
|
||||||
|
unread: number;
|
||||||
|
withAttachments: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
loaded: messages.length,
|
||||||
|
unread: messages.filter((m) => !m.seen).length,
|
||||||
|
withAttachments: messages.filter((m) => m.has_attachments).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterMessages<T extends MessageSummary>(messages: T[], filter: ListFilter): T[] {
|
||||||
|
if (filter === "unread") return messages.filter((m) => !m.seen);
|
||||||
|
if (filter === "attachment") return messages.filter((m) => m.has_attachments);
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The count beside the list heading, and whether it describes the folder.
|
||||||
|
*
|
||||||
|
* Two numbers were being conflated. The size of the folder comes from the
|
||||||
|
* folder list; the number of rows on screen is whatever the API returned for
|
||||||
|
* this page, capped at 50 - so a large folder advertised 50 messages. During a
|
||||||
|
* search neither the folder total nor a cap is meaningful: what the reader
|
||||||
|
* wants is how many matched.
|
||||||
|
*
|
||||||
|
* The folder counter is denormalised and can lag, so it is never allowed to
|
||||||
|
* claim fewer messages than are already visible.
|
||||||
|
*/
|
||||||
|
export function listHeaderCount(input: {
|
||||||
|
folder: FolderMetrics;
|
||||||
|
loaded: number;
|
||||||
|
searching: boolean;
|
||||||
|
}): { count: number; isFolderTotal: boolean } {
|
||||||
|
if (input.searching) return { count: counter(input.loaded), isFolderTotal: false };
|
||||||
|
return {
|
||||||
|
count: Math.max(counter(input.folder.total), counter(input.loaded)),
|
||||||
|
isFolderTotal: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list with one message's read flag changed.
|
||||||
|
*
|
||||||
|
* A new array, because React reads this state: mutating in place left the row
|
||||||
|
* bold until some unrelated change forced a render.
|
||||||
|
*/
|
||||||
|
export function applySeen<T extends MessageSummary>(messages: T[], uid: number, seen: boolean): T[] {
|
||||||
|
return messages.map((m) => (m.uid === uid ? { ...m, seen } : m));
|
||||||
|
}
|
||||||
@@ -10,6 +10,29 @@ import DOMPurify from "dompurify";
|
|||||||
* a regex cannot parse HTML. DOMPurify parses, which is why it is the only
|
* a regex cannot parse HTML. DOMPurify parses, which is why it is the only
|
||||||
* thing here.
|
* thing here.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The data: URIs a message body may carry: raster images only.
|
||||||
|
*
|
||||||
|
* DOMPurify allows data: on an image source from its own built-in list, which
|
||||||
|
* includes image/svg+xml, and that list is consulted instead of
|
||||||
|
* ALLOWED_URI_REGEXP - so tightening the regexp alone does not exclude an SVG.
|
||||||
|
* The hook below is what actually refuses it, and an SVG has to be refused: it
|
||||||
|
* is a document that carries script, not a picture.
|
||||||
|
*/
|
||||||
|
const ALLOWED_DATA_IMAGE = /^data:image\/(?:png|gif|jpe?g|webp|bmp|x-icon|vnd\.microsoft\.icon);/i;
|
||||||
|
|
||||||
|
let hookInstalled = false;
|
||||||
|
|
||||||
|
function installDataUriHook(): void {
|
||||||
|
if (hookInstalled) return;
|
||||||
|
DOMPurify.addHook("uponSanitizeAttribute", (_node, data) => {
|
||||||
|
if (/^\s*data:/i.test(data.attrValue) && !ALLOWED_DATA_IMAGE.test(data.attrValue.trim())) {
|
||||||
|
data.keepAttr = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
hookInstalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
export function sanitizeEmailHtml(rawHtml: string): string {
|
export function sanitizeEmailHtml(rawHtml: string): string {
|
||||||
if (!rawHtml) return "";
|
if (!rawHtml) return "";
|
||||||
|
|
||||||
@@ -20,6 +43,8 @@ export function sanitizeEmailHtml(rawHtml: string): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
installDataUriHook();
|
||||||
|
|
||||||
return DOMPurify.sanitize(rawHtml, {
|
return DOMPurify.sanitize(rawHtml, {
|
||||||
ALLOWED_TAGS: [
|
ALLOWED_TAGS: [
|
||||||
"a", "b", "blockquote", "br", "caption", "code", "div", "em", "h1", "h2", "h3",
|
"a", "b", "blockquote", "br", "caption", "code", "div", "em", "h1", "h2", "h3",
|
||||||
@@ -37,8 +62,12 @@ export function sanitizeEmailHtml(rawHtml: string): string {
|
|||||||
FORBID_TAGS: ["script", "iframe", "object", "embed", "form", "input", "button", "style", "link", "meta", "base"],
|
FORBID_TAGS: ["script", "iframe", "object", "embed", "form", "input", "button", "style", "link", "meta", "base"],
|
||||||
FORBID_ATTR: ["srcset", "formaction", "ping", "id"],
|
FORBID_ATTR: ["srcset", "formaction", "ping", "id"],
|
||||||
ALLOW_DATA_ATTR: false,
|
ALLOW_DATA_ATTR: false,
|
||||||
// data: is how an anchor smuggles a document onto this origin.
|
// data: is how an anchor smuggles a document onto this origin, so only the
|
||||||
ALLOWED_URI_REGEXP: /^(?:https?|mailto|cid|tel):/i,
|
// raster image types are let through - which is how this app delivers a
|
||||||
|
// message's own inline parts, and rejecting them stripped the src from
|
||||||
|
// every embedded picture. image/svg+xml is deliberately absent: an SVG is
|
||||||
|
// a document that carries script.
|
||||||
|
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|cid|tel):|data:image\/(?:png|gif|jpe?g|webp|bmp|x-icon|vnd\.microsoft\.icon);)/i,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Which session a page needs before it can usefully render.
|
||||||
|
*
|
||||||
|
* Kept out of the middleware so it can be tested: as two inline prefix checks
|
||||||
|
* it could not express the case that matters, that /admin/step-up is reached
|
||||||
|
* *with* a webmail session and *without* an admin one. Gating it on an admin
|
||||||
|
* session made the screen that mints admin sessions unreachable.
|
||||||
|
*
|
||||||
|
* This is not the security boundary. Every /api/v1/* call is verified by the
|
||||||
|
* service that owns the signing key; this only avoids serving a shell that
|
||||||
|
* cannot work, and sends the visitor somewhere useful instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GatedSurface = "user" | "admin";
|
||||||
|
|
||||||
|
/** Paths inside a surface that must stay reachable without its session. */
|
||||||
|
const OPEN_PATHS = new Set(["/user/login", "/admin/login"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paths that live under one surface but are gated on another.
|
||||||
|
*
|
||||||
|
* The step-up trades a webmail session for an admin one, so requiring the
|
||||||
|
* admin session it produces would be circular.
|
||||||
|
*/
|
||||||
|
const CROSS_GATED: Record<string, GatedSurface> = {
|
||||||
|
"/admin/step-up": "user",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True when `pathname` is inside `area` as a path segment, not as a prefix. */
|
||||||
|
function inArea(pathname: string, area: string): boolean {
|
||||||
|
return pathname === area || pathname.startsWith(`${area}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function surfaceGateFor(pathname: string): GatedSurface | null {
|
||||||
|
if (OPEN_PATHS.has(pathname)) return null;
|
||||||
|
if (CROSS_GATED[pathname]) return CROSS_GATED[pathname];
|
||||||
|
if (inArea(pathname, "/admin")) return "admin";
|
||||||
|
if (inArea(pathname, "/user")) return "user";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useMailEvents } from "./useMailEvents";
|
||||||
|
import type { FolderSummary } from "./mailCounts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The folder list, kept current.
|
||||||
|
*
|
||||||
|
* It used to be fetched once when the sidebar mounted, which froze every unread
|
||||||
|
* badge at whatever it was when the tab was opened: mail arriving did not raise
|
||||||
|
* it, and reading a message did not lower it. Two things move these counters,
|
||||||
|
* so both have to reach here - the server's push for anything that happened
|
||||||
|
* elsewhere, and an in-page announcement for what this tab just did.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired on window when this tab changes something a counter is derived from.
|
||||||
|
*
|
||||||
|
* A DOM event rather than shared React state because the two are in different
|
||||||
|
* subtrees: the reader is inside the page, the badges are in the layout around
|
||||||
|
* it, and neither renders the other.
|
||||||
|
*/
|
||||||
|
export const MAIL_STATE_CHANGED = "lm:mail-state-changed";
|
||||||
|
|
||||||
|
/** Announces that this tab moved a counter, so the badges re-read them. */
|
||||||
|
export function notifyMailStateChanged(): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.dispatchEvent(new Event(MAIL_STATE_CHANGED));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFolders(): { folders: FolderSummary[]; reload: () => void } {
|
||||||
|
const [folders, setFolders] = useState<FolderSummary[]>([]);
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
void fetch("/api/v1/mail/folders", { cache: "no-store" })
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
// Only a real list replaces the current one: overwriting with [] on a
|
||||||
|
// failed request would blank every badge on a transient error.
|
||||||
|
if (Array.isArray(data)) setFolders(data as FolderSummary[]);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(reload, [reload]);
|
||||||
|
|
||||||
|
// Any push means something changed in the mailbox; which folder it landed in
|
||||||
|
// is not worth deducing here when re-reading the counters is one small call.
|
||||||
|
useMailEvents(useCallback(() => reload(), [reload]));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener(MAIL_STATE_CHANGED, reload);
|
||||||
|
return () => window.removeEventListener(MAIL_STATE_CHANGED, reload);
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
return { folders, reload };
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wiring behind the counters, the reader pane and the two surfaces.
|
||||||
|
*
|
||||||
|
* mailCounts.test.ts and emailBody.test.ts cover the rules themselves; these
|
||||||
|
* check that the screens actually go through them, because every defect in this
|
||||||
|
* file was a component computing the number or the markup inline where nothing
|
||||||
|
* could reach it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const read = (path: string) => readFileSync(resolve(process.cwd(), path), "utf8");
|
||||||
|
/** Strips comments, so a fix may name the code it removed without tripping. */
|
||||||
|
const code = (path: string) =>
|
||||||
|
read(path)
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||||
|
.replace(/\{\/\*[\s\S]*?\*\/\}/g, "")
|
||||||
|
.replace(/^\s*\/\/.*$/gm, "");
|
||||||
|
|
||||||
|
const USER_LAYOUT = "src/app/(user)/user/layout.tsx";
|
||||||
|
const MAIL_PAGE = "src/app/(user)/user/mail/[folder]/page.tsx";
|
||||||
|
const ADMIN_LOGIN = "src/app/(auth)/admin/login/page.tsx";
|
||||||
|
const USER_LOGIN = "src/app/(auth)/user/login/page.tsx";
|
||||||
|
|
||||||
|
describe("the counters the sidebar shows", () => {
|
||||||
|
// The badge was computed inline from folders.find(...).unread_count, which
|
||||||
|
// missed folders with no special-use role and printed nothing for Drafts.
|
||||||
|
it("goes through the tested counting rules", () => {
|
||||||
|
expect(code(USER_LAYOUT)).toContain("badgeCount");
|
||||||
|
expect(code(USER_LAYOUT)).not.toMatch(/unread_count\s*\?\?/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The folder list was fetched once on mount, so every badge froze at the
|
||||||
|
// value it had when the tab was opened: new mail did not raise it and reading
|
||||||
|
// a message did not lower it.
|
||||||
|
it("refreshes the folder list instead of fetching it once on mount", () => {
|
||||||
|
const layout = code(USER_LAYOUT);
|
||||||
|
expect(layout).toContain("useFolders");
|
||||||
|
expect(layout).not.toMatch(/useEffect\([^)]*\)\s*=>\s*\{\s*void fetch\("\/api\/v1\/mail\/folders"\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-reads the counters when the server pushes a mailbox event", () => {
|
||||||
|
expect(code("src/lib/useFolders.ts")).toContain("useMailEvents");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reading a message changes a counter that lives in another component, so the
|
||||||
|
// reader has to say so; nothing else can know.
|
||||||
|
it("gives the reader a way to announce that a counter moved", () => {
|
||||||
|
expect(code("src/lib/useFolders.ts")).toContain("MAIL_STATE_CHANGED");
|
||||||
|
expect(code(MAIL_PAGE)).toContain("notifyMailStateChanged");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the counters the message list shows", () => {
|
||||||
|
// The header printed messages.length - the size of one page, capped at 50 -
|
||||||
|
// as though it were the size of the folder.
|
||||||
|
it("does not present the length of the loaded page as the folder total", () => {
|
||||||
|
const page = code(MAIL_PAGE);
|
||||||
|
expect(page).toContain("listHeaderCount");
|
||||||
|
expect(page).not.toContain("({messages.length})");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters through the tested rule rather than a chain of inline predicates", () => {
|
||||||
|
expect(code(MAIL_PAGE)).toContain("filterMessages");
|
||||||
|
});
|
||||||
|
|
||||||
|
// A chip that filters by unread or by attachment is worth a number, and both
|
||||||
|
// were available on the page already.
|
||||||
|
it("shows how many messages each filter chip would keep", () => {
|
||||||
|
expect(code(MAIL_PAGE)).toContain("messageCounts");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("read and unread state", () => {
|
||||||
|
// Opening a message marked it read on the server, so the flag flipping back
|
||||||
|
// on the next reload was the list, the badge and the server disagreeing.
|
||||||
|
it("persists a read-state change through the API, not only in local state", () => {
|
||||||
|
expect(code(MAIL_PAGE)).toContain("/api/v1/mail/seen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the row in step through the tested helper", () => {
|
||||||
|
expect(code(MAIL_PAGE)).toContain("applySeen");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the reader pane", () => {
|
||||||
|
it("resolves the message's own inline images", () => {
|
||||||
|
const page = code(MAIL_PAGE);
|
||||||
|
expect(page).toContain("resolveInlineImages");
|
||||||
|
expect(page).toContain("inline_images");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the body inside the tested document rather than a bare fragment", () => {
|
||||||
|
expect(code(MAIL_PAGE)).toContain("buildReaderDocument");
|
||||||
|
});
|
||||||
|
|
||||||
|
// A fixed 500px frame clipped every long message and left a slab of white
|
||||||
|
// under every short one.
|
||||||
|
it("sizes the frame from the message instead of pinning it to 500px", () => {
|
||||||
|
const page = code(MAIL_PAGE);
|
||||||
|
expect(page).not.toContain("h-[500px]");
|
||||||
|
expect(page).toContain("lm:reader-height");
|
||||||
|
// The height report needs script inside the frame, which sandbox="" forbids.
|
||||||
|
expect(page).toContain("allow-scripts");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every one of these was a Portuguese string typed into a component that had
|
||||||
|
// a translated bundle beside it, so the interface was half English for a
|
||||||
|
// Spanish reader and half Portuguese for an English one.
|
||||||
|
it("has no hardcoded strings left in the reader", () => {
|
||||||
|
const page = read(MAIL_PAGE);
|
||||||
|
for (const literal of ["Sem assunto", "Para:", "Imagens Habilitadas", "(sanitised)"]) {
|
||||||
|
expect(page).not.toContain(literal);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("moving between the two surfaces", () => {
|
||||||
|
// The mailbox is the account's own mail behind the account's own password.
|
||||||
|
it("does not ask the webmail sign-in for a second factor", () => {
|
||||||
|
const login = code(USER_LOGIN);
|
||||||
|
expect(login).not.toContain("mfa_required");
|
||||||
|
expect(login).not.toContain("challenge_token");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Crossing into the console asked for the password again, though the session
|
||||||
|
// in hand had just proven it.
|
||||||
|
it("steps up into the console instead of sending the operator to a password form", () => {
|
||||||
|
expect(code(USER_LAYOUT)).toContain("/admin/step-up");
|
||||||
|
expect(code(USER_LAYOUT)).not.toContain('href="/admin/login"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collects only the second factor on the step-up screen", () => {
|
||||||
|
const stepUp = code("src/app/(auth)/admin/step-up/page.tsx");
|
||||||
|
expect(stepUp).toContain("/api/v1/auth/admin/step-up");
|
||||||
|
expect(stepUp).not.toContain('type="password"');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Leaving the console needs nothing: the admin sign-in now issues the webmail
|
||||||
|
// session too, so the link back cannot land on a login screen.
|
||||||
|
it("keeps a plain link out of the console", () => {
|
||||||
|
expect(code("src/app/(admin)/admin/layout.tsx")).toContain("/user/mail/inbox");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still requires the second factor at the admin sign-in itself", () => {
|
||||||
|
expect(code(ADMIN_LOGIN)).toContain("challenge_token");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
folderMetrics,
|
||||||
|
badgeCount,
|
||||||
|
messageCounts,
|
||||||
|
filterMessages,
|
||||||
|
listHeaderCount,
|
||||||
|
applySeen,
|
||||||
|
type FolderSummary,
|
||||||
|
type MessageSummary,
|
||||||
|
} from "./lib/mailCounts.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The counters the interface shows.
|
||||||
|
*
|
||||||
|
* Every case here is something the screens got wrong: the sidebar matched a
|
||||||
|
* folder by a role it does not always carry, the message list printed the
|
||||||
|
* length of the page it had loaded as if it were the size of the folder, that
|
||||||
|
* number then changed while typing in the search box, and marking a message
|
||||||
|
* read moved no counter at all.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const folders: FolderSummary[] = [
|
||||||
|
{ special_use: "inbox", name: "INBOX", unread_count: 3, total_count: 42 },
|
||||||
|
{ special_use: "drafts", name: "Drafts", unread_count: 0, total_count: 2 },
|
||||||
|
{ special_use: "", name: "Projects", unread_count: 5, total_count: 9 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("resolving a folder", () => {
|
||||||
|
it("finds it by its special-use role", () => {
|
||||||
|
expect(folderMetrics(folders, "inbox")).toEqual({ unread: 3, total: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// A folder created by an IMAP client has no special-use role at all, so the
|
||||||
|
// route segment is all there is to match on.
|
||||||
|
it("falls back to the folder name when there is no role", () => {
|
||||||
|
expect(folderMetrics(folders, "projects")).toEqual({ unread: 5, total: 9 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches the name regardless of case, as IMAP INBOX requires", () => {
|
||||||
|
expect(folderMetrics(folders, "Inbox").total).toBe(42);
|
||||||
|
expect(folderMetrics([{ special_use: "", name: "INBOX", unread_count: 1, total_count: 7 }], "inbox").total)
|
||||||
|
.toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Before the folder list has loaded there is nothing to read counts from.
|
||||||
|
// Zero is the honest answer; NaN rendered as "NaN" in the badge.
|
||||||
|
it("reports zeros rather than NaN for a folder it does not have yet", () => {
|
||||||
|
expect(folderMetrics([], "inbox")).toEqual({ unread: 0, total: 0 });
|
||||||
|
expect(folderMetrics(undefined, "inbox")).toEqual({ unread: 0, total: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// A counter column can drift below zero on a bad migration; the interface
|
||||||
|
// must not be the place that publishes it.
|
||||||
|
it("never reports a negative count", () => {
|
||||||
|
const broken: FolderSummary[] = [{ special_use: "inbox", name: "INBOX", unread_count: -4, total_count: -1 }];
|
||||||
|
expect(folderMetrics(broken, "inbox")).toEqual({ unread: 0, total: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the number on a sidebar folder", () => {
|
||||||
|
it("is the unread count for a folder that receives mail", () => {
|
||||||
|
expect(badgeCount(folders, "inbox")).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nothing marks a draft read, so its unread count is always zero and the
|
||||||
|
// badge was always absent - while the useful number, how many unfinished
|
||||||
|
// messages are waiting, was on hand the whole time.
|
||||||
|
it("is the total for Drafts, which has no notion of unread", () => {
|
||||||
|
expect(badgeCount(folders, "drafts")).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is absent, not a zero, when there is nothing to report", () => {
|
||||||
|
expect(badgeCount(folders, "sent")).toBe(0);
|
||||||
|
expect(badgeCount([{ special_use: "drafts", name: "Drafts", unread_count: 0, total_count: 0 }], "drafts"))
|
||||||
|
.toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages: MessageSummary[] = [
|
||||||
|
{ uid: 1, seen: false, has_attachments: true },
|
||||||
|
{ uid: 2, seen: true, has_attachments: false },
|
||||||
|
{ uid: 3, seen: false, has_attachments: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("counting the loaded page", () => {
|
||||||
|
it("counts what is on it by unread and by attachment", () => {
|
||||||
|
expect(messageCounts(messages)).toEqual({ loaded: 3, unread: 2, withAttachments: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts an empty page as zeros", () => {
|
||||||
|
expect(messageCounts([])).toEqual({ loaded: 0, unread: 0, withAttachments: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filtering the list", () => {
|
||||||
|
it("keeps everything when no filter is set", () => {
|
||||||
|
expect(filterMessages(messages, null)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only unread, and only messages with attachments", () => {
|
||||||
|
expect(filterMessages(messages, "unread").map((m) => m.uid)).toEqual([1, 3]);
|
||||||
|
expect(filterMessages(messages, "attachment").map((m) => m.uid)).toEqual([1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the count in the list header", () => {
|
||||||
|
// The header printed messages.length, which is the size of the page the API
|
||||||
|
// returned - capped at 50 - so a folder holding 4000 messages advertised 50.
|
||||||
|
it("is the folder's size, not the size of the page that was loaded", () => {
|
||||||
|
expect(listHeaderCount({ folder: { unread: 12, total: 4000 }, loaded: 50, searching: false })).toEqual({
|
||||||
|
count: 4000,
|
||||||
|
isFolderTotal: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// While searching, the folder total is not what the reader is looking at, and
|
||||||
|
// showing it made the number jump around as they typed.
|
||||||
|
it("is the number of matches while a search is active", () => {
|
||||||
|
expect(listHeaderCount({ folder: { unread: 3, total: 42 }, loaded: 2, searching: true })).toEqual({
|
||||||
|
count: 2,
|
||||||
|
isFolderTotal: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A folder whose counter column is behind - it is denormalised - must not
|
||||||
|
// claim fewer messages than the page already on screen.
|
||||||
|
it("never reports fewer messages than are visible", () => {
|
||||||
|
expect(listHeaderCount({ folder: { unread: 0, total: 1 }, loaded: 12, searching: false })).toEqual({
|
||||||
|
count: 12,
|
||||||
|
isFolderTotal: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("marking a message read in the loaded list", () => {
|
||||||
|
it("flips the flag on that message only", () => {
|
||||||
|
const next = applySeen(messages, 1, true);
|
||||||
|
expect(next.find((m) => m.uid === 1)?.seen).toBe(true);
|
||||||
|
expect(next.find((m) => m.uid === 3)?.seen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reduces the unread count of the page it returns", () => {
|
||||||
|
expect(messageCounts(applySeen(messages, 1, true)).unread).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can put a message back to unread", () => {
|
||||||
|
expect(messageCounts(applySeen(messages, 2, false)).unread).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The list state is read by React, so a mutation in place would not re-render
|
||||||
|
// and the row would keep its bold styling until something else moved.
|
||||||
|
it("returns a new array rather than mutating the one it was given", () => {
|
||||||
|
const next = applySeen(messages, 1, true);
|
||||||
|
expect(next).not.toBe(messages);
|
||||||
|
expect(messages.find((m) => m.uid === 1)?.seen).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { surfaceGateFor } from "./lib/surfaceGate";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gate for the two web surfaces.
|
* Gate for the two web surfaces.
|
||||||
@@ -52,14 +53,16 @@ function isUsable(claims: JwtClaims | null, expectedAud: string): boolean {
|
|||||||
export function middleware(req: NextRequest) {
|
export function middleware(req: NextRequest) {
|
||||||
const { pathname, search } = req.nextUrl;
|
const { pathname, search } = req.nextUrl;
|
||||||
|
|
||||||
const isAdminArea = pathname.startsWith("/admin") && !pathname.startsWith("/admin/login");
|
// Which session this path needs is decided by surfaceGateFor, where it is
|
||||||
const isUserArea = pathname.startsWith("/user") && !pathname.startsWith("/user/login");
|
// tested. It is not always the surface the path sits under: /admin/step-up
|
||||||
if (!isAdminArea && !isUserArea) {
|
// exists to turn a webmail session into an admin one, so requiring the admin
|
||||||
|
// session would make it unreachable.
|
||||||
|
const surface = surfaceGateFor(pathname);
|
||||||
|
if (!surface) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const surface = isAdminArea ? "admin" : "user";
|
const cookieName = surface === "admin" ? ADMIN_COOKIE : USER_COOKIE;
|
||||||
const cookieName = isAdminArea ? ADMIN_COOKIE : USER_COOKIE;
|
|
||||||
const claims = readClaims(req.cookies.get(cookieName)?.value);
|
const claims = readClaims(req.cookies.get(cookieName)?.value);
|
||||||
|
|
||||||
if (isUsable(claims, `lambdamail:${surface}`)) {
|
if (isUsable(claims, `lambdamail:${surface}`)) {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { surfaceGateFor } from "./lib/surfaceGate.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which session, if any, a page needs before it may render.
|
||||||
|
*
|
||||||
|
* The gate used to be a pair of string comparisons inside the middleware, and
|
||||||
|
* it had no way to express the case that matters most here: the step-up screen
|
||||||
|
* lives under /admin but is reached with a webmail session, because obtaining
|
||||||
|
* the admin one is what it is for. Gating it on an admin session made it
|
||||||
|
* unreachable and bounced the operator to the password form it replaces.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("the session a path requires", () => {
|
||||||
|
it("guards the console with the admin session", () => {
|
||||||
|
expect(surfaceGateFor("/admin/dashboard")).toBe("admin");
|
||||||
|
expect(surfaceGateFor("/admin/domains")).toBe("admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards the webmail with the webmail session", () => {
|
||||||
|
expect(surfaceGateFor("/user/mail/inbox")).toBe("user");
|
||||||
|
expect(surfaceGateFor("/user/settings")).toBe("user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves both sign-in screens open", () => {
|
||||||
|
expect(surfaceGateFor("/user/login")).toBe(null);
|
||||||
|
expect(surfaceGateFor("/admin/login")).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The whole point of the step-up is that the caller has a webmail session and
|
||||||
|
// not yet an admin one.
|
||||||
|
it("gates the step-up on the webmail session it steps up from", () => {
|
||||||
|
expect(surfaceGateFor("/admin/step-up")).toBe("user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves everything outside the two surfaces alone", () => {
|
||||||
|
expect(surfaceGateFor("/")).toBe(null);
|
||||||
|
expect(surfaceGateFor("/health")).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// "/userland" is not the webmail, and "/admin-x" is not the console. Matching
|
||||||
|
// on a bare prefix would gate paths that have nothing to do with either.
|
||||||
|
it("does not gate a path that merely starts with the same letters", () => {
|
||||||
|
expect(surfaceGateFor("/userland")).toBe(null);
|
||||||
|
expect(surfaceGateFor("/administration")).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("where an ungated visitor is sent", () => {
|
||||||
|
it("sends someone without a webmail session to the webmail sign-in", () => {
|
||||||
|
expect(surfaceGateFor("/admin/step-up")).toBe("user");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user