diff --git a/migrations/0012_mailbox_avatars.down.sql b/migrations/0012_mailbox_avatars.down.sql
new file mode 100644
index 0000000..11c93e1
--- /dev/null
+++ b/migrations/0012_mailbox_avatars.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS mailbox_avatars;
diff --git a/migrations/0012_mailbox_avatars.up.sql b/migrations/0012_mailbox_avatars.up.sql
new file mode 100644
index 0000000..3d9c709
--- /dev/null
+++ b/migrations/0012_mailbox_avatars.up.sql
@@ -0,0 +1,17 @@
+-- The profile photo a mailbox shows inside LambdaMail.
+--
+-- Stored in the database rather than the blob spool: an avatar is small,
+-- there is at most one per mailbox, and it is read on nearly every screen -
+-- so it belongs where the mailbox row already is, and is deleted with it.
+--
+-- Not to be confused with BIMI, which is one logo for a whole domain and is
+-- the only thing an external mail client ever renders.
+CREATE TABLE IF NOT EXISTS mailbox_avatars (
+ mailbox_id UUID PRIMARY KEY REFERENCES mailboxes(id) ON DELETE CASCADE,
+ content_type VARCHAR(32) NOT NULL CHECK (content_type IN ('image/png','image/jpeg','image/webp')),
+ bytes BYTEA NOT NULL,
+ -- Lets a conditional request answer 304 instead of resending the image on
+ -- every screen that shows the person.
+ etag CHAR(64) NOT NULL,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
diff --git a/services/auth/src/imageValidation.test.ts b/services/auth/src/imageValidation.test.ts
new file mode 100644
index 0000000..19fdd87
--- /dev/null
+++ b/services/auth/src/imageValidation.test.ts
@@ -0,0 +1,58 @@
+import { describe, it, expect } from "vitest";
+import { sniffImage, checkAvatar, MAX_AVATAR_BYTES } from "./imageValidation.js";
+
+const png = () => Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
+const jpeg = () => Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0]);
+const webp = () => Buffer.concat([Buffer.from("RIFF"), Buffer.from([0, 0, 0, 0]), Buffer.from("WEBPVP8 ")]);
+
+describe("sniffImage", () => {
+ it("identifies the formats the editor produces", () => {
+ expect(sniffImage(png())?.contentType).toBe("image/png");
+ expect(sniffImage(jpeg())?.contentType).toBe("image/jpeg");
+ expect(sniffImage(webp())?.contentType).toBe("image/webp");
+ });
+
+ // The type comes from the bytes. A client that says "image/png" over an HTML
+ // document must not get an HTML document stored and served from our origin.
+ it("refuses anything that is not one of them, whatever it claims to be", () => {
+ expect(sniffImage(Buffer.from(""))).toBeNull();
+ expect(sniffImage(Buffer.from("GIF89a"))).toBeNull();
+ expect(sniffImage(Buffer.from("%PDF-1.7"))).toBeNull();
+ });
+
+ // SVG is an executable document, and it would be served back from our own
+ // origin - so it is refused even though it is an image.
+ it("refuses SVG", () => {
+ expect(sniffImage(Buffer.from(''))).toBeNull();
+ expect(sniffImage(Buffer.from(''))).toBeNull();
+ });
+
+ it("does not read past the end of a short buffer", () => {
+ expect(() => sniffImage(Buffer.from([0x89]))).not.toThrow();
+ expect(sniffImage(Buffer.alloc(0))).toBeNull();
+ // The first four bytes of a WEBP without the rest is not a WEBP.
+ expect(sniffImage(Buffer.from("RIFF"))).toBeNull();
+ });
+});
+
+describe("checkAvatar", () => {
+ it("accepts a real image", () => {
+ const res = checkAvatar(png());
+ expect(res.ok).toBe(true);
+ expect(res.contentType).toBe("image/png");
+ });
+
+ it("refuses an empty upload", () => {
+ expect(checkAvatar(Buffer.alloc(0))).toMatchObject({ ok: false, reason: "EMPTY" });
+ });
+
+ // On size before contents: an enormous upload should not be sniffed first.
+ it("refuses one that is too big", () => {
+ const huge = Buffer.concat([png(), Buffer.alloc(MAX_AVATAR_BYTES)]);
+ expect(checkAvatar(huge)).toMatchObject({ ok: false, reason: "TOO_LARGE" });
+ });
+
+ it("refuses a file that is not an image", () => {
+ expect(checkAvatar(Buffer.from("just text"))).toMatchObject({ ok: false, reason: "UNSUPPORTED_TYPE" });
+ });
+});
diff --git a/services/auth/src/imageValidation.ts b/services/auth/src/imageValidation.ts
new file mode 100644
index 0000000..c965fca
--- /dev/null
+++ b/services/auth/src/imageValidation.ts
@@ -0,0 +1,62 @@
+/**
+ * What an uploaded avatar is allowed to be.
+ *
+ * The type is decided from the bytes, never from the Content-Type the client
+ * sent or the name of the file: both are chosen by whoever is uploading, and a
+ * stored image is later served back to browsers.
+ */
+
+export const MAX_AVATAR_BYTES = 512 * 1024;
+
+export type AvatarType = "image/png" | "image/jpeg" | "image/webp";
+
+export interface Sniffed {
+ contentType: AvatarType;
+}
+
+const startsWith = (buf: Buffer, sig: number[], offset = 0): boolean =>
+ buf.length >= offset + sig.length && sig.every((b, i) => buf[offset + i] === b);
+
+/**
+ * Identifies an image from its leading bytes.
+ *
+ * SVG is deliberately absent. It is a document that can carry script and
+ * external references, and serving one back from our own origin would hand an
+ * uploader a foothold there; the editor produces a raster image anyway.
+ */
+export function sniffImage(buf: Buffer): Sniffed | null {
+ // \x89PNG\r\n\x1a\n
+ if (startsWith(buf, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
+ return { contentType: "image/png" };
+ }
+ // JPEG: FF D8 FF
+ if (startsWith(buf, [0xff, 0xd8, 0xff])) {
+ return { contentType: "image/jpeg" };
+ }
+ // RIFF....WEBP
+ if (startsWith(buf, [0x52, 0x49, 0x46, 0x46]) && startsWith(buf, [0x57, 0x45, 0x42, 0x50], 8)) {
+ return { contentType: "image/webp" };
+ }
+ return null;
+}
+
+export type AvatarRejection = "TOO_LARGE" | "EMPTY" | "UNSUPPORTED_TYPE";
+
+export interface AvatarCheck {
+ ok: boolean;
+ contentType?: AvatarType;
+ reason?: AvatarRejection;
+}
+
+/** Decides whether these bytes may be stored and later served back. */
+export function checkAvatar(buf: Buffer): AvatarCheck {
+ if (buf.length === 0) return { ok: false, reason: "EMPTY" };
+ // Checked before sniffing, so an enormous upload is refused on its size
+ // rather than on its contents.
+ if (buf.length > MAX_AVATAR_BYTES) return { ok: false, reason: "TOO_LARGE" };
+
+ const sniffed = sniffImage(buf);
+ if (!sniffed) return { ok: false, reason: "UNSUPPORTED_TYPE" };
+
+ return { ok: true, contentType: sniffed.contentType };
+}
diff --git a/services/auth/src/repository.ts b/services/auth/src/repository.ts
index 499f631..7e7cf80 100644
--- a/services/auth/src/repository.ts
+++ b/services/auth/src/repository.ts
@@ -1,3 +1,4 @@
+import crypto from "node:crypto";
import { query, queryOne } from "./db.js";
import { verifyPassword, hashPassword, encryptSecret, decryptSecret } from "./crypto.js";
import { verifyTotpCode } from "./totp.js";
@@ -716,6 +717,65 @@ export async function adminRemoveTotp(scope: AdminScope, mailboxId: string): Pro
return true;
}
+// ------------------------------------------------------------- avatars
+
+export interface StoredAvatar {
+ contentType: string;
+ bytes: Buffer;
+ etag: string;
+}
+
+/** Replaces a mailbox's profile photo. */
+export async function saveAvatar(
+ mailboxId: string,
+ contentType: string,
+ bytes: Buffer,
+): Promise {
+ // The digest is of the image itself, so an unchanged photo keeps its tag and
+ // the browsers that already hold it are not made to fetch it again.
+ const etag = crypto.createHash("sha256").update(bytes).digest("hex");
+ await query(
+ `INSERT INTO mailbox_avatars (mailbox_id, content_type, bytes, etag, updated_at)
+ VALUES ($1, $2, $3, $4, NOW())
+ ON CONFLICT (mailbox_id) DO UPDATE
+ SET content_type = EXCLUDED.content_type,
+ bytes = EXCLUDED.bytes,
+ etag = EXCLUDED.etag,
+ updated_at = NOW()`,
+ [mailboxId, contentType, bytes, etag],
+ );
+ return etag;
+}
+
+export async function loadAvatar(mailboxId: string): Promise {
+ const row = await queryOne<{ content_type: string; bytes: Buffer; etag: string }>(
+ `SELECT content_type, bytes, etag FROM mailbox_avatars WHERE mailbox_id = $1`,
+ [mailboxId],
+ );
+ if (!row) return null;
+ return { contentType: row.content_type, bytes: row.bytes, etag: row.etag };
+}
+
+/** Looks up an avatar by address, for showing a sender in the message list. */
+export async function loadAvatarByAddress(address: string): Promise {
+ const row = await queryOne<{ content_type: string; bytes: Buffer; etag: string }>(
+ `SELECT a.content_type, a.bytes, a.etag
+ FROM mailbox_avatars a JOIN mailboxes m ON m.id = a.mailbox_id
+ WHERE lower(m.email_address) = lower($1)`,
+ [address],
+ );
+ if (!row) return null;
+ return { contentType: row.content_type, bytes: row.bytes, etag: row.etag };
+}
+
+export async function deleteAvatar(mailboxId: string): Promise {
+ const rows = await query<{ mailbox_id: string }>(
+ `DELETE FROM mailbox_avatars WHERE mailbox_id = $1 RETURNING mailbox_id`,
+ [mailboxId],
+ );
+ return rows.length === 1;
+}
+
// ------------------------------------------------------------- audit log
/** Records an administrative action. PLAN.md section 14.3 requires every
diff --git a/services/auth/src/router.test.ts b/services/auth/src/router.test.ts
index e5eb972..b67dfba 100644
--- a/services/auth/src/router.test.ts
+++ b/services/auth/src/router.test.ts
@@ -927,6 +927,64 @@ describeDb("auth API against a real database", () => {
expect(still).toHaveLength(1);
});
+ it("stores a profile photo and serves it back with a cache tag", async () => {
+ const token = (await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD }))
+ .body.token as unknown as string;
+
+ const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]);
+ const put = await fetch(`${base}/api/v1/user/avatar`, {
+ method: "PUT",
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "image/png" },
+ body: png,
+ });
+ expect(put.status).toBe(200);
+
+ const got = await fetch(`${base}/api/v1/avatar?address=${encodeURIComponent(userEmail())}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ expect(got.status).toBe(200);
+ expect(got.headers.get("content-type")).toBe("image/png");
+ expect(Buffer.from(await got.arrayBuffer())).toEqual(png);
+
+ // A photo appears beside every message from that person; without the
+ // conditional answer it would be re-sent on every one of them.
+ const etag = got.headers.get("etag")!;
+ const again = await fetch(`${base}/api/v1/avatar?address=${encodeURIComponent(userEmail())}`, {
+ headers: { Authorization: `Bearer ${token}`, "If-None-Match": etag },
+ });
+ expect(again.status).toBe(304);
+ }, 60000);
+
+ // The stored type is decided from the bytes. A caller that labels an HTML
+ // document as a PNG must not get it stored and served from our own origin.
+ it("refuses an upload that is not really an image", async () => {
+ const token = (await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD }))
+ .body.token as unknown as string;
+
+ const res = await fetch(`${base}/api/v1/user/avatar`, {
+ method: "PUT",
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "image/png" },
+ body: Buffer.from(""),
+ });
+ expect(res.status).toBe(400);
+ }, 60000);
+
+ // An open avatar endpoint would answer "this address exists" to anyone
+ // who asked, for every address they cared to try.
+ it("does not serve photos to a caller with no session", async () => {
+ const res = await fetch(`${base}/api/v1/avatar?address=${encodeURIComponent(userEmail())}`);
+ expect(res.status).toBe(401);
+ });
+
+ it("answers 404 for an address with no photo", async () => {
+ const token = (await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD }))
+ .body.token as unknown as string;
+ const res = await fetch(`${base}/api/v1/avatar?address=nobody@nowhere.invalid`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ expect(res.status).toBe(404);
+ }, 60000);
+
it("answers 404 for a user that does not exist rather than leaking the difference", async () => {
const res = await get(`/api/v1/admin/mailboxes/44444444-4444-4444-4444-444444444444/aliases`, admin);
expect(res.status).toBe(404);
diff --git a/services/auth/src/router.ts b/services/auth/src/router.ts
index 1515da2..2c5f71b 100644
--- a/services/auth/src/router.ts
+++ b/services/auth/src/router.ts
@@ -12,6 +12,7 @@ import {
} from "./surfaceAccess.js";
import * as repo from "./repository.js";
import { parsePageParams, paged } from "./pagination.js";
+import { checkAvatar, MAX_AVATAR_BYTES } from "./imageValidation.js";
import { checkUser, checkServer, overallStatus } from "./diagnostics.js";
const CHALLENGE_TTL_SECONDS = 300;
@@ -142,6 +143,12 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr
if (url === "/api/v1/user/me" && method === "GET") return me(res, session!);
if (url === "/api/v1/user/locale" && method === "PUT") return updateLocale(req, res, session!);
+ if (url === "/api/v1/user/avatar" && method === "PUT") return userSaveAvatar(req, res, session!);
+ if (url === "/api/v1/user/avatar" && method === "DELETE") return userDeleteAvatar(req, res, session!);
+ if (url.split("?")[0] === "/api/v1/avatar" && method === "GET") {
+ const wanted = new URL(req.url || "/", "http://localhost").searchParams.get("address") || "";
+ return getAvatar(req, res, session, wanted);
+ }
if (url === "/api/v1/user/preferences" && method === "GET") return userGetPreferences(res, session!);
if (url === "/api/v1/user/preferences" && method === "POST") return userUpdatePreferences(req, res, session!);
if (url === "/api/v1/user/sieve" && method === "GET") return userGetSieve(res, session!);
@@ -229,6 +236,30 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr
}
}
+/**
+ * Reads a binary body, refusing anything over the cap as it arrives.
+ *
+ * Kept separate from readBody, which concatenates into a string: an image
+ * turned into a UTF-8 string and back is no longer the same bytes.
+ */
+function readBinaryBody(req: IncomingMessage, maxBytes: number): Promise {
+ return new Promise((resolve, reject) => {
+ const chunks: Buffer[] = [];
+ let size = 0;
+ req.on("data", (chunk: Buffer) => {
+ size += chunk.length;
+ if (size > maxBytes) {
+ reject(new Error("PAYLOAD_TOO_LARGE"));
+ req.destroy();
+ return;
+ }
+ chunks.push(chunk);
+ });
+ req.on("end", () => resolve(Buffer.concat(chunks)));
+ req.on("error", reject);
+ });
+}
+
async function parseJsonBody(req: IncomingMessage): Promise | null> {
const raw = await readBody(req);
try {
@@ -238,6 +269,95 @@ async function parseJsonBody(req: IncomingMessage): Promise {
+ let body: Buffer;
+ try {
+ body = await readBinaryBody(req, MAX_AVATAR_BYTES + 1);
+ } catch {
+ return sendJson(res, 413, { error: "TOO_LARGE", message: "The image is too large" });
+ }
+
+ const check = checkAvatar(body);
+ if (!check.ok) {
+ const status = check.reason === "TOO_LARGE" ? 413 : 400;
+ return sendJson(res, status, {
+ error: check.reason,
+ message:
+ check.reason === "TOO_LARGE"
+ ? "The image is too large"
+ : "The upload is not a PNG, JPEG or WebP image",
+ });
+ }
+
+ const etag = await repo.saveAvatar(session.sub, check.contentType!, body);
+ await repo.recordAudit(session.sub, clientIp(req), "user.avatar_set", "mailbox", session.sub, {
+ content_type: check.contentType,
+ bytes: body.length,
+ });
+ sendJson(res, 200, { ok: true, etag });
+}
+
+/**
+ * Serves a mailbox's photo, by address.
+ *
+ * Behind a session on purpose, and checked here rather than by the prefix
+ * guard, which only covers /user/ and /admin/. An open endpoint keyed by
+ * address answers "this mailbox exists" to anyone willing to ask, for every
+ * address they care to try.
+ */
+async function getAvatar(
+ req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload | null, address: string,
+): Promise {
+ if (!session) {
+ return sendJson(res, 401, { error: "UNAUTHORIZED", message: "A signed-in session is required" });
+ }
+
+ const avatar = address ? await repo.loadAvatarByAddress(address) : null;
+ if (!avatar) {
+ // 404 rather than a placeholder: the caller decides what to draw instead,
+ // and initials are drawn client-side already.
+ return sendJson(res, 404, { error: "NOT_FOUND", message: "No photo" });
+ }
+
+ // A photo appears beside every message from that person, so re-sending it
+ // each time is the difference between one request and a hundred.
+ if (req.headers["if-none-match"] === `"${avatar.etag}"`) {
+ res.statusCode = 304;
+ res.end();
+ return;
+ }
+
+ res.statusCode = 200;
+ res.setHeader("Content-Type", avatar.contentType);
+ res.setHeader("ETag", `"${avatar.etag}"`);
+ // Private: this is served to a signed-in reader, and a shared cache must not
+ // hand one person's photo to the next request that comes along.
+ res.setHeader("Cache-Control", "private, max-age=300");
+ // The bytes were sniffed on the way in, but a browser that ignores the type
+ // and sniffs for itself must not be allowed to find something else.
+ res.setHeader("X-Content-Type-Options", "nosniff");
+ res.end(avatar.bytes);
+}
+
+async function userDeleteAvatar(
+ req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload,
+): Promise {
+ await repo.deleteAvatar(session.sub);
+ await repo.recordAudit(session.sub, clientIp(req), "user.avatar_cleared", "mailbox", session.sub, {});
+ sendJson(res, 200, { ok: true });
+}
+
// ------------------------------------------------------------------- login
async function login(req: IncomingMessage, res: ServerResponse, surface: "user" | "admin"): Promise {
diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json
index 7e228fb..65c2285 100644
--- a/services/webmail/messages/en.json
+++ b/services/webmail/messages/en.json
@@ -428,5 +428,15 @@
"user": "User",
"domainAdmin": "Domain admin",
"superAdmin": "Super admin"
+ },
+ "avatar": {
+ "title": "Profile photo",
+ "explainer": "This photo appears inside LambdaMail. External services like Gmail and Outlook do not read it: for those, see BIMI on the domains page.",
+ "none": "No photo",
+ "choose": "Choose a photo",
+ "replace": "Replace photo",
+ "zoom": "Zoom",
+ "dragToPosition": "Drag to position",
+ "notAnImage": "That file could not be read as an image."
}
}
diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json
index 8f05d7f..92c65b4 100644
--- a/services/webmail/messages/es.json
+++ b/services/webmail/messages/es.json
@@ -428,5 +428,15 @@
"user": "Usuario",
"domainAdmin": "Admin del dominio",
"superAdmin": "Super admin"
+ },
+ "avatar": {
+ "title": "Foto de perfil",
+ "explainer": "Esta foto aparece dentro de LambdaMail. Servicios externos como Gmail y Outlook no la leen: para eso, vea BIMI en la pagina de dominios.",
+ "none": "Sin foto",
+ "choose": "Elegir foto",
+ "replace": "Cambiar foto",
+ "zoom": "Zoom",
+ "dragToPosition": "Arrastre para posicionar",
+ "notAnImage": "No se pudo leer ese archivo como imagen."
}
}
diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json
index 7bdcf5b..eee2cc5 100644
--- a/services/webmail/messages/pt-BR.json
+++ b/services/webmail/messages/pt-BR.json
@@ -428,5 +428,15 @@
"user": "Utilizador",
"domainAdmin": "Admin do dominio",
"superAdmin": "Super admin"
+ },
+ "avatar": {
+ "title": "Foto de perfil",
+ "explainer": "Esta foto aparece dentro do LambdaMail. Servicos externos como Gmail e Outlook nao a leem: para eles, veja BIMI na pagina de dominios.",
+ "none": "Sem foto",
+ "choose": "Escolher foto",
+ "replace": "Trocar foto",
+ "zoom": "Zoom",
+ "dragToPosition": "Arraste para posicionar",
+ "notAnImage": "Nao foi possivel ler esse ficheiro como imagem."
}
}
diff --git a/services/webmail/src/app/(user)/user/layout.tsx b/services/webmail/src/app/(user)/user/layout.tsx
index 8a08a74..87e8cad 100644
--- a/services/webmail/src/app/(user)/user/layout.tsx
+++ b/services/webmail/src/app/(user)/user/layout.tsx
@@ -27,7 +27,7 @@ import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
import { useAccount, isAdminRole } from "../../../lib/useAccount";
import { useFolders } from "../../../lib/useFolders";
import { folderBadge, customFolders } from "../../../lib/mailCounts";
-import { initialsFor } from "../../../lib/initials";
+import { Avatar } from "../../../components/Avatar";
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
const t = useTranslations();
@@ -279,9 +279,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
onClick={() => setMobileOpen(false)}
className="flex items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 shadow-edge transition-colors hover:bg-dark-card"
>
-
- {initialsFor(account?.email)}
-
+
-
- {initials(msg.from_display_name, msg.sender_address)}
-
+ }
+ />
@@ -652,9 +653,12 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
{selected.from}
diff --git a/services/webmail/src/app/(user)/user/settings/page.tsx b/services/webmail/src/app/(user)/user/settings/page.tsx
index 1999441..a7e2085 100644
--- a/services/webmail/src/app/(user)/user/settings/page.tsx
+++ b/services/webmail/src/app/(user)/user/settings/page.tsx
@@ -15,6 +15,7 @@ import {
Plus,
Lock,
Languages,
+ UserRound,
} from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Badge } from "../../../../components/ui/Badge";
@@ -24,6 +25,8 @@ import { LanguageSwitcher } from "../../../../i18n/LanguageSwitcher";
import { signatureToHtml, sanitizeSignature } from "../../../../lib/signature";
import { buildSieve, parseSieve, type Rule, type RuleField, type RuleCondition, type RuleAction } from "../../../../lib/rules";
import { useFolders } from "../../../../lib/useFolders";
+import { AvatarEditor } from "../../../../components/AvatarEditor";
+import { useAccount } from "../../../../lib/useAccount";
import { moveTargets } from "../../../../lib/mailCounts";
interface SieveRule {
@@ -51,11 +54,20 @@ interface WebSession {
current: boolean;
}
-type SettingsTab = "security" | "filters" | "vacation" | "signature" | "language";
+type SettingsTab = "profile" | "security" | "filters" | "vacation" | "signature" | "language";
export default function UserSettingsPage() {
const t = useTranslations();
- const [activeTab, setActiveTab] = useState("security");
+ const [activeTab, setActiveTab] = useState("profile");
+ // Bumped after a save so the browser refetches instead of showing the photo
+ // it already holds under the same URL.
+ const [avatarVersion, setAvatarVersion] = useState(0);
+ const account = useAccount("user");
+ // Null until the account loads, so the editor is not told "no photo" before
+ // it is known whether there is one.
+ const avatarSrc = account?.email
+ ? `/api/v1/avatar?address=${encodeURIComponent(account.email)}&v=${avatarVersion}`
+ : null;
const [mfaEnabled, setMfaEnabled] = useState(false);
const [qrSecret, setQrSecret] = useState(null);
@@ -285,6 +297,7 @@ export default function UserSettingsPage() {
separated by everything else. The same panels now sit behind a tab strip,
in the order the redesign groups them. */
const tabs = [
+ { id: "profile" as const, label: t("avatar.title"), icon: UserRound },
{ id: "security" as const, label: t("settings.security"), icon: Shield },
{ id: "filters" as const, label: t("settings.sieveTitle"), icon: Filter },
{ id: "vacation" as const, label: t("settings.vacationTitle"), icon: Palmtree },
@@ -336,6 +349,21 @@ export default function UserSettingsPage() {
)}
+ {activeTab === "profile" && (
+
+
{t("avatar.title")}
+
{t("avatar.explainer")}
+ {
+ // The query string changes so the browser fetches the new
+ // photo instead of the one it already has under this URL.
+ setAvatarVersion((v) => v + 1);
+ }}
+ />
+
+ )}
+
{activeTab === "security" && (
{/* 2FA TOTP SECTION */}
diff --git a/services/webmail/src/components/Avatar.tsx b/services/webmail/src/components/Avatar.tsx
new file mode 100644
index 0000000..f4b4590
--- /dev/null
+++ b/services/webmail/src/components/Avatar.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import React, { useState } from "react";
+import { initialsFor } from "../lib/initials";
+
+interface AvatarProps {
+ address: string | null | undefined;
+ /** Rendered size in pixels; the square the circle is cut from. */
+ size: number;
+ className?: string;
+ /**
+ * What to show while there is no photo. Passed in where the caller already
+ * has a better answer than the address - a display name, usually - so the
+ * fallback does not get worse than what it replaced.
+ */
+ initials?: string;
+}
+
+/**
+ * Someone's photo, falling back to their initials.
+ *
+ * The endpoint answers 404 when there is no photo, which is most people most
+ * of the time, so the initials are what is rendered first and the image only
+ * replaces them once it has actually loaded. Doing it the other way round
+ * shows an empty circle on every row until each request comes back.
+ */
+export function Avatar({ address, size, className = "", initials }: AvatarProps) {
+ const [loaded, setLoaded] = useState(false);
+ const [failed, setFailed] = useState(false);
+
+ const src = address ? `/api/v1/avatar?address=${encodeURIComponent(address)}` : null;
+
+ return (
+
+ {!loaded && {initials ?? initialsFor(address)}}
+ {src && !failed && (
+ // eslint-disable-next-line @next/next/no-img-element
+ setLoaded(true)}
+ onError={() => setFailed(true)}
+ className={`absolute inset-0 h-full w-full object-cover ${loaded ? "" : "opacity-0"}`}
+ />
+ )}
+
+ );
+}
diff --git a/services/webmail/src/components/AvatarEditor.tsx b/services/webmail/src/components/AvatarEditor.tsx
new file mode 100644
index 0000000..84cb421
--- /dev/null
+++ b/services/webmail/src/components/AvatarEditor.tsx
@@ -0,0 +1,254 @@
+"use client";
+
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import { Upload, ZoomIn, ZoomOut, Trash2 } from "lucide-react";
+import { Button } from "./ui/Button";
+import { useTranslations } from "../i18n/provider";
+import {
+ clampTransform,
+ initialTransform,
+ scaleRange,
+ sourceRect,
+ type Size,
+ type Transform,
+} from "../lib/avatarCrop";
+
+/** The frame is square; the mask over it is what makes the photo a circle. */
+const VIEWPORT = 288;
+/** What gets stored. Larger than any place it is shown, so it stays sharp. */
+const EXPORT_SIZE = 512;
+
+interface AvatarEditorProps {
+ /** Current photo, if there is one. */
+ src: string | null;
+ onSaved: () => void;
+}
+
+export function AvatarEditor({ src, onSaved }: AvatarEditorProps) {
+ const t = useTranslations();
+
+ const [image, setImage] = useState(null);
+ const [natural, setNatural] = useState({ width: 0, height: 0 });
+ const [transform, setTransform] = useState({ scale: 1, offsetX: 0, offsetY: 0 });
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ const canvasRef = useRef(null);
+ const fileRef = useRef(null);
+ const dragRef = useRef<{ x: number; y: number; from: Transform } | null>(null);
+
+ /** Draws the current transform into the preview. */
+ const paint = useCallback(() => {
+ const canvas = canvasRef.current;
+ if (!canvas || !image) return;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ ctx.clearRect(0, 0, VIEWPORT, VIEWPORT);
+ const r = sourceRect(natural, VIEWPORT, transform);
+ ctx.drawImage(image, r.x, r.y, r.width, r.height, 0, 0, VIEWPORT, VIEWPORT);
+ }, [image, natural, transform]);
+
+ useEffect(() => {
+ paint();
+ }, [paint]);
+
+ const openFile = (file: File) => {
+ setError(null);
+ const url = URL.createObjectURL(file);
+ const img = new Image();
+ img.onload = () => {
+ const size = { width: img.naturalWidth, height: img.naturalHeight };
+ setImage(img);
+ setNatural(size);
+ setTransform(initialTransform(size, VIEWPORT));
+ // The bitmap is decoded and held; the object URL has done its job and
+ // would otherwise keep the file alive for the life of the page.
+ URL.revokeObjectURL(url);
+ };
+ img.onerror = () => {
+ URL.revokeObjectURL(url);
+ setError(t("avatar.notAnImage"));
+ };
+ img.src = url;
+ };
+
+ const onPointerDown = (e: React.PointerEvent) => {
+ if (!image) return;
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
+ dragRef.current = { x: e.clientX, y: e.clientY, from: transform };
+ };
+
+ const onPointerMove = (e: React.PointerEvent) => {
+ const drag = dragRef.current;
+ if (!drag || !image) return;
+ setTransform(
+ clampTransform(natural, VIEWPORT, {
+ scale: drag.from.scale,
+ offsetX: drag.from.offsetX + (e.clientX - drag.x),
+ offsetY: drag.from.offsetY + (e.clientY - drag.y),
+ }),
+ );
+ };
+
+ const endDrag = () => {
+ dragRef.current = null;
+ };
+
+ const zoomTo = (scale: number) => {
+ if (!image) return;
+ setTransform((prev) => clampTransform(natural, VIEWPORT, { ...prev, scale }));
+ };
+
+ const save = async () => {
+ if (!image) return;
+ setBusy(true);
+ setError(null);
+ try {
+ // Exported from the source pixels rather than from the preview, so the
+ // stored photo is as sharp as the original allows instead of being
+ // limited to the size of the editor on screen.
+ const out = document.createElement("canvas");
+ out.width = EXPORT_SIZE;
+ out.height = EXPORT_SIZE;
+ const ctx = out.getContext("2d");
+ if (!ctx) throw new Error();
+ const r = sourceRect(natural, VIEWPORT, transform);
+ ctx.drawImage(image, r.x, r.y, r.width, r.height, 0, 0, EXPORT_SIZE, EXPORT_SIZE);
+
+ const blob = await new Promise((resolve) =>
+ // The frame is always fully covered, so there is no transparency to
+ // preserve and JPEG is a fraction of the size.
+ out.toBlob((b) => resolve(b), "image/jpeg", 0.9),
+ );
+ if (!blob) throw new Error();
+
+ const res = await fetch("/api/v1/user/avatar", {
+ method: "PUT",
+ headers: { "Content-Type": "image/jpeg" },
+ body: blob,
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.message);
+ }
+ setImage(null);
+ onSaved();
+ } catch (err) {
+ setError(err instanceof Error && err.message ? err.message : t("errors.serverError"));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const remove = async () => {
+ setBusy(true);
+ setError(null);
+ try {
+ const res = await fetch("/api/v1/user/avatar", { method: "DELETE" });
+ if (!res.ok) throw new Error();
+ onSaved();
+ } catch {
+ setError(t("errors.serverError"));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const range = scaleRange(natural, VIEWPORT);
+
+ return (
+
+ {!image ? (
+
+ {/* What is stored today, shown as it will appear elsewhere. */}
+
+ {src ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+ {t("avatar.none")}
+ )}
+
+
+
+ )}
+
+ {
+ const file = e.target.files?.[0];
+ if (file) openFile(file);
+ // Cleared so choosing the same file twice in a row still fires.
+ e.target.value = "";
+ }}
+ />
+
+ );
+}
diff --git a/services/webmail/src/lib/avatarCrop.test.ts b/services/webmail/src/lib/avatarCrop.test.ts
new file mode 100644
index 0000000..f4b9edd
--- /dev/null
+++ b/services/webmail/src/lib/avatarCrop.test.ts
@@ -0,0 +1,109 @@
+import { describe, it, expect } from "vitest";
+import {
+ minimumScale,
+ scaleRange,
+ clampTransform,
+ initialTransform,
+ sourceRect,
+} from "./avatarCrop";
+
+const VIEWPORT = 320;
+
+describe("minimumScale", () => {
+ // Cover, not contain. Scaling a wide photo to fit its width leaves the top
+ // and bottom of the circle empty.
+ it("covers the frame on the short side of a wide image", () => {
+ expect(minimumScale({ width: 800, height: 400 }, VIEWPORT)).toBeCloseTo(320 / 400);
+ });
+
+ it("covers the frame on the short side of a tall image", () => {
+ expect(minimumScale({ width: 400, height: 800 }, VIEWPORT)).toBeCloseTo(320 / 400);
+ });
+
+ it("scales a small image up rather than leaving it small", () => {
+ expect(minimumScale({ width: 100, height: 100 }, VIEWPORT)).toBeCloseTo(3.2);
+ });
+
+ it.each([
+ [{ width: 0, height: 100 }],
+ [{ width: 100, height: 0 }],
+ [{ width: Number.NaN, height: 100 }],
+ ])("survives a degenerate image %o", (image) => {
+ expect(Number.isFinite(minimumScale(image, VIEWPORT))).toBe(true);
+ });
+});
+
+describe("clampTransform", () => {
+ const image = { width: 800, height: 600 };
+
+ it("refuses to zoom below covering the frame", () => {
+ const t = clampTransform(image, VIEWPORT, { scale: 0.01, offsetX: 0, offsetY: 0 });
+ expect(t.scale).toBeCloseTo(minimumScale(image, VIEWPORT));
+ });
+
+ // Without this, dragging far enough pulls the edge of the photo into the
+ // circle and the export carries a transparent wedge.
+ it("stops the image at its own edge", () => {
+ const scale = minimumScale(image, VIEWPORT);
+ const t = clampTransform(image, VIEWPORT, { scale, offsetX: 99999, offsetY: 99999 });
+
+ const overhangX = (image.width * scale - VIEWPORT) / 2;
+ expect(t.offsetX).toBeCloseTo(overhangX);
+ // At the covering scale the short side has no slack at all, so it cannot
+ // move: any movement there would expose a gap.
+ expect(t.offsetY).toBeCloseTo(0);
+ });
+
+ it("allows movement on both axes once zoomed in", () => {
+ const scale = minimumScale(image, VIEWPORT) * 2;
+ const t = clampTransform(image, VIEWPORT, { scale, offsetX: 99999, offsetY: 99999 });
+ expect(t.offsetX).toBeGreaterThan(0);
+ expect(t.offsetY).toBeGreaterThan(0);
+ });
+
+ it("caps the zoom so the photo cannot be magnified into mush", () => {
+ const { max } = scaleRange(image, VIEWPORT);
+ expect(clampTransform(image, VIEWPORT, { scale: 9999, offsetX: 0, offsetY: 0 }).scale).toBeCloseTo(max);
+ });
+});
+
+describe("sourceRect", () => {
+ const image = { width: 800, height: 600 };
+
+ it("is always square, because the frame is", () => {
+ const r = sourceRect(image, VIEWPORT, { scale: 1.2, offsetX: 30, offsetY: -20 });
+ expect(r.width).toBeCloseTo(r.height);
+ });
+
+ // At the covering scale the whole short side is used: nothing of the height
+ // is cropped away, and the crop is the full 600px of it.
+ it("takes the whole short side at the covering scale", () => {
+ const r = sourceRect(image, VIEWPORT, initialTransform(image, VIEWPORT));
+ expect(r.height).toBeCloseTo(600);
+ expect(r.y).toBeCloseTo(0);
+ // Centred horizontally: (800 - 600) / 2.
+ expect(r.x).toBeCloseTo(100);
+ });
+
+ it("never reads outside the image", () => {
+ for (const wanted of [
+ { scale: 0.001, offsetX: -9999, offsetY: -9999 },
+ { scale: 9999, offsetX: 9999, offsetY: 9999 },
+ { scale: 1, offsetX: 0, offsetY: 9999 },
+ ]) {
+ const r = sourceRect(image, VIEWPORT, wanted);
+ expect(r.x).toBeGreaterThanOrEqual(-0.001);
+ expect(r.y).toBeGreaterThanOrEqual(-0.001);
+ expect(r.x + r.width).toBeLessThanOrEqual(image.width + 0.001);
+ expect(r.y + r.height).toBeLessThanOrEqual(image.height + 0.001);
+ }
+ });
+
+ // Dragging the image right shows what is further left in the source.
+ it("moves the crop opposite to the drag", () => {
+ const scale = minimumScale(image, VIEWPORT) * 2;
+ const left = sourceRect(image, VIEWPORT, { scale, offsetX: 40, offsetY: 0 });
+ const centre = sourceRect(image, VIEWPORT, { scale, offsetX: 0, offsetY: 0 });
+ expect(left.x).toBeLessThan(centre.x);
+ });
+});
diff --git a/services/webmail/src/lib/avatarCrop.ts b/services/webmail/src/lib/avatarCrop.ts
new file mode 100644
index 0000000..b5268ef
--- /dev/null
+++ b/services/webmail/src/lib/avatarCrop.ts
@@ -0,0 +1,109 @@
+/**
+ * The geometry behind the circular photo editor.
+ *
+ * The editor shows a fixed square viewport with a circular mask; the image sits
+ * behind it, scaled and dragged. Everything here works in viewport pixels and
+ * is deliberately free of DOM and canvas, so the awkward parts - the smallest
+ * scale that still covers the circle, and clamping so no gap can be dragged
+ * into view - are testable on their own.
+ */
+
+export interface Size {
+ width: number;
+ height: number;
+}
+
+/** Where the image sits behind the viewport, and how big it is drawn. */
+export interface Transform {
+ /** Multiplier applied to the image's natural size. */
+ scale: number;
+ /** Image centre, relative to the viewport centre, in viewport pixels. */
+ offsetX: number;
+ offsetY: number;
+}
+
+/**
+ * The smallest scale at which the image still covers the whole viewport.
+ *
+ * Below this a corner of the frame is empty, and the exported photo would carry
+ * a transparent wedge - so this is the floor for the zoom control, not merely a
+ * suggestion.
+ */
+export function minimumScale(image: Size, viewport: number): number {
+ // Finite, not merely positive: NaN fails every comparison, so `<= 0` lets it
+ // through and it then propagates into the transform and blanks the editor.
+ // A NaN dimension is what an image that failed to decode reports.
+ if (
+ !Number.isFinite(image.width) || !Number.isFinite(image.height) || !Number.isFinite(viewport) ||
+ image.width <= 0 || image.height <= 0 || viewport <= 0
+ ) {
+ return 1;
+ }
+ // Cover, not contain: the larger of the two ratios.
+ return Math.max(viewport / image.width, viewport / image.height);
+}
+
+/** The zoom control's range: from covering the frame to four times that. */
+export function scaleRange(image: Size, viewport: number): { min: number; max: number } {
+ const min = minimumScale(image, viewport);
+ return { min, max: min * 4 };
+}
+
+/**
+ * Holds the image over the viewport, whatever was asked for.
+ *
+ * A drag or a zoom out can otherwise pull an edge inside the frame. Clamping
+ * the offset to the overhang keeps the frame covered without fighting the
+ * gesture: the image stops at its edge instead of springing back.
+ */
+export function clampTransform(image: Size, viewport: number, wanted: Transform): Transform {
+ const { min, max } = scaleRange(image, viewport);
+ const scale = Math.min(Math.max(wanted.scale, min), max);
+
+ // Half of what sticks out past the viewport on each axis.
+ const overhangX = Math.max(0, (image.width * scale - viewport) / 2);
+ const overhangY = Math.max(0, (image.height * scale - viewport) / 2);
+
+ return {
+ scale,
+ offsetX: Math.min(Math.max(wanted.offsetX, -overhangX), overhangX),
+ offsetY: Math.min(Math.max(wanted.offsetY, -overhangY), overhangY),
+ };
+}
+
+/** The transform that centres the image at the smallest covering scale. */
+export function initialTransform(image: Size, viewport: number): Transform {
+ return { scale: minimumScale(image, viewport), offsetX: 0, offsetY: 0 };
+}
+
+/**
+ * The rectangle of the *source* image that the viewport is showing.
+ *
+ * This is what the export draws. Working back to source pixels rather than
+ * scaling the preview means the saved photo is as sharp as the original
+ * allows, instead of being limited to the size of the editor on screen.
+ */
+export interface SourceRect {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+export function sourceRect(image: Size, viewport: number, transform: Transform): SourceRect {
+ const t = clampTransform(image, viewport, transform);
+ // One viewport pixel is this many source pixels.
+ const perPixel = 1 / t.scale;
+ const size = viewport * perPixel;
+
+ // The viewport centre, expressed in source coordinates.
+ const centreX = image.width / 2 - t.offsetX * perPixel;
+ const centreY = image.height / 2 - t.offsetY * perPixel;
+
+ return {
+ x: centreX - size / 2,
+ y: centreY - size / 2,
+ width: size,
+ height: size,
+ };
+}