revert: foto de perfil e BIMI, para restaurar o servico

O site ficou a responder 502 em todos os hostnames depois do deploy destas
duas funcionalidades. A reversao vem primeiro; o diagnostico vem a seguir, com
o servico de pe.

As migracoes 0012 e 0013 ficam. Sao aditivas, nada no codigo revertido as usa,
e apaga-las deixaria a base de dados numa versao sem ficheiro correspondente,
o que parte o proprio migrate no arranque seguinte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-09-07 08:45:16 -03:00
1 parent 3e06162fa0
commit e30d000e43
26 files changed
+21 -1899

No files matched your search

+1 -28
View File
@@ -24,7 +24,6 @@ paid tiers and no artificial limits on domains or mailboxes.
| **Delivery** | Durable outbound queue in Postgres with the retry ladder and RFC 3464 bounces; smarthost fallback for hosts with port 25 blocked | | **Delivery** | Durable outbound queue in Postgres with the retry ladder and RFC 3464 bounces; smarthost fallback for hosts with port 25 blocked |
| **DNS** | Cloudflare reconciliation of the expected records for every active domain, idempotent, with per-record verification against public resolvers | | **DNS** | Cloudflare reconciliation of the expected records for every active domain, idempotent, with per-record verification against public resolvers |
| **Filing** | Sieve rules built in the UI, custom folders, and an RFC 5230 vacation responder that replies in thread and once per sender per week | | **Filing** | Sieve rules built in the UI, custom folders, and an RFC 5230 vacation responder that replies in thread and once per sender per week |
| **Identity** | Per-user profile photos with a circular crop editor, shown inside the webmail; BIMI logo hosting and validation for the mark external clients render |
| **Web** | Webmail at `/user/*` and an admin console at `/admin/*`, isolated by session audience, with TOTP two-factor, recovery codes, app passwords, and English, Portuguese and Spanish | | **Web** | Webmail at `/user/*` and an admin console at `/admin/*`, isolated by session audience, with TOTP two-factor, recovery codes, app passwords, and English, Portuguese and Spanish |
| **Console** | Server-side paging and filtering, user editing (role, quota, language, password, second factor), per-user aliases, and a diagnostics page at `/admin/tests` | | **Console** | Server-side paging and filtering, user editing (role, quota, language, password, second factor), per-user aliases, and a diagnostics page at `/admin/tests` |
@@ -184,32 +183,6 @@ reads as a certificate problem and is usually a permissions problem.
`/admin/security` names the issuer and says outright when the certificate is `/admin/security` names the issuer and says outright when the certificate is
self-signed, which is the fastest way to tell the two apart. self-signed, which is the fastest way to tell the two apart.
### Profile photos and BIMI
These are two different things, and the difference is the whole story.
A **profile photo** is per user and is shown inside LambdaMail. No external
mail client reads it, and no protocol exists that would let one: the avatar
Gmail or Outlook shows beside a message comes from the *recipient's* own
contacts, or from the sender's Google or Microsoft account. A self-hosted
server cannot put an image there.
**BIMI** is what external clients do read, and it is per domain — one mark for
everyone who sends from it. Publishing one needs:
- DMARC at `p=quarantine` or `p=reject`, which this server already sets;
- a logo in SVG Tiny Portable/Secure — square viewBox, one `<title>`, nothing
active or external. `/admin/domains` validates an upload against every rule
at once and names the ones it fails, because a receiver that rejects a mark
simply shows nothing;
- a **Verified Mark Certificate** for Gmail and Outlook to render it. It is
issued against a registered trademark, costs on the order of US$1000 a year,
and without it the record is still valid — some clients use it, those two do
not.
The logo is served from `https://<mail host>/.well-known/bimi/default.svg` and
the record is published with the rest of the zone.
### DANE ### DANE
Off by default, and worth understanding before turning on: a TLSA record that Off by default, and worth understanding before turning on: a TLSA record that
@@ -255,7 +228,7 @@ Stated plainly, because a mail server that half works loses mail:
- **Read-only console panels.** The guided domain onboarding checklist reports - **Read-only console panels.** The guided domain onboarding checklist reports
on state rather than driving it. on state rather than driving it.
- **Not implemented.** JMAP, CalDAV/CardDAV, multi-node. Backups are - **Not implemented.** JMAP, BIMI, CalDAV/CardDAV, multi-node. Backups are
documented but not automated. documented but not automated.
- **DANE** is off by default. See [DANE](#dane) for turning it on; under - **DANE** is off by default. See [DANE](#dane) for turning it on; under
`TLS_MODE=traefik` it stays off deliberately, because the proxy's key changes `TLS_MODE=traefik` it stays off deliberately, because the proxy's key changes
-58
View File
@@ -1,58 +0,0 @@
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("<!doctype html><script>alert(1)</script>"))).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('<svg xmlns="http://www.w3.org/2000/svg"><script/></svg>'))).toBeNull();
expect(sniffImage(Buffer.from('<?xml version="1.0"?><svg/>'))).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" });
});
});
-62
View File
@@ -1,62 +0,0 @@
/**
* 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 };
}
-60
View File
@@ -1,4 +1,3 @@
import crypto from "node:crypto";
import { query, queryOne } from "./db.js"; import { query, queryOne } from "./db.js";
import { verifyPassword, hashPassword, encryptSecret, decryptSecret } from "./crypto.js"; import { verifyPassword, hashPassword, encryptSecret, decryptSecret } from "./crypto.js";
import { verifyTotpCode } from "./totp.js"; import { verifyTotpCode } from "./totp.js";
@@ -717,65 +716,6 @@ export async function adminRemoveTotp(scope: AdminScope, mailboxId: string): Pro
return true; 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<string> {
// 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<StoredAvatar | null> {
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<StoredAvatar | null> {
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<boolean> {
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 // ------------------------------------------------------------- audit log
/** Records an administrative action. PLAN.md section 14.3 requires every /** Records an administrative action. PLAN.md section 14.3 requires every
-58
View File
@@ -927,64 +927,6 @@ describeDb("auth API against a real database", () => {
expect(still).toHaveLength(1); 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("<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>"),
});
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 () => { 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); const res = await get(`/api/v1/admin/mailboxes/44444444-4444-4444-4444-444444444444/aliases`, admin);
expect(res.status).toBe(404); expect(res.status).toBe(404);
-120
View File
@@ -12,7 +12,6 @@ import {
} from "./surfaceAccess.js"; } from "./surfaceAccess.js";
import * as repo from "./repository.js"; import * as repo from "./repository.js";
import { parsePageParams, paged } from "./pagination.js"; import { parsePageParams, paged } from "./pagination.js";
import { checkAvatar, MAX_AVATAR_BYTES } from "./imageValidation.js";
import { checkUser, checkServer, overallStatus } from "./diagnostics.js"; import { checkUser, checkServer, overallStatus } from "./diagnostics.js";
const CHALLENGE_TTL_SECONDS = 300; const CHALLENGE_TTL_SECONDS = 300;
@@ -143,12 +142,6 @@ 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/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/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 === "GET") return userGetPreferences(res, session!);
if (url === "/api/v1/user/preferences" && method === "POST") return userUpdatePreferences(req, 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!); if (url === "/api/v1/user/sieve" && method === "GET") return userGetSieve(res, session!);
@@ -236,30 +229,6 @@ 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<Buffer> {
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<Record<string, unknown> | null> { async function parseJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | null> {
const raw = await readBody(req); const raw = await readBody(req);
try { try {
@@ -269,95 +238,6 @@ async function parseJsonBody(req: IncomingMessage): Promise<Record<string, unkno
} }
} }
// ------------------------------------------------------------------ avatars
/**
* Stores the photo the editor produced.
*
* The body is the image itself rather than a multipart form or a base64 field:
* the editor already has the exact bytes from a canvas, and the other two
* shapes only make them bigger on the way here.
*/
async function userSaveAvatar(
req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload,
): Promise<void> {
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<void> {
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<void> {
await repo.deleteAvatar(session.sub);
await repo.recordAudit(session.sub, clientIp(req), "user.avatar_cleared", "mailbox", session.sub, {});
sendJson(res, 200, { ok: true });
}
// ------------------------------------------------------------------- login // ------------------------------------------------------------------- login
async function login(req: IncomingMessage, res: ServerResponse, surface: "user" | "admin"): Promise<void> { async function login(req: IncomingMessage, res: ServerResponse, surface: "user" | "admin"): Promise<void> {
@@ -1,30 +0,0 @@
package main
import (
"context"
"lambdamail/protocols/internal/infrastructure/postgres"
httppresentation "lambdamail/protocols/internal/presentation/http"
)
// bimiStore adapts the repository to the shape the HTTP layer asks for, so
// that layer does not import the database package.
type bimiStore struct {
repo *postgres.BimiRepository
}
func (s bimiStore) Save(ctx context.Context, domain string, svg []byte, vmcURL string) (string, error) {
return s.repo.Save(ctx, domain, svg, vmcURL)
}
func (s bimiStore) Load(ctx context.Context, domain string) (*httppresentation.BimiLogo, error) {
logo, err := s.repo.Load(ctx, domain)
if err != nil || logo == nil {
return nil, err
}
return &httppresentation.BimiLogo{SVG: logo.SVG, ETag: logo.ETag, VmcURL: logo.VmcURL}, nil
}
func (s bimiStore) Delete(ctx context.Context, domain string) error {
return s.repo.Delete(ctx, domain)
}
@@ -230,15 +230,11 @@ func run(cfg config) {
// list stops disagreeing with the page next to it. // list stops disagreeing with the page next to it.
router.SetDnsStatusWriter(postgres.NewDomainRepository(pool)) router.SetDnsStatusWriter(postgres.NewDomainRepository(pool))
router.SetAdminDnsAPI( router.SetAdminDnsAPI(
&dnsSpecSource{cfg: cfg, dkim: dkimRepo, bimi: postgres.NewBimiRepository(pool)}, &dnsSpecSource{cfg: cfg, dkim: dkimRepo},
netdns.NewPublicVerifier(), netdns.NewPublicVerifier(),
cfg.JwtSecret, cfg.JwtSecret,
) )
} }
// The BIMI mark: served to receivers over the well-known URL, managed from
// the domains panel.
router.SetBimiAPI(bimiStore{postgres.NewBimiRepository(pool)}, cfg.JwtSecret, cfg.domain())
// The TLS panel reads the live watcher instead of the constants the admin // The TLS panel reads the live watcher instead of the constants the admin
// service used to return, which reported a healthy certificate whatever // service used to return, which reported a healthy certificate whatever
// the process actually held. // the process actually held.
@@ -702,7 +698,6 @@ func runHealthcheck() {
type dnsSpecSource struct { type dnsSpecSource struct {
cfg config cfg config
dkim *postgres.DkimRepository dkim *postgres.DkimRepository
bimi *postgres.BimiRepository
} }
func (s *dnsSpecSource) ExpectedRecords(ctx context.Context, domain string) ([]entity.DnsRecord, error) { func (s *dnsSpecSource) ExpectedRecords(ctx context.Context, domain string) ([]entity.DnsRecord, error) {
@@ -715,15 +710,6 @@ func (s *dnsSpecSource) ExpectedRecords(ctx context.Context, domain string) ([]e
RelaySpfInclude: relayConfig(s.cfg).SpfInclude(), RelaySpfInclude: relayConfig(s.cfg).SpfInclude(),
} }
// The BIMI record is published only when a mark is actually stored, so
// the panel never advertises a logo that would 404.
if s.bimi != nil {
if logo, err := s.bimi.Load(ctx, domain); err == nil && logo != nil {
spec.BimiLogoURL = httppresentation.BimiLogoURL(s.cfg.PrimaryMailHost)
spec.BimiVmcURL = logo.VmcURL
}
}
// Read rather than provisioned: verifying must not create keys as a side // Read rather than provisioned: verifying must not create keys as a side
// effect of somebody opening a screen. // effect of somebody opening a screen.
if s.dkim != nil { if s.dkim != nil {
@@ -1,153 +0,0 @@
// Package bimi validates the logo a domain publishes for BIMI.
//
// BIMI is the only mechanism by which an external mail client shows an image
// for a sender it has never met. It is per domain, not per mailbox, and the
// receiving side is strict: a logo that is not SVG Tiny Portable/Secure is
// ignored without explanation, so the checks that matter are made here rather
// than discovered from a mailbox that quietly shows nothing.
package bimi
import (
"encoding/xml"
"fmt"
"regexp"
"strings"
)
// MaxLogoBytes is the ceiling the BIMI specification sets. Receivers fetch
// this over the network for every sender they evaluate.
const MaxLogoBytes = 32 * 1024
// Violation is one reason a logo would be rejected.
type Violation struct {
Rule string
Detail string
}
func (v Violation) String() string { return fmt.Sprintf("%s: %s", v.Rule, v.Detail) }
// Elements that must not appear. Script and event handlers make the document
// active; external references make it fetch; raster images defeat the point of
// a vector mark and are how a tracking pixel would arrive.
var forbiddenElements = []string{"script", "a", "image", "foreignObject", "use", "animate", "animateTransform", "animateMotion", "set"}
var (
svgOpenRe = regexp.MustCompile(`(?is)<svg\b[^>]*>`)
attrRe = regexp.MustCompile(`(?is)([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*"([^"]*)"|([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*'([^']*)'`)
onEventRe = regexp.MustCompile(`(?is)\son[a-z]+\s*=`)
externalRefRe = regexp.MustCompile(`(?is)(href|xlink:href|src)\s*=\s*["']\s*(https?:|//|data:)`)
titleElementRe = regexp.MustCompile(`(?is)<title\b[^>]*>(.*?)</title>`)
)
// Validate reports every reason the logo would not be accepted.
//
// All of them, not the first: an operator fixing one rule at a time against a
// receiver that simply shows nothing is the worst way to learn these.
func Validate(payload []byte) []Violation {
var out []Violation
add := func(rule, detail string) { out = append(out, Violation{Rule: rule, Detail: detail}) }
if len(payload) == 0 {
return []Violation{{Rule: "empty", Detail: "the file is empty"}}
}
if len(payload) > MaxLogoBytes {
add("size", fmt.Sprintf("%d bytes; the limit is %d", len(payload), MaxLogoBytes))
}
// Parseable at all: a receiver will not repair a broken document.
if err := xml.Unmarshal(payload, new(struct {
XMLName xml.Name
})); err != nil {
return append(out, Violation{Rule: "xml", Detail: "the file is not well-formed XML"})
}
text := string(payload)
open := svgOpenRe.FindString(text)
if open == "" {
return append(out, Violation{Rule: "svg", Detail: "no <svg> root element"})
}
attrs := map[string]string{}
for _, m := range attrRe.FindAllStringSubmatch(open, -1) {
if m[1] != "" {
attrs[strings.ToLower(m[1])] = m[2]
} else {
attrs[strings.ToLower(m[3])] = m[4]
}
}
// The profile a receiver looks for. Without it the document is a plain
// SVG and is ignored.
if got := attrs["baseprofile"]; !strings.EqualFold(got, "tiny-ps") {
add("baseProfile", fmt.Sprintf("must be \"tiny-ps\", found %q", got))
}
if got := attrs["version"]; got != "1.2" {
add("version", fmt.Sprintf("must be \"1.2\", found %q", got))
}
// A non-square mark is cropped or letterboxed differently by every client,
// so the specification requires the viewBox itself be square.
if err := checkSquareViewBox(attrs["viewbox"]); err != nil {
add("viewBox", err.Error())
}
// x and y are meaningful only when the mark is placed in another document,
// and the specification forbids them on the root.
for _, forbidden := range []string{"x", "y"} {
if _, present := attrs[forbidden]; present {
add("rootAttributes", fmt.Sprintf("the root <svg> must not carry %q", forbidden))
}
}
// Exactly one title, and it is what a screen reader announces.
titles := titleElementRe.FindAllStringSubmatch(text, -1)
switch {
case len(titles) == 0:
add("title", "a <title> element is required")
case len(titles) > 1:
add("title", fmt.Sprintf("found %d <title> elements; there must be exactly one", len(titles)))
case strings.TrimSpace(titles[0][1]) == "":
add("title", "the <title> element is empty")
}
lower := strings.ToLower(text)
for _, name := range forbiddenElements {
if strings.Contains(lower, "<"+strings.ToLower(name)) {
add("forbiddenElement", fmt.Sprintf("<%s> is not allowed", name))
}
}
if onEventRe.MatchString(text) {
add("script", "event handler attributes are not allowed")
}
if externalRefRe.MatchString(text) {
add("externalReference", "the logo must not reference anything outside itself")
}
return out
}
// checkSquareViewBox verifies the mark occupies a square.
func checkSquareViewBox(value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("a viewBox is required")
}
fields := strings.FieldsFunc(value, func(r rune) bool { return r == ' ' || r == ',' || r == '\t' || r == '\n' })
if len(fields) != 4 {
return fmt.Errorf("must be four numbers, found %q", value)
}
var width, height float64
if _, err := fmt.Sscanf(fields[2], "%g", &width); err != nil {
return fmt.Errorf("width %q is not a number", fields[2])
}
if _, err := fmt.Sscanf(fields[3], "%g", &height); err != nil {
return fmt.Errorf("height %q is not a number", fields[3])
}
if width <= 0 || height <= 0 {
return fmt.Errorf("width and height must be positive")
}
if width != height {
return fmt.Errorf("must be square; found %g by %g", width, height)
}
return nil
}
@@ -1,152 +0,0 @@
package bimi
import (
"fmt"
"strings"
"testing"
)
// A logo that satisfies every rule, used as the starting point each case
// then breaks in exactly one way.
const goodLogo = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 512 512">
<title>LambdaMail</title>
<rect width="512" height="512" fill="#1b1b25"/>
<path d="M128 384 L256 128 L384 384 Z" fill="#9184d9"/>
</svg>`
func rulesOf(vs []Violation) []string {
out := make([]string, 0, len(vs))
for _, v := range vs {
out = append(out, v.Rule)
}
return out
}
func contains(rules []string, want string) bool {
for _, r := range rules {
if r == want {
return true
}
}
return false
}
func TestValidate_AcceptsAConformingLogo(t *testing.T) {
if vs := Validate([]byte(goodLogo)); len(vs) != 0 {
t.Errorf("a conforming logo was rejected: %v", vs)
}
}
func TestValidate_RequiresTheProfileReceiversLookFor(t *testing.T) {
cases := map[string]struct{ from, to, rule string }{
// Without the profile the document is a plain SVG, and a receiver
// ignores it without saying so.
"missing baseProfile": {`baseProfile="tiny-ps" `, "", "baseProfile"},
"wrong baseProfile": {`baseProfile="tiny-ps"`, `baseProfile="full"`, "baseProfile"},
"missing version": {`version="1.2" `, "", "version"},
"wrong version": {`version="1.2"`, `version="1.1"`, "version"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
logo := strings.Replace(goodLogo, tc.from, tc.to, 1)
if !contains(rulesOf(Validate([]byte(logo))), tc.rule) {
t.Errorf("no %s violation reported", tc.rule)
}
})
}
}
// Every client crops or letterboxes a non-square mark differently, so the
// specification requires the viewBox itself be square.
func TestValidate_RequiresASquareViewBox(t *testing.T) {
for _, viewBox := range []string{"0 0 512 256", "0 0 100 200", "0 0 512", "", "0 0 0 0", "0 0 a b"} {
t.Run(viewBox, func(t *testing.T) {
logo := strings.Replace(goodLogo, `viewBox="0 0 512 512"`, fmt.Sprintf(`viewBox=%q`, viewBox), 1)
if !contains(rulesOf(Validate([]byte(logo))), "viewBox") {
t.Errorf("viewBox %q was accepted", viewBox)
}
})
}
// Commas are a legal separator and must not be read as a malformed box.
commas := strings.Replace(goodLogo, `viewBox="0 0 512 512"`, `viewBox="0,0,512,512"`, 1)
if contains(rulesOf(Validate([]byte(commas))), "viewBox") {
t.Error("a comma-separated square viewBox was rejected")
}
}
func TestValidate_RequiresExactlyOneTitle(t *testing.T) {
none := strings.Replace(goodLogo, "<title>LambdaMail</title>", "", 1)
if !contains(rulesOf(Validate([]byte(none))), "title") {
t.Error("a logo with no title was accepted")
}
two := strings.Replace(goodLogo, "<title>LambdaMail</title>", "<title>A</title><title>B</title>", 1)
if !contains(rulesOf(Validate([]byte(two))), "title") {
t.Error("a logo with two titles was accepted")
}
empty := strings.Replace(goodLogo, "<title>LambdaMail</title>", "<title> </title>", 1)
if !contains(rulesOf(Validate([]byte(empty))), "title") {
t.Error("an empty title was accepted")
}
}
// The logo is fetched and rendered by other people's mail clients. Anything
// active or anything that reaches out over the network has no business in it.
func TestValidate_RefusesAnythingActiveOrExternal(t *testing.T) {
cases := map[string]struct{ insert, rule string }{
"script element": {`<script>alert(1)</script>`, "forbiddenElement"},
"link": {`<a href="https://example.test">x</a>`, "forbiddenElement"},
"raster image": {`<image href="https://example.test/p.png"/>`, "forbiddenElement"},
"foreign object": {`<foreignObject><b>x</b></foreignObject>`, "forbiddenElement"},
"animation": {`<animate attributeName="x" to="9"/>`, "forbiddenElement"},
"event handler": {`<rect width="1" height="1" onload="alert(1)"/>`, "script"},
"external href": {`<rect width="1" height="1" xlink:href="https://example.test/x"/>`, "externalReference"},
"data uri": {`<rect width="1" height="1" href="data:image/png;base64,AAAA"/>`, "externalReference"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
logo := strings.Replace(goodLogo, "</svg>", tc.insert+"</svg>", 1)
if !contains(rulesOf(Validate([]byte(logo))), tc.rule) {
t.Errorf("%s was accepted: %v", name, Validate([]byte(logo)))
}
})
}
}
func TestValidate_RefusesWhatCannotBeReadAtAll(t *testing.T) {
if !contains(rulesOf(Validate([]byte(""))), "empty") {
t.Error("an empty file was accepted")
}
if !contains(rulesOf(Validate([]byte("<svg><unclosed>"))), "xml") {
t.Error("malformed XML was accepted")
}
// A PNG renamed to .svg is the likeliest wrong upload of all.
if len(Validate([]byte{0x89, 'P', 'N', 'G'})) == 0 {
t.Error("a PNG was accepted as a logo")
}
}
func TestValidate_RefusesALogoTooBigToBeFetched(t *testing.T) {
padding := strings.Repeat(" ", MaxLogoBytes)
logo := strings.Replace(goodLogo, "</svg>", "<!--"+padding+"--></svg>", 1)
if !contains(rulesOf(Validate([]byte(logo))), "size") {
t.Error("an oversized logo was accepted")
}
}
// One rule at a time, against a receiver that shows nothing either way, is a
// miserable way to learn these - so every violation is reported at once.
func TestValidate_ReportsEveryViolationAtOnce(t *testing.T) {
broken := `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 20"><script/></svg>`
rules := rulesOf(Validate([]byte(broken)))
for _, want := range []string{"baseProfile", "version", "viewBox", "title", "forbiddenElement"} {
if !contains(rules, want) {
t.Errorf("missing %s; got %v", want, rules)
}
}
}
@@ -35,15 +35,6 @@ type DnsRecordSpec struct {
// this domain. Without it every message the relay forwards fails SPF // this domain. Without it every message the relay forwards fails SPF
// (PLAN.md section 10.4). // (PLAN.md section 10.4).
RelaySpfInclude string RelaySpfInclude string
// BimiLogoURL is where the published mark is served from. Empty when the
// domain has no logo, and then no BIMI record is published at all: an
// advertised record pointing at nothing is worse than none, because a
// receiver stops looking once it finds one.
BimiLogoURL string
// BimiVmcURL is the Verified Mark Certificate. Gmail and Outlook render
// the logo only when the record carries one; without it the record is
// still valid and other clients may use it.
BimiVmcURL string
} }
// BuildDnsRecordSpecs generates the DNS records of PLAN.md section 7: the 13 // BuildDnsRecordSpecs generates the DNS records of PLAN.md section 7: the 13
@@ -205,21 +196,6 @@ func BuildDnsRecordSpecs(spec DnsRecordSpec) []DnsRecord {
}) })
} }
// BIMI, published only when there is a logo to point at.
if spec.BimiLogoURL != "" {
value := fmt.Sprintf("v=BIMI1; l=%s;", spec.BimiLogoURL)
if spec.BimiVmcURL != "" {
value = fmt.Sprintf("v=BIMI1; l=%s; a=%s;", spec.BimiLogoURL, spec.BimiVmcURL)
}
records = append(records, DnsRecord{
Type: "TXT",
Name: fmt.Sprintf("default._bimi.%s", domainName),
Value: value,
TTL: 1,
Comment: "LambdaMail BIMI Record",
})
}
// 2. Conditional AAAA record if IPv6 is supplied // 2. Conditional AAAA record if IPv6 is supplied
if ownsMailHost && serverIPv6 != "" { if ownsMailHost && serverIPv6 != "" {
records = append(records, DnsRecord{ records = append(records, DnsRecord{
@@ -195,72 +195,3 @@ func TestBuildDnsRecordSpecs_KeepsMailHostAddressesInsideTheDomain(t *testing.T)
t.Errorf("lost the mail host addresses (A=%v AAAA=%v)", a, aaaa) t.Errorf("lost the mail host addresses (A=%v AAAA=%v)", a, aaaa)
} }
} }
// BIMI is published only when there is a mark to point at.
//
// A record that advertises a logo which is not there is worse than no record:
// a receiver stops looking once it finds one, so an empty pointer buys a
// failed fetch instead of the fallback it would otherwise have used.
func TestBuildDnsRecordSpecs_BimiOnlyWhenThereIsALogo(t *testing.T) {
without := BuildDnsRecordSpecs(DnsRecordSpec{
DomainName: "example.test",
MailHost: "mail.example.test",
ServerIPv4: "192.0.2.1",
})
for _, r := range without {
if strings.Contains(r.Name, "_bimi") {
t.Errorf("published %s with no logo configured", r.Name)
}
}
with := BuildDnsRecordSpecs(DnsRecordSpec{
DomainName: "example.test",
MailHost: "mail.example.test",
ServerIPv4: "192.0.2.1",
BimiLogoURL: "https://mail.example.test/.well-known/bimi/default.svg",
})
var found *DnsRecord
for i, r := range with {
if r.Name == "default._bimi.example.test" {
found = &with[i]
}
}
if found == nil {
t.Fatal("no BIMI record was published for a domain with a logo")
}
if found.Type != "TXT" {
t.Errorf("BIMI record type is %q, want TXT", found.Type)
}
if !strings.HasPrefix(found.Value, "v=BIMI1;") {
t.Errorf("value %q does not start with the version tag", found.Value)
}
// No certificate configured, so no a= tag: an empty one would advertise a
// certificate that cannot be fetched.
if strings.Contains(found.Value, "a=") {
t.Errorf("value %q carries an empty certificate tag", found.Value)
}
}
// Gmail and Outlook render the mark only when the record points at a Verified
// Mark Certificate, so the tag has to be carried when there is one.
func TestBuildDnsRecordSpecs_BimiCarriesTheCertificateWhenConfigured(t *testing.T) {
specs := BuildDnsRecordSpecs(DnsRecordSpec{
DomainName: "example.test",
MailHost: "mail.example.test",
ServerIPv4: "192.0.2.1",
BimiLogoURL: "https://mail.example.test/.well-known/bimi/default.svg",
BimiVmcURL: "https://mail.example.test/.well-known/bimi/default.pem",
})
for _, r := range specs {
if r.Name != "default._bimi.example.test" {
continue
}
if !strings.Contains(r.Value, "a=https://mail.example.test/.well-known/bimi/default.pem") {
t.Errorf("value %q does not carry the certificate", r.Value)
}
return
}
t.Fatal("no BIMI record was published")
}
@@ -1,79 +0,0 @@
package postgres
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// BimiRepository stores the logo a domain publishes for BIMI.
type BimiRepository struct {
pool *pgxpool.Pool
}
func NewBimiRepository(pool *pgxpool.Pool) *BimiRepository {
return &BimiRepository{pool: pool}
}
// BimiLogo is a stored mark.
type BimiLogo struct {
SVG []byte
ETag string
VmcURL string
}
// Save replaces a domain's logo, returning the digest receivers can cache on.
func (r *BimiRepository) Save(ctx context.Context, domain string, svg []byte, vmcURL string) (string, error) {
sum := sha256.Sum256(svg)
etag := hex.EncodeToString(sum[:])
_, err := r.pool.Exec(ctx, `
INSERT INTO domain_bimi (domain_id, svg, etag, vmc_url, updated_at)
SELECT d.id, $2, $3, NULLIF($4, ''), NOW() FROM domains d WHERE lower(d.name) = lower($1)
ON CONFLICT (domain_id) DO UPDATE
SET svg = EXCLUDED.svg, etag = EXCLUDED.etag,
vmc_url = EXCLUDED.vmc_url, updated_at = NOW()
`, domain, svg, etag, vmcURL)
if err != nil {
return "", fmt.Errorf("save BIMI logo for %s: %w", domain, err)
}
return etag, nil
}
// Load returns a domain's logo, or nil when it has none.
func (r *BimiRepository) Load(ctx context.Context, domain string) (*BimiLogo, error) {
var logo BimiLogo
var vmc *string
err := r.pool.QueryRow(ctx, `
SELECT b.svg, b.etag, b.vmc_url
FROM domain_bimi b JOIN domains d ON d.id = b.domain_id
WHERE lower(d.name) = lower($1)
`, domain).Scan(&logo.SVG, &logo.ETag, &vmc)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("load BIMI logo for %s: %w", domain, err)
}
if vmc != nil {
logo.VmcURL = *vmc
}
return &logo, nil
}
// Delete removes a domain's logo.
func (r *BimiRepository) Delete(ctx context.Context, domain string) error {
_, err := r.pool.Exec(ctx, `
DELETE FROM domain_bimi b USING domains d
WHERE b.domain_id = d.id AND lower(d.name) = lower($1)
`, domain)
if err != nil {
return fmt.Errorf("delete BIMI logo for %s: %w", domain, err)
}
return nil
}
@@ -1,217 +0,0 @@
package httppresentation
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"lambdamail/protocols/internal/domain/bimi"
)
// BimiStore holds the mark a domain publishes.
type BimiStore interface {
Save(ctx context.Context, domain string, svg []byte, vmcURL string) (string, error)
Load(ctx context.Context, domain string) (*BimiLogo, error)
Delete(ctx context.Context, domain string) error
}
// BimiLogo is a stored mark as this layer sees it.
type BimiLogo struct {
SVG []byte
ETag string
VmcURL string
}
type bimiAPI struct {
store BimiStore
sessions *WebSessionVerifier
// domainOf maps the host a request arrived on to the mail domain whose
// logo should answer, so one deployment can serve several.
defaultDomain string
}
// SetBimiAPI wires the BIMI logo store.
func (r *Router) SetBimiAPI(store BimiStore, sessionSecret, defaultDomain string) {
if store == nil {
return
}
r.bimi = &bimiAPI{store: store, defaultDomain: defaultDomain}
if sessionSecret != "" {
r.bimi.sessions = NewWebSessionVerifier(sessionSecret)
}
}
// handlePublicLogo serves the mark itself.
//
// Deliberately open: this is fetched by receiving mail providers evaluating a
// message, which hold no session here and never will. It is also the only
// thing in this service that is meant to be read by strangers, so it answers
// nothing but the stored bytes.
func (r *Router) handleBimiLogo(w http.ResponseWriter, req *http.Request) {
if r.bimi == nil {
http.NotFound(w, req)
return
}
if req.Method != http.MethodGet && req.Method != http.MethodHead {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "GET required")
return
}
domain := r.bimi.domainFor(req)
logo, err := r.bimi.store.Load(req.Context(), domain)
if err != nil || logo == nil {
http.NotFound(w, req)
return
}
if req.Header.Get("If-None-Match") == `"`+logo.ETag+`"` {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("ETag", `"`+logo.ETag+`"`)
// Public and long: receivers fetch this for every message they evaluate,
// and the mark changes about as often as a company rebrands.
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Header().Set("X-Content-Type-Options", "nosniff")
// The document is served from the same origin as the console, and an SVG
// is a document a browser will happily execute. Nothing in a validated
// Tiny PS mark is active, but the header costs nothing and covers the case
// where validation is one day loosened.
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
if req.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
_, _ = w.Write(logo.SVG)
}
func (a *bimiAPI) domainFor(req *http.Request) string {
host := extractHost(req.Host)
// mail.example.test serves example.test's mark; anything else falls back
// to the domain this deployment was configured with.
if trimmed := strings.TrimPrefix(host, "mail."); trimmed != host && trimmed != "" {
return trimmed
}
return a.defaultDomain
}
// handleBimiAdmin uploads, inspects or removes a domain's mark.
func (r *Router) handleBimiAdmin(w http.ResponseWriter, req *http.Request) {
if r.bimi == nil || r.bimi.sessions == nil {
writeError(w, http.StatusServiceUnavailable, "BIMI_DISABLED",
"The BIMI panel needs JWT_SECRET and a database")
return
}
token := bearerOrCookie(req, "lm_admin_session")
if token == "" {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Admin session required")
return
}
if _, err := r.bimi.sessions.RequireSurface(token, "admin"); err != nil {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Admin session required")
return
}
domain := strings.TrimSpace(req.URL.Query().Get("domain"))
if domain == "" {
writeError(w, http.StatusBadRequest, "DOMAIN_REQUIRED", "A domain is required")
return
}
switch req.Method {
case http.MethodGet:
r.bimi.get(w, req, domain)
case http.MethodPut:
r.bimi.put(w, req, domain)
case http.MethodDelete:
if err := r.bimi.store.Delete(req.Context(), domain); err != nil {
log.Printf("bimi: could not delete the logo for %s: %v", domain, err)
writeError(w, http.StatusInternalServerError, "DELETE_FAILED", "Could not remove the logo")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
default:
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "GET, PUT or DELETE")
}
}
func (a *bimiAPI) get(w http.ResponseWriter, req *http.Request, domain string) {
logo, err := a.store.Load(req.Context(), domain)
if err != nil {
writeError(w, http.StatusInternalServerError, "LOAD_FAILED", "Could not read the logo")
return
}
if logo == nil {
writeJSON(w, http.StatusOK, map[string]any{"configured": false})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"configured": true,
"etag": logo.ETag,
"vmc_url": logo.VmcURL,
"bytes": len(logo.SVG),
"svg": string(logo.SVG),
})
}
func (a *bimiAPI) put(w http.ResponseWriter, req *http.Request, domain string) {
// A shade over the specification's ceiling, so an oversized upload is
// refused by the validator with a reason rather than truncated here.
body, err := io.ReadAll(io.LimitReader(req.Body, bimi.MaxLogoBytes*2))
if err != nil {
writeError(w, http.StatusBadRequest, "READ_FAILED", "Could not read the upload")
return
}
var payload struct {
SVG string `json:"svg"`
VmcURL string `json:"vmc_url"`
}
if err := json.Unmarshal(body, &payload); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Expected a JSON body with an svg field")
return
}
// Every reason at once. Fixing one rule at a time against a receiver that
// silently shows nothing is a miserable way to learn this format.
if violations := bimi.Validate([]byte(payload.SVG)); len(violations) > 0 {
reasons := make([]string, 0, len(violations))
for _, v := range violations {
reasons = append(reasons, v.String())
}
writeJSON(w, http.StatusBadRequest, map[string]any{
"error": "INVALID_LOGO",
"message": "The logo is not SVG Tiny Portable/Secure",
"violations": reasons,
})
return
}
vmc := strings.TrimSpace(payload.VmcURL)
if vmc != "" && !strings.HasPrefix(vmc, "https://") {
// A certificate fetched over plain HTTP is not evidence of anything,
// and every receiver refuses it.
writeError(w, http.StatusBadRequest, "INSECURE_VMC", "The certificate URL must be https")
return
}
etag, err := a.store.Save(req.Context(), domain, []byte(payload.SVG), vmc)
if err != nil {
log.Printf("bimi: could not save the logo for %s: %v", domain, err)
writeError(w, http.StatusInternalServerError, "SAVE_FAILED", "Could not store the logo")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "etag": etag})
}
// BimiLogoURL is where a domain's mark is served from.
func BimiLogoURL(mailHost string) string {
return fmt.Sprintf("https://%s/.well-known/bimi/default.svg", mailHost)
}
@@ -40,7 +40,6 @@ type Router struct {
// dnsReconciler is held here so it survives being set before dns exists. // dnsReconciler is held here so it survives being set before dns exists.
dnsReconciler DomainReconciler dnsReconciler DomainReconciler
dnsStatus DnsStatusWriter dnsStatus DnsStatusWriter
bimi *bimiAPI
// degradedFunc reports a condition that leaves the service running but // degradedFunc reports a condition that leaves the service running but
// not fit for production, the clearest case being a self-signed // not fit for production, the clearest case being a self-signed
// certificate standing in for one Traefik never issued // certificate standing in for one Traefik never issued
@@ -79,8 +78,6 @@ func (r *Router) registerRoutes() {
r.mux.HandleFunc("/health", r.handleHealth) r.mux.HandleFunc("/health", r.handleHealth)
r.mux.HandleFunc("/.well-known/mta-sts.txt", r.handleMtaSts) r.mux.HandleFunc("/.well-known/mta-sts.txt", r.handleMtaSts)
r.mux.HandleFunc("/.well-known/security.txt", r.handleSecurityTxt) r.mux.HandleFunc("/.well-known/security.txt", r.handleSecurityTxt)
r.mux.HandleFunc("/.well-known/bimi/default.svg", r.handleBimiLogo)
r.mux.HandleFunc("/api/v1/admin/bimi", r.handleBimiAdmin)
r.mux.HandleFunc("/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig) r.mux.HandleFunc("/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig)
r.mux.HandleFunc("/.well-known/autoconfig/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig) r.mux.HandleFunc("/.well-known/autoconfig/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig)
r.mux.HandleFunc("/autodiscover/autodiscover.xml", r.handleOutlookAutodiscover) r.mux.HandleFunc("/autodiscover/autodiscover.xml", r.handleOutlookAutodiscover)
+1 -20
View File
@@ -113,16 +113,7 @@
"passwordResetWarning": "Signs out every session this user holds, including one open right now.", "passwordResetWarning": "Signs out every session this user holds, including one open right now.",
"removeTotp": "Remove second factor", "removeTotp": "Remove second factor",
"totpRemoved": "Second factor removed, with its recovery codes.", "totpRemoved": "Second factor removed, with its recovery codes.",
"tlsaDigest": "Digest for the TLSA record (DANE 3 1 1)", "tlsaDigest": "Digest for the TLSA record (DANE 3 1 1)"
"bimiTitle": "BIMI for {domain}",
"bimiExplainer": "BIMI is the only mechanism that shows an image beside your mail in external clients. It is per domain, not per user. Gmail and Outlook render it only when the record points at a VMC.",
"bimiConfigured": "Logo published",
"bimiNotConfigured": "No logo",
"bimiNoVmc": "No VMC: Gmail and Outlook will not show this logo.",
"bimiSvg": "Logo (SVG Tiny PS)",
"bimiVmc": "VMC URL (optional)",
"bimiRejected": "The logo does not meet SVG Tiny Portable/Secure:",
"bimiSaved": "Logo saved."
}, },
"auth": { "auth": {
"adminLoginTitle": "Admin console", "adminLoginTitle": "Admin console",
@@ -437,15 +428,5 @@
"user": "User", "user": "User",
"domainAdmin": "Domain admin", "domainAdmin": "Domain admin",
"superAdmin": "Super 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."
} }
} }
+1 -20
View File
@@ -113,16 +113,7 @@
"passwordResetWarning": "Cierra todas las sesiones de este usuario, incluida una abierta ahora.", "passwordResetWarning": "Cierra todas las sesiones de este usuario, incluida una abierta ahora.",
"removeTotp": "Quitar segundo factor", "removeTotp": "Quitar segundo factor",
"totpRemoved": "Segundo factor eliminado, con sus codigos de recuperacion.", "totpRemoved": "Segundo factor eliminado, con sus codigos de recuperacion.",
"tlsaDigest": "Digest para el registro TLSA (DANE 3 1 1)", "tlsaDigest": "Digest para el registro TLSA (DANE 3 1 1)"
"bimiTitle": "BIMI de {domain}",
"bimiExplainer": "BIMI es el unico mecanismo que muestra una imagen junto a su correo en clientes externos. Es por dominio, no por usuario. Gmail y Outlook solo lo dibujan cuando el registro apunta a un VMC.",
"bimiConfigured": "Logotipo publicado",
"bimiNotConfigured": "Sin logotipo",
"bimiNoVmc": "Sin VMC: Gmail y Outlook no mostraran este logotipo.",
"bimiSvg": "Logotipo (SVG Tiny PS)",
"bimiVmc": "URL del VMC (opcional)",
"bimiRejected": "El logotipo no cumple SVG Tiny Portable/Secure:",
"bimiSaved": "Logotipo guardado."
}, },
"auth": { "auth": {
"adminLoginTitle": "Consola de administración", "adminLoginTitle": "Consola de administración",
@@ -437,15 +428,5 @@
"user": "Usuario", "user": "Usuario",
"domainAdmin": "Admin del dominio", "domainAdmin": "Admin del dominio",
"superAdmin": "Super admin" "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."
} }
} }
+1 -20
View File
@@ -113,16 +113,7 @@
"passwordResetWarning": "Termina todas as sessoes deste utilizador, incluindo a que esteja aberta agora.", "passwordResetWarning": "Termina todas as sessoes deste utilizador, incluindo a que esteja aberta agora.",
"removeTotp": "Remover segundo factor", "removeTotp": "Remover segundo factor",
"totpRemoved": "Segundo factor removido, com os codigos de recuperacao.", "totpRemoved": "Segundo factor removido, com os codigos de recuperacao.",
"tlsaDigest": "Digest para o registo TLSA (DANE 3 1 1)", "tlsaDigest": "Digest para o registo TLSA (DANE 3 1 1)"
"bimiTitle": "BIMI de {domain}",
"bimiExplainer": "O BIMI e o unico mecanismo que mostra uma imagem ao lado do seu correio em clientes externos. E por dominio, nao por utilizador. O Gmail e o Outlook so o desenham quando o registo aponta para um VMC.",
"bimiConfigured": "Logotipo publicado",
"bimiNotConfigured": "Sem logotipo",
"bimiNoVmc": "Sem VMC: o Gmail e o Outlook nao vao mostrar este logotipo.",
"bimiSvg": "Logotipo (SVG Tiny PS)",
"bimiVmc": "URL do VMC (opcional)",
"bimiRejected": "O logotipo nao cumpre o SVG Tiny Portable/Secure:",
"bimiSaved": "Logotipo guardado."
}, },
"auth": { "auth": {
"adminLoginTitle": "Console de administração", "adminLoginTitle": "Console de administração",
@@ -437,15 +428,5 @@
"user": "Utilizador", "user": "Utilizador",
"domainAdmin": "Admin do dominio", "domainAdmin": "Admin do dominio",
"superAdmin": "Super admin" "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."
} }
} }
@@ -78,14 +78,6 @@ export default function AdminDomainsPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [reconciling, setReconciling] = useState(false); const [reconciling, setReconciling] = useState(false);
// The BIMI mark for the selected domain: the one thing an external mail
// client will ever render for this sender.
const [bimi, setBimi] = useState<{ configured: boolean; vmc_url?: string; bytes?: number } | null>(null);
const [bimiSvg, setBimiSvg] = useState("");
const [bimiVmc, setBimiVmc] = useState("");
const [bimiBusy, setBimiBusy] = useState(false);
const [bimiProblems, setBimiProblems] = useState<string[]>([]);
const [verification, setVerification] = useState<DnsVerification | null>(null); const [verification, setVerification] = useState<DnsVerification | null>(null);
// What the last reconciliation actually did, so the panel can say it rather // What the last reconciliation actually did, so the panel can say it rather
// than leaving the operator to infer it from the verification that follows. // than leaving the operator to infer it from the verification that follows.
@@ -126,73 +118,6 @@ export default function AdminDomainsPage() {
// Asks the protocols service to resolve every expected record and report // Asks the protocols service to resolve every expected record and report
// each one. It used to post to the auth service, which has no resolver and // each one. It used to post to the auth service, which has no resolver and
// could only re-read the status already in the database. // could only re-read the status already in the database.
// Reloaded whenever the selected domain changes: the mark belongs to the
// domain, not to the console.
useEffect(() => {
if (!selected) {
setBimi(null);
return;
}
let cancelled = false;
void (async () => {
try {
const res = await fetch(`/api/v1/admin/bimi?domain=${encodeURIComponent(selected.name)}`);
const data = res.ok ? await res.json() : { configured: false };
if (cancelled) return;
setBimi(data);
setBimiVmc(data.vmc_url ?? "");
setBimiSvg("");
setBimiProblems([]);
} catch {
if (!cancelled) setBimi({ configured: false });
}
})();
return () => {
cancelled = true;
};
}, [selected?.name]);
const saveBimi = async () => {
if (!selected || !bimiSvg.trim()) return;
setBimiBusy(true);
setBimiProblems([]);
setError(null);
try {
const res = await fetch(`/api/v1/admin/bimi?domain=${encodeURIComponent(selected.name)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ svg: bimiSvg, vmc_url: bimiVmc.trim() }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
// Every rule that failed, not the first: a receiver that rejects the
// mark shows nothing at all, so one round trip per rule is the worst
// possible way to learn this format.
setBimiProblems(data.violations ?? [data.message ?? t("errors.serverError")]);
return;
}
setBimi({ configured: true, vmc_url: bimiVmc.trim(), bytes: bimiSvg.length });
setBimiSvg("");
setNotice(t("admin.bimiSaved"));
} catch {
setError(t("errors.serverError"));
} finally {
setBimiBusy(false);
}
};
const removeBimi = async () => {
if (!selected) return;
setBimiBusy(true);
try {
await fetch(`/api/v1/admin/bimi?domain=${encodeURIComponent(selected.name)}`, { method: "DELETE" });
setBimi({ configured: false });
setBimiVmc("");
} finally {
setBimiBusy(false);
}
};
const handleReconcile = async () => { const handleReconcile = async () => {
if (!selected) return; if (!selected) return;
setReconciling(true); setReconciling(true);
@@ -521,70 +446,6 @@ export default function AdminDomainsPage() {
</section> </section>
)} )}
{/* BIMI: the only mark an external mail client ever renders for this
sender, and the reason a profile photo does not travel. */}
{activeTab === "domains" && selected && (
<section className={panel}>
<h2 className="text-[17px] font-medium leading-tight text-slate-100">
{t("admin.bimiTitle", { domain: selected.name })}
</h2>
<p className="text-[12.5px] leading-relaxed text-slate-400">{t("admin.bimiExplainer")}</p>
<div className="flex flex-wrap items-center gap-2.5">
<Badge variant={bimi?.configured ? "success" : "neutral"}>
{bimi?.configured ? t("admin.bimiConfigured") : t("admin.bimiNotConfigured")}
</Badge>
{bimi?.configured && !bimi.vmc_url && (
<span className="text-[11.5px] leading-relaxed text-amber-400">{t("admin.bimiNoVmc")}</span>
)}
</div>
<div>
<label htmlFor="bimi-svg" className={fieldLabel}>{t("admin.bimiSvg")}</label>
<textarea
id="bimi-svg"
value={bimiSvg}
onChange={(e) => setBimiSvg(e.target.value)}
rows={7}
spellCheck={false}
className={`${input} font-mono text-[12px]`}
/>
</div>
<div>
<label htmlFor="bimi-vmc" className={fieldLabel}>{t("admin.bimiVmc")}</label>
<input
id="bimi-vmc"
type="url"
value={bimiVmc}
onChange={(e) => setBimiVmc(e.target.value)}
placeholder="https://.../vmc.pem"
className={input}
/>
</div>
{bimiProblems.length > 0 && (
<div className="flex flex-col gap-1 rounded-xl bg-rose-900/50 px-3.5 py-3 shadow-edge">
<span className="text-[12.5px] text-rose-100">{t("admin.bimiRejected")}</span>
{bimiProblems.map((p) => (
<span key={p} className="font-mono text-[11.5px] leading-relaxed text-rose-200">{p}</span>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" size="sm" onClick={() => void saveBimi()} disabled={bimiBusy || !bimiSvg.trim()}>
{bimiBusy ? t("common.loading") : t("common.save")}
</Button>
{bimi?.configured && (
<Button variant="ghost" size="sm" onClick={() => void removeBimi()} disabled={bimiBusy}>
{t("common.delete")}
</Button>
)}
</div>
</section>
)}
{activeTab === "dkim" && ( {activeTab === "dkim" && (
<section className={`${panel} max-w-[720px]`}> <section className={`${panel} max-w-[720px]`}>
<div> <div>
@@ -27,7 +27,7 @@ import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
import { useAccount, isAdminRole } from "../../../lib/useAccount"; import { useAccount, isAdminRole } from "../../../lib/useAccount";
import { useFolders } from "../../../lib/useFolders"; import { useFolders } from "../../../lib/useFolders";
import { folderBadge, customFolders } from "../../../lib/mailCounts"; import { folderBadge, customFolders } from "../../../lib/mailCounts";
import { Avatar } from "../../../components/Avatar"; import { initialsFor } from "../../../lib/initials";
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) { export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
const t = useTranslations(); const t = useTranslations();
@@ -279,7 +279,9 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
onClick={() => setMobileOpen(false)} 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" className="flex items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 shadow-edge transition-colors hover:bg-dark-card"
> >
<Avatar address={account?.email} size={30} className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]" /> <span className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[12px] uppercase text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]">
{initialsFor(account?.email)}
</span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span <span
className="block truncate text-[12.5px] leading-tight" className="block truncate text-[12.5px] leading-tight"
@@ -38,7 +38,6 @@ import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../..
import { isSenderTrusted, trustSender, revokeSender } from "../../../../../lib/remoteImages"; import { isSenderTrusted, trustSender, revokeSender } from "../../../../../lib/remoteImages";
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody"; import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody";
import { Button } from "../../../../../components/ui/Button"; import { Button } from "../../../../../components/ui/Button";
import { Avatar } from "../../../../../components/Avatar";
interface MessageSummary { interface MessageSummary {
uid: number; uid: number;
@@ -458,16 +457,16 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
data-active={isSelected} data-active={isSelected}
className="lm-row w-full text-left" className="lm-row w-full text-left"
> >
<Avatar <span
address={msg.sender_address} className={`flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full text-xs ${
initials={initials(msg.from_display_name, msg.sender_address)}
size={38}
className={
msg.seen msg.seen
? "bg-dark-card text-slate-400" ? "bg-dark-card text-slate-400"
: "bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]" : "bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
} }`}
/> aria-hidden="true"
>
{initials(msg.from_display_name, msg.sender_address)}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-[3px]"> <span className="flex min-w-0 flex-1 flex-col gap-[3px]">
<span className="flex items-baseline gap-2"> <span className="flex items-baseline gap-2">
@@ -653,12 +652,9 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
</h1> </h1>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Avatar <div className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[13px] text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]">
address={selectedRow?.sender_address ?? selected.from} {initials(selectedRow?.from_display_name ?? "", selected.from)}
initials={initials(selectedRow?.from_display_name ?? "", selected.from)} </div>
size={38}
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
/>
<div className="flex min-w-0 flex-1 flex-col gap-[3px]"> <div className="flex min-w-0 flex-1 flex-col gap-[3px]">
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1"> <div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<span className="break-all text-sm text-slate-100">{selected.from}</span> <span className="break-all text-sm text-slate-100">{selected.from}</span>
@@ -15,7 +15,6 @@ import {
Plus, Plus,
Lock, Lock,
Languages, Languages,
UserRound,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "../../../../i18n/provider"; import { useTranslations } from "../../../../i18n/provider";
import { Badge } from "../../../../components/ui/Badge"; import { Badge } from "../../../../components/ui/Badge";
@@ -25,8 +24,6 @@ import { LanguageSwitcher } from "../../../../i18n/LanguageSwitcher";
import { signatureToHtml, sanitizeSignature } from "../../../../lib/signature"; import { signatureToHtml, sanitizeSignature } from "../../../../lib/signature";
import { buildSieve, parseSieve, type Rule, type RuleField, type RuleCondition, type RuleAction } from "../../../../lib/rules"; import { buildSieve, parseSieve, type Rule, type RuleField, type RuleCondition, type RuleAction } from "../../../../lib/rules";
import { useFolders } from "../../../../lib/useFolders"; import { useFolders } from "../../../../lib/useFolders";
import { AvatarEditor } from "../../../../components/AvatarEditor";
import { useAccount } from "../../../../lib/useAccount";
import { moveTargets } from "../../../../lib/mailCounts"; import { moveTargets } from "../../../../lib/mailCounts";
interface SieveRule { interface SieveRule {
@@ -54,20 +51,11 @@ interface WebSession {
current: boolean; current: boolean;
} }
type SettingsTab = "profile" | "security" | "filters" | "vacation" | "signature" | "language"; type SettingsTab = "security" | "filters" | "vacation" | "signature" | "language";
export default function UserSettingsPage() { export default function UserSettingsPage() {
const t = useTranslations(); const t = useTranslations();
const [activeTab, setActiveTab] = useState<SettingsTab>("profile"); const [activeTab, setActiveTab] = useState<SettingsTab>("security");
// 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 [mfaEnabled, setMfaEnabled] = useState(false);
const [qrSecret, setQrSecret] = useState<string | null>(null); const [qrSecret, setQrSecret] = useState<string | null>(null);
@@ -297,7 +285,6 @@ export default function UserSettingsPage() {
separated by everything else. The same panels now sit behind a tab strip, separated by everything else. The same panels now sit behind a tab strip,
in the order the redesign groups them. */ in the order the redesign groups them. */
const tabs = [ const tabs = [
{ id: "profile" as const, label: t("avatar.title"), icon: UserRound },
{ id: "security" as const, label: t("settings.security"), icon: Shield }, { id: "security" as const, label: t("settings.security"), icon: Shield },
{ id: "filters" as const, label: t("settings.sieveTitle"), icon: Filter }, { id: "filters" as const, label: t("settings.sieveTitle"), icon: Filter },
{ id: "vacation" as const, label: t("settings.vacationTitle"), icon: Palmtree }, { id: "vacation" as const, label: t("settings.vacationTitle"), icon: Palmtree },
@@ -349,21 +336,6 @@ export default function UserSettingsPage() {
</div> </div>
)} )}
{activeTab === "profile" && (
<section className={panel}>
<h2 className="text-[17px] font-medium leading-tight text-slate-100">{t("avatar.title")}</h2>
<p className="text-[12.5px] leading-relaxed text-slate-400">{t("avatar.explainer")}</p>
<AvatarEditor
src={avatarSrc}
onSaved={() => {
// 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);
}}
/>
</section>
)}
{activeTab === "security" && ( {activeTab === "security" && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* 2FA TOTP SECTION */} {/* 2FA TOTP SECTION */}
@@ -1,54 +0,0 @@
"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 (
<span
className={`relative inline-flex flex-none items-center justify-center overflow-hidden rounded-full uppercase ${className}`}
style={{ width: size, height: size, fontSize: Math.max(9, Math.round(size * 0.38)) }}
aria-hidden="true"
>
{!loaded && <span>{initials ?? initialsFor(address)}</span>}
{src && !failed && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt=""
width={size}
height={size}
onLoad={() => setLoaded(true)}
onError={() => setFailed(true)}
className={`absolute inset-0 h-full w-full object-cover ${loaded ? "" : "opacity-0"}`}
/>
)}
</span>
);
}
@@ -1,254 +0,0 @@
"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<HTMLImageElement | null>(null);
const [natural, setNatural] = useState<Size>({ width: 0, height: 0 });
const [transform, setTransform] = useState<Transform>({ scale: 1, offsetX: 0, offsetY: 0 });
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const fileRef = useRef<HTMLInputElement | null>(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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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<Blob | null>((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 (
<div className="flex flex-col gap-4">
{!image ? (
<div className="flex flex-wrap items-center gap-4">
{/* What is stored today, shown as it will appear elsewhere. */}
<span className="flex h-[88px] w-[88px] flex-none items-center justify-center overflow-hidden rounded-full bg-dark-card shadow-edge">
{src ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt="" className="h-full w-full object-cover" />
) : (
<span className="text-[11px] text-slate-500">{t("avatar.none")}</span>
)}
</span>
<div className="flex flex-wrap items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => fileRef.current?.click()} disabled={busy}>
<Upload className="h-3.5 w-3.5" />
<span>{src ? t("avatar.replace") : t("avatar.choose")}</span>
</Button>
{src && (
<Button variant="ghost" size="sm" onClick={() => void remove()} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
<span>{t("common.delete")}</span>
</Button>
)}
</div>
</div>
) : (
<div className="flex flex-col items-center gap-3">
<div
className="relative overflow-hidden rounded-full shadow-edge"
style={{ width: VIEWPORT, height: VIEWPORT }}
>
<canvas
ref={canvasRef}
width={VIEWPORT}
height={VIEWPORT}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className="block cursor-grab touch-none active:cursor-grabbing"
aria-label={t("avatar.dragToPosition")}
/>
</div>
<div className="flex w-full max-w-[288px] items-center gap-2.5">
<ZoomOut className="h-4 w-4 flex-none text-slate-400" />
<input
type="range"
min={range.min}
max={range.max}
step={(range.max - range.min) / 200 || 0.01}
value={transform.scale}
onChange={(e) => zoomTo(Number(e.target.value))}
aria-label={t("avatar.zoom")}
className="h-1 flex-1 cursor-pointer appearance-none rounded-full bg-dark-card accent-indigo-500"
/>
<ZoomIn className="h-4 w-4 flex-none text-slate-400" />
</div>
<p className="text-[11.5px] text-slate-500">{t("avatar.dragToPosition")}</p>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button variant="secondary" size="sm" onClick={() => setImage(null)} disabled={busy}>
{t("common.cancel")}
</Button>
<Button variant="primary" size="sm" onClick={() => void save()} disabled={busy}>
{busy ? t("common.loading") : t("common.save")}
</Button>
</div>
</div>
)}
{error && (
<div className="rounded-xl bg-rose-900/60 px-3.5 py-2.5 text-[12.5px] text-rose-200 shadow-edge">
{error}
</div>
)}
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => {
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 = "";
}}
/>
</div>
);
}
-109
View File
@@ -1,109 +0,0 @@
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);
});
});
-109
View File
@@ -1,109 +0,0 @@
/**
* 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,
};
}