diff --git a/README.md b/README.md
index f51bd3d..c93d715 100644
--- a/README.md
+++ b/README.md
@@ -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 |
| **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` |
@@ -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
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:///.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
@@ -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
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.
- **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
deleted file mode 100644
index 19fdd87..0000000
--- a/services/auth/src/imageValidation.test.ts
+++ /dev/null
@@ -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(""))).toBeNull();
- expect(sniffImage(Buffer.from("GIF89a"))).toBeNull();
- expect(sniffImage(Buffer.from("%PDF-1.7"))).toBeNull();
- });
-
- // SVG is an executable document, and it would be served back from our own
- // origin - so it is refused even though it is an image.
- it("refuses SVG", () => {
- expect(sniffImage(Buffer.from(''))).toBeNull();
- expect(sniffImage(Buffer.from(''))).toBeNull();
- });
-
- it("does not read past the end of a short buffer", () => {
- expect(() => sniffImage(Buffer.from([0x89]))).not.toThrow();
- expect(sniffImage(Buffer.alloc(0))).toBeNull();
- // The first four bytes of a WEBP without the rest is not a WEBP.
- expect(sniffImage(Buffer.from("RIFF"))).toBeNull();
- });
-});
-
-describe("checkAvatar", () => {
- it("accepts a real image", () => {
- const res = checkAvatar(png());
- expect(res.ok).toBe(true);
- expect(res.contentType).toBe("image/png");
- });
-
- it("refuses an empty upload", () => {
- expect(checkAvatar(Buffer.alloc(0))).toMatchObject({ ok: false, reason: "EMPTY" });
- });
-
- // On size before contents: an enormous upload should not be sniffed first.
- it("refuses one that is too big", () => {
- const huge = Buffer.concat([png(), Buffer.alloc(MAX_AVATAR_BYTES)]);
- expect(checkAvatar(huge)).toMatchObject({ ok: false, reason: "TOO_LARGE" });
- });
-
- it("refuses a file that is not an image", () => {
- expect(checkAvatar(Buffer.from("just text"))).toMatchObject({ ok: false, reason: "UNSUPPORTED_TYPE" });
- });
-});
diff --git a/services/auth/src/imageValidation.ts b/services/auth/src/imageValidation.ts
deleted file mode 100644
index c965fca..0000000
--- a/services/auth/src/imageValidation.ts
+++ /dev/null
@@ -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 };
-}
diff --git a/services/auth/src/repository.ts b/services/auth/src/repository.ts
index 7e7cf80..499f631 100644
--- a/services/auth/src/repository.ts
+++ b/services/auth/src/repository.ts
@@ -1,4 +1,3 @@
-import crypto from "node:crypto";
import { query, queryOne } from "./db.js";
import { verifyPassword, hashPassword, encryptSecret, decryptSecret } from "./crypto.js";
import { verifyTotpCode } from "./totp.js";
@@ -717,65 +716,6 @@ export async function adminRemoveTotp(scope: AdminScope, mailboxId: string): Pro
return true;
}
-// ------------------------------------------------------------- avatars
-
-export interface StoredAvatar {
- contentType: string;
- bytes: Buffer;
- etag: string;
-}
-
-/** Replaces a mailbox's profile photo. */
-export async function saveAvatar(
- mailboxId: string,
- contentType: string,
- bytes: Buffer,
-): Promise {
- // The digest is of the image itself, so an unchanged photo keeps its tag and
- // the browsers that already hold it are not made to fetch it again.
- const etag = crypto.createHash("sha256").update(bytes).digest("hex");
- await query(
- `INSERT INTO mailbox_avatars (mailbox_id, content_type, bytes, etag, updated_at)
- VALUES ($1, $2, $3, $4, NOW())
- ON CONFLICT (mailbox_id) DO UPDATE
- SET content_type = EXCLUDED.content_type,
- bytes = EXCLUDED.bytes,
- etag = EXCLUDED.etag,
- updated_at = NOW()`,
- [mailboxId, contentType, bytes, etag],
- );
- return etag;
-}
-
-export async function loadAvatar(mailboxId: string): Promise {
- const row = await queryOne<{ content_type: string; bytes: Buffer; etag: string }>(
- `SELECT content_type, bytes, etag FROM mailbox_avatars WHERE mailbox_id = $1`,
- [mailboxId],
- );
- if (!row) return null;
- return { contentType: row.content_type, bytes: row.bytes, etag: row.etag };
-}
-
-/** Looks up an avatar by address, for showing a sender in the message list. */
-export async function loadAvatarByAddress(address: string): Promise {
- const row = await queryOne<{ content_type: string; bytes: Buffer; etag: string }>(
- `SELECT a.content_type, a.bytes, a.etag
- FROM mailbox_avatars a JOIN mailboxes m ON m.id = a.mailbox_id
- WHERE lower(m.email_address) = lower($1)`,
- [address],
- );
- if (!row) return null;
- return { contentType: row.content_type, bytes: row.bytes, etag: row.etag };
-}
-
-export async function deleteAvatar(mailboxId: string): Promise {
- const rows = await query<{ mailbox_id: string }>(
- `DELETE FROM mailbox_avatars WHERE mailbox_id = $1 RETURNING mailbox_id`,
- [mailboxId],
- );
- return rows.length === 1;
-}
-
// ------------------------------------------------------------- audit log
/** Records an administrative action. PLAN.md section 14.3 requires every
diff --git a/services/auth/src/router.test.ts b/services/auth/src/router.test.ts
index b67dfba..e5eb972 100644
--- a/services/auth/src/router.test.ts
+++ b/services/auth/src/router.test.ts
@@ -927,64 +927,6 @@ describeDb("auth API against a real database", () => {
expect(still).toHaveLength(1);
});
- it("stores a profile photo and serves it back with a cache tag", async () => {
- const token = (await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD }))
- .body.token as unknown as string;
-
- const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]);
- const put = await fetch(`${base}/api/v1/user/avatar`, {
- method: "PUT",
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "image/png" },
- body: png,
- });
- expect(put.status).toBe(200);
-
- const got = await fetch(`${base}/api/v1/avatar?address=${encodeURIComponent(userEmail())}`, {
- headers: { Authorization: `Bearer ${token}` },
- });
- expect(got.status).toBe(200);
- expect(got.headers.get("content-type")).toBe("image/png");
- expect(Buffer.from(await got.arrayBuffer())).toEqual(png);
-
- // A photo appears beside every message from that person; without the
- // conditional answer it would be re-sent on every one of them.
- const etag = got.headers.get("etag")!;
- const again = await fetch(`${base}/api/v1/avatar?address=${encodeURIComponent(userEmail())}`, {
- headers: { Authorization: `Bearer ${token}`, "If-None-Match": etag },
- });
- expect(again.status).toBe(304);
- }, 60000);
-
- // The stored type is decided from the bytes. A caller that labels an HTML
- // document as a PNG must not get it stored and served from our own origin.
- it("refuses an upload that is not really an image", async () => {
- const token = (await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD }))
- .body.token as unknown as string;
-
- const res = await fetch(`${base}/api/v1/user/avatar`, {
- method: "PUT",
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "image/png" },
- body: Buffer.from(""),
- });
- expect(res.status).toBe(400);
- }, 60000);
-
- // An open avatar endpoint would answer "this address exists" to anyone
- // who asked, for every address they cared to try.
- it("does not serve photos to a caller with no session", async () => {
- const res = await fetch(`${base}/api/v1/avatar?address=${encodeURIComponent(userEmail())}`);
- expect(res.status).toBe(401);
- });
-
- it("answers 404 for an address with no photo", async () => {
- const token = (await post("/api/v1/auth/user/login", { email: userEmail(), password: PASSWORD }))
- .body.token as unknown as string;
- const res = await fetch(`${base}/api/v1/avatar?address=nobody@nowhere.invalid`, {
- headers: { Authorization: `Bearer ${token}` },
- });
- expect(res.status).toBe(404);
- }, 60000);
-
it("answers 404 for a user that does not exist rather than leaking the difference", async () => {
const res = await get(`/api/v1/admin/mailboxes/44444444-4444-4444-4444-444444444444/aliases`, admin);
expect(res.status).toBe(404);
diff --git a/services/auth/src/router.ts b/services/auth/src/router.ts
index 2c5f71b..1515da2 100644
--- a/services/auth/src/router.ts
+++ b/services/auth/src/router.ts
@@ -12,7 +12,6 @@ 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;
@@ -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/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!);
@@ -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 {
- return new Promise((resolve, reject) => {
- const chunks: Buffer[] = [];
- let size = 0;
- req.on("data", (chunk: Buffer) => {
- size += chunk.length;
- if (size > maxBytes) {
- reject(new Error("PAYLOAD_TOO_LARGE"));
- req.destroy();
- return;
- }
- chunks.push(chunk);
- });
- req.on("end", () => resolve(Buffer.concat(chunks)));
- req.on("error", reject);
- });
-}
-
async function parseJsonBody(req: IncomingMessage): Promise | null> {
const raw = await readBody(req);
try {
@@ -269,95 +238,6 @@ async function parseJsonBody(req: IncomingMessage): Promise {
- let body: Buffer;
- try {
- body = await readBinaryBody(req, MAX_AVATAR_BYTES + 1);
- } catch {
- return sendJson(res, 413, { error: "TOO_LARGE", message: "The image is too large" });
- }
-
- const check = checkAvatar(body);
- if (!check.ok) {
- const status = check.reason === "TOO_LARGE" ? 413 : 400;
- return sendJson(res, status, {
- error: check.reason,
- message:
- check.reason === "TOO_LARGE"
- ? "The image is too large"
- : "The upload is not a PNG, JPEG or WebP image",
- });
- }
-
- const etag = await repo.saveAvatar(session.sub, check.contentType!, body);
- await repo.recordAudit(session.sub, clientIp(req), "user.avatar_set", "mailbox", session.sub, {
- content_type: check.contentType,
- bytes: body.length,
- });
- sendJson(res, 200, { ok: true, etag });
-}
-
-/**
- * Serves a mailbox's photo, by address.
- *
- * Behind a session on purpose, and checked here rather than by the prefix
- * guard, which only covers /user/ and /admin/. An open endpoint keyed by
- * address answers "this mailbox exists" to anyone willing to ask, for every
- * address they care to try.
- */
-async function getAvatar(
- req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload | null, address: string,
-): Promise {
- if (!session) {
- return sendJson(res, 401, { error: "UNAUTHORIZED", message: "A signed-in session is required" });
- }
-
- const avatar = address ? await repo.loadAvatarByAddress(address) : null;
- if (!avatar) {
- // 404 rather than a placeholder: the caller decides what to draw instead,
- // and initials are drawn client-side already.
- return sendJson(res, 404, { error: "NOT_FOUND", message: "No photo" });
- }
-
- // A photo appears beside every message from that person, so re-sending it
- // each time is the difference between one request and a hundred.
- if (req.headers["if-none-match"] === `"${avatar.etag}"`) {
- res.statusCode = 304;
- res.end();
- return;
- }
-
- res.statusCode = 200;
- res.setHeader("Content-Type", avatar.contentType);
- res.setHeader("ETag", `"${avatar.etag}"`);
- // Private: this is served to a signed-in reader, and a shared cache must not
- // hand one person's photo to the next request that comes along.
- res.setHeader("Cache-Control", "private, max-age=300");
- // The bytes were sniffed on the way in, but a browser that ignores the type
- // and sniffs for itself must not be allowed to find something else.
- res.setHeader("X-Content-Type-Options", "nosniff");
- res.end(avatar.bytes);
-}
-
-async function userDeleteAvatar(
- req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload,
-): Promise {
- await repo.deleteAvatar(session.sub);
- await repo.recordAudit(session.sub, clientIp(req), "user.avatar_cleared", "mailbox", session.sub, {});
- sendJson(res, 200, { ok: true });
-}
-
// ------------------------------------------------------------------- login
async function login(req: IncomingMessage, res: ServerResponse, surface: "user" | "admin"): Promise {
diff --git a/services/protocols/cmd/lambdamail-protocols/bimi_store.go b/services/protocols/cmd/lambdamail-protocols/bimi_store.go
deleted file mode 100644
index aec8ec8..0000000
--- a/services/protocols/cmd/lambdamail-protocols/bimi_store.go
+++ /dev/null
@@ -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)
-}
diff --git a/services/protocols/cmd/lambdamail-protocols/main.go b/services/protocols/cmd/lambdamail-protocols/main.go
index cbec76e..764a81a 100644
--- a/services/protocols/cmd/lambdamail-protocols/main.go
+++ b/services/protocols/cmd/lambdamail-protocols/main.go
@@ -230,15 +230,11 @@ 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, bimi: postgres.NewBimiRepository(pool)},
+ &dnsSpecSource{cfg: cfg, dkim: dkimRepo},
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.
@@ -702,7 +698,6 @@ 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) {
@@ -715,15 +710,6 @@ 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
deleted file mode 100644
index bc82dc4..0000000
--- a/services/protocols/internal/domain/bimi/svg.go
+++ /dev/null
@@ -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)", 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 ae69287..808793a 100644
--- a/services/protocols/internal/domain/entity/dns_record.go
+++ b/services/protocols/internal/domain/entity/dns_record.go
@@ -35,15 +35,6 @@ 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
@@ -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
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 570eb81..9d52377 100644
--- a/services/protocols/internal/domain/entity/dns_record_test.go
+++ b/services/protocols/internal/domain/entity/dns_record_test.go
@@ -195,72 +195,3 @@ 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
deleted file mode 100644
index 8e0eefe..0000000
--- a/services/protocols/internal/infrastructure/postgres/bimi_repository.go
+++ /dev/null
@@ -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
-}
diff --git a/services/protocols/internal/presentation/http/bimi_api.go b/services/protocols/internal/presentation/http/bimi_api.go
deleted file mode 100644
index 2f0547c..0000000
--- a/services/protocols/internal/presentation/http/bimi_api.go
+++ /dev/null
@@ -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)
-}
diff --git a/services/protocols/internal/presentation/http/router.go b/services/protocols/internal/presentation/http/router.go
index 47d266b..eb72a48 100644
--- a/services/protocols/internal/presentation/http/router.go
+++ b/services/protocols/internal/presentation/http/router.go
@@ -40,7 +40,6 @@ 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
@@ -79,8 +78,6 @@ 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 40da3df..7e228fb 100644
--- a/services/webmail/messages/en.json
+++ b/services/webmail/messages/en.json
@@ -113,16 +113,7 @@
"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)",
- "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."
+ "tlsaDigest": "Digest for the TLSA record (DANE 3 1 1)"
},
"auth": {
"adminLoginTitle": "Admin console",
@@ -437,15 +428,5 @@
"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 9acb663..8f05d7f 100644
--- a/services/webmail/messages/es.json
+++ b/services/webmail/messages/es.json
@@ -113,16 +113,7 @@
"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)",
- "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."
+ "tlsaDigest": "Digest para el registro TLSA (DANE 3 1 1)"
},
"auth": {
"adminLoginTitle": "Consola de administración",
@@ -437,15 +428,5 @@
"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 30e59b9..7bdcf5b 100644
--- a/services/webmail/messages/pt-BR.json
+++ b/services/webmail/messages/pt-BR.json
@@ -113,16 +113,7 @@
"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)",
- "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."
+ "tlsaDigest": "Digest para o registo TLSA (DANE 3 1 1)"
},
"auth": {
"adminLoginTitle": "Console de administração",
@@ -437,15 +428,5 @@
"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 37fd678..d4893f2 100644
--- a/services/webmail/src/app/(admin)/admin/domains/page.tsx
+++ b/services/webmail/src/app/(admin)/admin/domains/page.tsx
@@ -78,14 +78,6 @@ 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.
@@ -126,73 +118,6 @@ 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);
@@ -521,70 +446,6 @@ 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 && (
-
-
{selected.from}
diff --git a/services/webmail/src/app/(user)/user/settings/page.tsx b/services/webmail/src/app/(user)/user/settings/page.tsx
index a7e2085..1999441 100644
--- a/services/webmail/src/app/(user)/user/settings/page.tsx
+++ b/services/webmail/src/app/(user)/user/settings/page.tsx
@@ -15,7 +15,6 @@ import {
Plus,
Lock,
Languages,
- UserRound,
} from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Badge } from "../../../../components/ui/Badge";
@@ -25,8 +24,6 @@ import { LanguageSwitcher } from "../../../../i18n/LanguageSwitcher";
import { signatureToHtml, sanitizeSignature } from "../../../../lib/signature";
import { buildSieve, parseSieve, type Rule, type RuleField, type RuleCondition, type RuleAction } from "../../../../lib/rules";
import { useFolders } from "../../../../lib/useFolders";
-import { AvatarEditor } from "../../../../components/AvatarEditor";
-import { useAccount } from "../../../../lib/useAccount";
import { moveTargets } from "../../../../lib/mailCounts";
interface SieveRule {
@@ -54,20 +51,11 @@ interface WebSession {
current: boolean;
}
-type SettingsTab = "profile" | "security" | "filters" | "vacation" | "signature" | "language";
+type SettingsTab = "security" | "filters" | "vacation" | "signature" | "language";
export default function UserSettingsPage() {
const t = useTranslations();
- const [activeTab, setActiveTab] = useState("profile");
- // Bumped after a save so the browser refetches instead of showing the photo
- // it already holds under the same URL.
- const [avatarVersion, setAvatarVersion] = useState(0);
- const account = useAccount("user");
- // Null until the account loads, so the editor is not told "no photo" before
- // it is known whether there is one.
- const avatarSrc = account?.email
- ? `/api/v1/avatar?address=${encodeURIComponent(account.email)}&v=${avatarVersion}`
- : null;
+ const [activeTab, setActiveTab] = useState("security");
const [mfaEnabled, setMfaEnabled] = useState(false);
const [qrSecret, setQrSecret] = useState(null);
@@ -297,7 +285,6 @@ export default function UserSettingsPage() {
separated by everything else. The same panels now sit behind a tab strip,
in the order the redesign groups them. */
const tabs = [
- { id: "profile" as const, label: t("avatar.title"), icon: UserRound },
{ id: "security" as const, label: t("settings.security"), icon: Shield },
{ id: "filters" as const, label: t("settings.sieveTitle"), icon: Filter },
{ id: "vacation" as const, label: t("settings.vacationTitle"), icon: Palmtree },
@@ -349,21 +336,6 @@ export default function UserSettingsPage() {
)}
- {activeTab === "profile" && (
-
-
{t("avatar.title")}
-
{t("avatar.explainer")}
- {
- // The query string changes so the browser fetches the new
- // photo instead of the one it already has under this URL.
- setAvatarVersion((v) => v + 1);
- }}
- />
-
- )}
-
{activeTab === "security" && (
{/* 2FA TOTP SECTION */}
diff --git a/services/webmail/src/components/Avatar.tsx b/services/webmail/src/components/Avatar.tsx
deleted file mode 100644
index f4b4590..0000000
--- a/services/webmail/src/components/Avatar.tsx
+++ /dev/null
@@ -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 (
-
- {!loaded && {initials ?? initialsFor(address)}}
- {src && !failed && (
- // eslint-disable-next-line @next/next/no-img-element
- setLoaded(true)}
- onError={() => setFailed(true)}
- className={`absolute inset-0 h-full w-full object-cover ${loaded ? "" : "opacity-0"}`}
- />
- )}
-
- );
-}
diff --git a/services/webmail/src/components/AvatarEditor.tsx b/services/webmail/src/components/AvatarEditor.tsx
deleted file mode 100644
index 84cb421..0000000
--- a/services/webmail/src/components/AvatarEditor.tsx
+++ /dev/null
@@ -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(null);
- const [natural, setNatural] = useState({ width: 0, height: 0 });
- const [transform, setTransform] = useState({ scale: 1, offsetX: 0, offsetY: 0 });
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState(null);
-
- const canvasRef = useRef(null);
- const fileRef = useRef(null);
- const dragRef = useRef<{ x: number; y: number; from: Transform } | null>(null);
-
- /** Draws the current transform into the preview. */
- const paint = useCallback(() => {
- const canvas = canvasRef.current;
- if (!canvas || !image) return;
- const ctx = canvas.getContext("2d");
- if (!ctx) return;
-
- ctx.clearRect(0, 0, VIEWPORT, VIEWPORT);
- const r = sourceRect(natural, VIEWPORT, transform);
- ctx.drawImage(image, r.x, r.y, r.width, r.height, 0, 0, VIEWPORT, VIEWPORT);
- }, [image, natural, transform]);
-
- useEffect(() => {
- paint();
- }, [paint]);
-
- const openFile = (file: File) => {
- setError(null);
- const url = URL.createObjectURL(file);
- const img = new Image();
- img.onload = () => {
- const size = { width: img.naturalWidth, height: img.naturalHeight };
- setImage(img);
- setNatural(size);
- setTransform(initialTransform(size, VIEWPORT));
- // The bitmap is decoded and held; the object URL has done its job and
- // would otherwise keep the file alive for the life of the page.
- URL.revokeObjectURL(url);
- };
- img.onerror = () => {
- URL.revokeObjectURL(url);
- setError(t("avatar.notAnImage"));
- };
- img.src = url;
- };
-
- const onPointerDown = (e: React.PointerEvent) => {
- if (!image) return;
- (e.target as HTMLElement).setPointerCapture(e.pointerId);
- dragRef.current = { x: e.clientX, y: e.clientY, from: transform };
- };
-
- const onPointerMove = (e: React.PointerEvent) => {
- const drag = dragRef.current;
- if (!drag || !image) return;
- setTransform(
- clampTransform(natural, VIEWPORT, {
- scale: drag.from.scale,
- offsetX: drag.from.offsetX + (e.clientX - drag.x),
- offsetY: drag.from.offsetY + (e.clientY - drag.y),
- }),
- );
- };
-
- const endDrag = () => {
- dragRef.current = null;
- };
-
- const zoomTo = (scale: number) => {
- if (!image) return;
- setTransform((prev) => clampTransform(natural, VIEWPORT, { ...prev, scale }));
- };
-
- const save = async () => {
- if (!image) return;
- setBusy(true);
- setError(null);
- try {
- // Exported from the source pixels rather than from the preview, so the
- // stored photo is as sharp as the original allows instead of being
- // limited to the size of the editor on screen.
- const out = document.createElement("canvas");
- out.width = EXPORT_SIZE;
- out.height = EXPORT_SIZE;
- const ctx = out.getContext("2d");
- if (!ctx) throw new Error();
- const r = sourceRect(natural, VIEWPORT, transform);
- ctx.drawImage(image, r.x, r.y, r.width, r.height, 0, 0, EXPORT_SIZE, EXPORT_SIZE);
-
- const blob = await new Promise((resolve) =>
- // The frame is always fully covered, so there is no transparency to
- // preserve and JPEG is a fraction of the size.
- out.toBlob((b) => resolve(b), "image/jpeg", 0.9),
- );
- if (!blob) throw new Error();
-
- const res = await fetch("/api/v1/user/avatar", {
- method: "PUT",
- headers: { "Content-Type": "image/jpeg" },
- body: blob,
- });
- if (!res.ok) {
- const data = await res.json().catch(() => ({}));
- throw new Error(data.message);
- }
- setImage(null);
- onSaved();
- } catch (err) {
- setError(err instanceof Error && err.message ? err.message : t("errors.serverError"));
- } finally {
- setBusy(false);
- }
- };
-
- const remove = async () => {
- setBusy(true);
- setError(null);
- try {
- const res = await fetch("/api/v1/user/avatar", { method: "DELETE" });
- if (!res.ok) throw new Error();
- onSaved();
- } catch {
- setError(t("errors.serverError"));
- } finally {
- setBusy(false);
- }
- };
-
- const range = scaleRange(natural, VIEWPORT);
-
- return (
-
- {!image ? (
-
- {/* What is stored today, shown as it will appear elsewhere. */}
-
- {src ? (
- // eslint-disable-next-line @next/next/no-img-element
-
- ) : (
- {t("avatar.none")}
- )}
-
-
-
- )}
-
- {
- const file = e.target.files?.[0];
- if (file) openFile(file);
- // Cleared so choosing the same file twice in a row still fires.
- e.target.value = "";
- }}
- />
-
- );
-}
diff --git a/services/webmail/src/lib/avatarCrop.test.ts b/services/webmail/src/lib/avatarCrop.test.ts
deleted file mode 100644
index f4b9edd..0000000
--- a/services/webmail/src/lib/avatarCrop.test.ts
+++ /dev/null
@@ -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);
- });
-});
diff --git a/services/webmail/src/lib/avatarCrop.ts b/services/webmail/src/lib/avatarCrop.ts
deleted file mode 100644
index b5268ef..0000000
--- a/services/webmail/src/lib/avatarCrop.ts
+++ /dev/null
@@ -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,
- };
-}