From c59548e311f7aee561176c34dccc229758ad98a4 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Mon, 7 Sep 2026 22:29:12 -0300 Subject: [PATCH] Revert "revert: foto de perfil e BIMI, para restaurar o servico" Repoe as duas funcionalidades. A reversao foi uma precaucao enquanto o site estava em baixo, e acabou por ser ela a provar que a culpa nao era delas: o 502 continuou depois de reverter. A avaria era o servico web nao declarar porta nenhuma, o que levava o Coolify a gerar um servico Traefik apontado ao porto 80. Fica corrigida no commit anterior, com expose 3000. As migracoes 0012 e 0013 nunca sairam do repositorio nem da base de dados, por isso o codigo reposto encontra as tabelas que espera. Co-Authored-By: Claude Opus 5 --- README.md | 29 +- services/auth/src/imageValidation.test.ts | 58 ++++ services/auth/src/imageValidation.ts | 62 +++++ services/auth/src/repository.ts | 60 +++++ services/auth/src/router.test.ts | 58 ++++ services/auth/src/router.ts | 120 +++++++++ .../cmd/lambdamail-protocols/bimi_store.go | 30 +++ .../cmd/lambdamail-protocols/main.go | 16 +- .../protocols/internal/domain/bimi/svg.go | 153 +++++++++++ .../internal/domain/bimi/svg_test.go | 152 +++++++++++ .../internal/domain/entity/dns_record.go | 24 ++ .../internal/domain/entity/dns_record_test.go | 69 +++++ .../postgres/bimi_repository.go | 79 ++++++ .../internal/presentation/http/bimi_api.go | 217 +++++++++++++++ .../internal/presentation/http/router.go | 3 + services/webmail/messages/en.json | 21 +- services/webmail/messages/es.json | 21 +- services/webmail/messages/pt-BR.json | 21 +- .../src/app/(admin)/admin/domains/page.tsx | 139 ++++++++++ .../webmail/src/app/(user)/user/layout.tsx | 6 +- .../app/(user)/user/mail/[folder]/page.tsx | 24 +- .../src/app/(user)/user/settings/page.tsx | 32 ++- services/webmail/src/components/Avatar.tsx | 54 ++++ .../webmail/src/components/AvatarEditor.tsx | 254 ++++++++++++++++++ services/webmail/src/lib/avatarCrop.test.ts | 109 ++++++++ services/webmail/src/lib/avatarCrop.ts | 109 ++++++++ 26 files changed, 1899 insertions(+), 21 deletions(-) create mode 100644 services/auth/src/imageValidation.test.ts create mode 100644 services/auth/src/imageValidation.ts create mode 100644 services/protocols/cmd/lambdamail-protocols/bimi_store.go create mode 100644 services/protocols/internal/domain/bimi/svg.go create mode 100644 services/protocols/internal/domain/bimi/svg_test.go create mode 100644 services/protocols/internal/infrastructure/postgres/bimi_repository.go create mode 100644 services/protocols/internal/presentation/http/bimi_api.go create mode 100644 services/webmail/src/components/Avatar.tsx create mode 100644 services/webmail/src/components/AvatarEditor.tsx create mode 100644 services/webmail/src/lib/avatarCrop.test.ts create mode 100644 services/webmail/src/lib/avatarCrop.ts diff --git a/README.md b/README.md index c93d715..f51bd3d 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ 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 | | **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 | +| **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 | | **Console** | Server-side paging and filtering, user editing (role, quota, language, password, second factor), per-user aliases, and a diagnostics page at `/admin/tests` | @@ -183,6 +184,32 @@ reads as a certificate problem and is usually a permissions problem. `/admin/security` names the issuer and says outright when the certificate is 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 ``, 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 Off by default, and worth understanding before turning on: a TLSA record that @@ -228,7 +255,7 @@ Stated plainly, because a mail server that half works loses mail: - **Read-only console panels.** The guided domain onboarding checklist reports on state rather than driving it. -- **Not implemented.** JMAP, BIMI, CalDAV/CardDAV, multi-node. Backups are +- **Not implemented.** JMAP, CalDAV/CardDAV, multi-node. Backups are documented but not automated. - **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 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("<!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" }); + }); +}); 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<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 /** 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("<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 () => { 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<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> { const raw = await readBody(req); try { @@ -238,6 +269,95 @@ 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 async function login(req: IncomingMessage, res: ServerResponse, surface: "user" | "admin"): Promise<void> { diff --git a/services/protocols/cmd/lambdamail-protocols/bimi_store.go b/services/protocols/cmd/lambdamail-protocols/bimi_store.go new file mode 100644 index 0000000..aec8ec8 --- /dev/null +++ b/services/protocols/cmd/lambdamail-protocols/bimi_store.go @@ -0,0 +1,30 @@ +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) +} diff --git a/services/protocols/cmd/lambdamail-protocols/main.go b/services/protocols/cmd/lambdamail-protocols/main.go index 764a81a..cbec76e 100644 --- a/services/protocols/cmd/lambdamail-protocols/main.go +++ b/services/protocols/cmd/lambdamail-protocols/main.go @@ -230,11 +230,15 @@ func run(cfg config) { // list stops disagreeing with the page next to it. router.SetDnsStatusWriter(postgres.NewDomainRepository(pool)) router.SetAdminDnsAPI( - &dnsSpecSource{cfg: cfg, dkim: dkimRepo}, + &dnsSpecSource{cfg: cfg, dkim: dkimRepo, bimi: postgres.NewBimiRepository(pool)}, netdns.NewPublicVerifier(), 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 // service used to return, which reported a healthy certificate whatever // the process actually held. @@ -698,6 +702,7 @@ func runHealthcheck() { type dnsSpecSource struct { cfg config dkim *postgres.DkimRepository + bimi *postgres.BimiRepository } func (s *dnsSpecSource) ExpectedRecords(ctx context.Context, domain string) ([]entity.DnsRecord, error) { @@ -710,6 +715,15 @@ func (s *dnsSpecSource) ExpectedRecords(ctx context.Context, domain string) ([]e 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 // effect of somebody opening a screen. if s.dkim != nil { diff --git a/services/protocols/internal/domain/bimi/svg.go b/services/protocols/internal/domain/bimi/svg.go new file mode 100644 index 0000000..bc82dc4 --- /dev/null +++ b/services/protocols/internal/domain/bimi/svg.go @@ -0,0 +1,153 @@ +// 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[^>]*>(.*?)`) +) + +// 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 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 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 element is required") + case len(titles) > 1: + add("title", fmt.Sprintf("found %d 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 +} diff --git a/services/protocols/internal/domain/bimi/svg_test.go b/services/protocols/internal/domain/bimi/svg_test.go new file mode 100644 index 0000000..48a5543 --- /dev/null +++ b/services/protocols/internal/domain/bimi/svg_test.go @@ -0,0 +1,152 @@ +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 + + +` + +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, "LambdaMail", "", 1) + if !contains(rulesOf(Validate([]byte(none))), "title") { + t.Error("a logo with no title was accepted") + } + + two := strings.Replace(goodLogo, "LambdaMail", "AB", 1) + if !contains(rulesOf(Validate([]byte(two))), "title") { + t.Error("a logo with two titles was accepted") + } + + empty := strings.Replace(goodLogo, "LambdaMail", " ", 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": {``, "forbiddenElement"}, + "link": {`x`, "forbiddenElement"}, + "raster image": {``, "forbiddenElement"}, + "foreign object": {`x`, "forbiddenElement"}, + "animation": {``, "forbiddenElement"}, + "event handler": {``, "script"}, + "external href": {``, "externalReference"}, + "data uri": {``, "externalReference"}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + logo := strings.Replace(goodLogo, "", tc.insert+"", 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(""))), "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, "", "", 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 := `` + 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) + } + } +} diff --git a/services/protocols/internal/domain/entity/dns_record.go b/services/protocols/internal/domain/entity/dns_record.go index 808793a..ae69287 100644 --- a/services/protocols/internal/domain/entity/dns_record.go +++ b/services/protocols/internal/domain/entity/dns_record.go @@ -35,6 +35,15 @@ type DnsRecordSpec struct { // this domain. Without it every message the relay forwards fails SPF // (PLAN.md section 10.4). 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 @@ -196,6 +205,21 @@ 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 if ownsMailHost && serverIPv6 != "" { records = append(records, DnsRecord{ diff --git a/services/protocols/internal/domain/entity/dns_record_test.go b/services/protocols/internal/domain/entity/dns_record_test.go index 9d52377..570eb81 100644 --- a/services/protocols/internal/domain/entity/dns_record_test.go +++ b/services/protocols/internal/domain/entity/dns_record_test.go @@ -195,3 +195,72 @@ func TestBuildDnsRecordSpecs_KeepsMailHostAddressesInsideTheDomain(t *testing.T) 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") +} diff --git a/services/protocols/internal/infrastructure/postgres/bimi_repository.go b/services/protocols/internal/infrastructure/postgres/bimi_repository.go new file mode 100644 index 0000000..8e0eefe --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/bimi_repository.go @@ -0,0 +1,79 @@ +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 +} diff --git a/services/protocols/internal/presentation/http/bimi_api.go b/services/protocols/internal/presentation/http/bimi_api.go new file mode 100644 index 0000000..2f0547c --- /dev/null +++ b/services/protocols/internal/presentation/http/bimi_api.go @@ -0,0 +1,217 @@ +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) +} diff --git a/services/protocols/internal/presentation/http/router.go b/services/protocols/internal/presentation/http/router.go index eb72a48..47d266b 100644 --- a/services/protocols/internal/presentation/http/router.go +++ b/services/protocols/internal/presentation/http/router.go @@ -40,6 +40,7 @@ type Router struct { // dnsReconciler is held here so it survives being set before dns exists. dnsReconciler DomainReconciler dnsStatus DnsStatusWriter + bimi *bimiAPI // degradedFunc reports a condition that leaves the service running but // not fit for production, the clearest case being a self-signed // certificate standing in for one Traefik never issued @@ -78,6 +79,8 @@ func (r *Router) registerRoutes() { r.mux.HandleFunc("/health", r.handleHealth) r.mux.HandleFunc("/.well-known/mta-sts.txt", r.handleMtaSts) 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("/.well-known/autoconfig/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig) r.mux.HandleFunc("/autodiscover/autodiscover.xml", r.handleOutlookAutodiscover) diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index 7e228fb..40da3df 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -113,7 +113,16 @@ "passwordResetWarning": "Signs out every session this user holds, including one open right now.", "removeTotp": "Remove second factor", "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": { "adminLoginTitle": "Admin console", @@ -428,5 +437,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..9acb663 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -113,7 +113,16 @@ "passwordResetWarning": "Cierra todas las sesiones de este usuario, incluida una abierta ahora.", "removeTotp": "Quitar segundo factor", "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": { "adminLoginTitle": "Consola de administración", @@ -428,5 +437,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..30e59b9 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -113,7 +113,16 @@ "passwordResetWarning": "Termina todas as sessoes deste utilizador, incluindo a que esteja aberta agora.", "removeTotp": "Remover segundo factor", "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": { "adminLoginTitle": "Console de administração", @@ -428,5 +437,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/(admin)/admin/domains/page.tsx b/services/webmail/src/app/(admin)/admin/domains/page.tsx index d4893f2..37fd678 100644 --- a/services/webmail/src/app/(admin)/admin/domains/page.tsx +++ b/services/webmail/src/app/(admin)/admin/domains/page.tsx @@ -78,6 +78,14 @@ export default function AdminDomainsPage() { const [error, setError] = useState(null); 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([]); const [verification, setVerification] = useState(null); // 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. @@ -118,6 +126,73 @@ export default function AdminDomainsPage() { // 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 // 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 () => { if (!selected) return; setReconciling(true); @@ -446,6 +521,70 @@ export default function AdminDomainsPage() { )} + {/* 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 && ( +
+

+ {t("admin.bimiTitle", { domain: selected.name })} +

+

{t("admin.bimiExplainer")}

+ +
+ + {bimi?.configured ? t("admin.bimiConfigured") : t("admin.bimiNotConfigured")} + + {bimi?.configured && !bimi.vmc_url && ( + {t("admin.bimiNoVmc")} + )} +
+ +
+ +