feat(bimi): logotipo do dominio validado, alojado e publicado no DNS

A foto de perfil nao viaja: nem o Gmail nem o Outlook leem uma imagem por
utilizador do servidor de quem envia, e nao existe protocolo que o permita. O
avatar que esses mostram vem dos contactos de quem recebe ou da conta Google
ou Microsoft do remetente.

O que eles leem e BIMI, que e por dominio. Fica implementado: validacao,
alojamento e o registo.

O validador reporta todas as violacoes de uma vez, e nao a primeira. Um
receptor que recusa a marca nao explica nada -- simplesmente nao desenha -- por
isso descobrir as regras uma viagem de cada vez seria a pior maneira possivel
de aprender este formato. Sao verificados o baseProfile e a versao que os
receptores procuram, o viewBox quadrado (uma marca nao quadrada e cortada de
forma diferente por cada cliente), exactamente um title, e a ausencia de tudo
o que seja activo ou externo: script, handlers de evento, links, imagens
rasterizadas, animacoes e referencias para fora do documento.

O registo so e publicado quando ha mesmo uma marca guardada. Um registo que
aponta para um logotipo inexistente e pior do que nenhum, porque o receptor
para de procurar assim que encontra um.

O logotipo e servido aberto, porque quem o vai buscar sao os fornecedores de
correio a avaliar uma mensagem e esses nunca terao sessao aqui.

Fica escrito no README que o Gmail e o Outlook so desenham a marca com um VMC,
e o painel avisa quando nao ha nenhum configurado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-09-07 08:12:11 -03:00
1 parent fcfee6a3f9
commit 6a602ab88f
16 files changed
+955 -5

No files matched your search

+28 -1
View File
@@ -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 `<title>`, nothing
active or external. `/admin/domains` validates an upload against every rule
at once and names the ones it fails, because a receiver that rejects a mark
simply shows nothing;
- a **Verified Mark Certificate** for Gmail and Outlook to render it. It is
issued against a registered trademark, costs on the order of US$1000 a year,
and without it the record is still valid — some clients use it, those two do
not.
The logo is served from `https://<mail host>/.well-known/bimi/default.svg` and
the record is published with the rest of the zone.
### DANE
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
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS domain_bimi;
+15
View File
@@ -0,0 +1,15 @@
-- The BIMI logo a domain publishes, and the certificate that vouches for it.
--
-- One per domain, not per mailbox: BIMI is a brand indicator, and every
-- message from the domain carries the same mark.
--
-- The VMC is a URL rather than a file because it is issued by an external
-- authority and renewed on its own schedule; Gmail and Outlook only render the
-- logo when the record points at one.
CREATE TABLE IF NOT EXISTS domain_bimi (
domain_id UUID PRIMARY KEY REFERENCES domains(id) ON DELETE CASCADE,
svg BYTEA NOT NULL,
etag CHAR(64) NOT NULL,
vmc_url TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -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)
}
@@ -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 {
@@ -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[^>]*>(.*?)</title>`)
)
// Validate reports every reason the logo would not be accepted.
//
// All of them, not the first: an operator fixing one rule at a time against a
// receiver that simply shows nothing is the worst way to learn these.
func Validate(payload []byte) []Violation {
var out []Violation
add := func(rule, detail string) { out = append(out, Violation{Rule: rule, Detail: detail}) }
if len(payload) == 0 {
return []Violation{{Rule: "empty", Detail: "the file is empty"}}
}
if len(payload) > MaxLogoBytes {
add("size", fmt.Sprintf("%d bytes; the limit is %d", len(payload), MaxLogoBytes))
}
// Parseable at all: a receiver will not repair a broken document.
if err := xml.Unmarshal(payload, new(struct {
XMLName xml.Name
})); err != nil {
return append(out, Violation{Rule: "xml", Detail: "the file is not well-formed XML"})
}
text := string(payload)
open := svgOpenRe.FindString(text)
if open == "" {
return append(out, Violation{Rule: "svg", Detail: "no <svg> root element"})
}
attrs := map[string]string{}
for _, m := range attrRe.FindAllStringSubmatch(open, -1) {
if m[1] != "" {
attrs[strings.ToLower(m[1])] = m[2]
} else {
attrs[strings.ToLower(m[3])] = m[4]
}
}
// The profile a receiver looks for. Without it the document is a plain
// SVG and is ignored.
if got := attrs["baseprofile"]; !strings.EqualFold(got, "tiny-ps") {
add("baseProfile", fmt.Sprintf("must be \"tiny-ps\", found %q", got))
}
if got := attrs["version"]; got != "1.2" {
add("version", fmt.Sprintf("must be \"1.2\", found %q", got))
}
// A non-square mark is cropped or letterboxed differently by every client,
// so the specification requires the viewBox itself be square.
if err := checkSquareViewBox(attrs["viewbox"]); err != nil {
add("viewBox", err.Error())
}
// x and y are meaningful only when the mark is placed in another document,
// and the specification forbids them on the root.
for _, forbidden := range []string{"x", "y"} {
if _, present := attrs[forbidden]; present {
add("rootAttributes", fmt.Sprintf("the root <svg> must not carry %q", forbidden))
}
}
// Exactly one title, and it is what a screen reader announces.
titles := titleElementRe.FindAllStringSubmatch(text, -1)
switch {
case len(titles) == 0:
add("title", "a <title> element is required")
case len(titles) > 1:
add("title", fmt.Sprintf("found %d <title> elements; there must be exactly one", len(titles)))
case strings.TrimSpace(titles[0][1]) == "":
add("title", "the <title> element is empty")
}
lower := strings.ToLower(text)
for _, name := range forbiddenElements {
if strings.Contains(lower, "<"+strings.ToLower(name)) {
add("forbiddenElement", fmt.Sprintf("<%s> is not allowed", name))
}
}
if onEventRe.MatchString(text) {
add("script", "event handler attributes are not allowed")
}
if externalRefRe.MatchString(text) {
add("externalReference", "the logo must not reference anything outside itself")
}
return out
}
// checkSquareViewBox verifies the mark occupies a square.
func checkSquareViewBox(value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("a viewBox is required")
}
fields := strings.FieldsFunc(value, func(r rune) bool { return r == ' ' || r == ',' || r == '\t' || r == '\n' })
if len(fields) != 4 {
return fmt.Errorf("must be four numbers, found %q", value)
}
var width, height float64
if _, err := fmt.Sscanf(fields[2], "%g", &width); err != nil {
return fmt.Errorf("width %q is not a number", fields[2])
}
if _, err := fmt.Sscanf(fields[3], "%g", &height); err != nil {
return fmt.Errorf("height %q is not a number", fields[3])
}
if width <= 0 || height <= 0 {
return fmt.Errorf("width and height must be positive")
}
if width != height {
return fmt.Errorf("must be square; found %g by %g", width, height)
}
return nil
}
@@ -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</title>
<rect width="512" height="512" fill="#1b1b25"/>
<path d="M128 384 L256 128 L384 384 Z" fill="#9184d9"/>
</svg>`
func rulesOf(vs []Violation) []string {
out := make([]string, 0, len(vs))
for _, v := range vs {
out = append(out, v.Rule)
}
return out
}
func contains(rules []string, want string) bool {
for _, r := range rules {
if r == want {
return true
}
}
return false
}
func TestValidate_AcceptsAConformingLogo(t *testing.T) {
if vs := Validate([]byte(goodLogo)); len(vs) != 0 {
t.Errorf("a conforming logo was rejected: %v", vs)
}
}
func TestValidate_RequiresTheProfileReceiversLookFor(t *testing.T) {
cases := map[string]struct{ from, to, rule string }{
// Without the profile the document is a plain SVG, and a receiver
// ignores it without saying so.
"missing baseProfile": {`baseProfile="tiny-ps" `, "", "baseProfile"},
"wrong baseProfile": {`baseProfile="tiny-ps"`, `baseProfile="full"`, "baseProfile"},
"missing version": {`version="1.2" `, "", "version"},
"wrong version": {`version="1.2"`, `version="1.1"`, "version"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
logo := strings.Replace(goodLogo, tc.from, tc.to, 1)
if !contains(rulesOf(Validate([]byte(logo))), tc.rule) {
t.Errorf("no %s violation reported", tc.rule)
}
})
}
}
// Every client crops or letterboxes a non-square mark differently, so the
// specification requires the viewBox itself be square.
func TestValidate_RequiresASquareViewBox(t *testing.T) {
for _, viewBox := range []string{"0 0 512 256", "0 0 100 200", "0 0 512", "", "0 0 0 0", "0 0 a b"} {
t.Run(viewBox, func(t *testing.T) {
logo := strings.Replace(goodLogo, `viewBox="0 0 512 512"`, fmt.Sprintf(`viewBox=%q`, viewBox), 1)
if !contains(rulesOf(Validate([]byte(logo))), "viewBox") {
t.Errorf("viewBox %q was accepted", viewBox)
}
})
}
// Commas are a legal separator and must not be read as a malformed box.
commas := strings.Replace(goodLogo, `viewBox="0 0 512 512"`, `viewBox="0,0,512,512"`, 1)
if contains(rulesOf(Validate([]byte(commas))), "viewBox") {
t.Error("a comma-separated square viewBox was rejected")
}
}
func TestValidate_RequiresExactlyOneTitle(t *testing.T) {
none := strings.Replace(goodLogo, "<title>LambdaMail</title>", "", 1)
if !contains(rulesOf(Validate([]byte(none))), "title") {
t.Error("a logo with no title was accepted")
}
two := strings.Replace(goodLogo, "<title>LambdaMail</title>", "<title>A</title><title>B</title>", 1)
if !contains(rulesOf(Validate([]byte(two))), "title") {
t.Error("a logo with two titles was accepted")
}
empty := strings.Replace(goodLogo, "<title>LambdaMail</title>", "<title> </title>", 1)
if !contains(rulesOf(Validate([]byte(empty))), "title") {
t.Error("an empty title was accepted")
}
}
// The logo is fetched and rendered by other people's mail clients. Anything
// active or anything that reaches out over the network has no business in it.
func TestValidate_RefusesAnythingActiveOrExternal(t *testing.T) {
cases := map[string]struct{ insert, rule string }{
"script element": {`<script>alert(1)</script>`, "forbiddenElement"},
"link": {`<a href="https://example.test">x</a>`, "forbiddenElement"},
"raster image": {`<image href="https://example.test/p.png"/>`, "forbiddenElement"},
"foreign object": {`<foreignObject><b>x</b></foreignObject>`, "forbiddenElement"},
"animation": {`<animate attributeName="x" to="9"/>`, "forbiddenElement"},
"event handler": {`<rect width="1" height="1" onload="alert(1)"/>`, "script"},
"external href": {`<rect width="1" height="1" xlink:href="https://example.test/x"/>`, "externalReference"},
"data uri": {`<rect width="1" height="1" href="data:image/png;base64,AAAA"/>`, "externalReference"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
logo := strings.Replace(goodLogo, "</svg>", tc.insert+"</svg>", 1)
if !contains(rulesOf(Validate([]byte(logo))), tc.rule) {
t.Errorf("%s was accepted: %v", name, Validate([]byte(logo)))
}
})
}
}
func TestValidate_RefusesWhatCannotBeReadAtAll(t *testing.T) {
if !contains(rulesOf(Validate([]byte(""))), "empty") {
t.Error("an empty file was accepted")
}
if !contains(rulesOf(Validate([]byte("<svg><unclosed>"))), "xml") {
t.Error("malformed XML was accepted")
}
// A PNG renamed to .svg is the likeliest wrong upload of all.
if len(Validate([]byte{0x89, 'P', 'N', 'G'})) == 0 {
t.Error("a PNG was accepted as a logo")
}
}
func TestValidate_RefusesALogoTooBigToBeFetched(t *testing.T) {
padding := strings.Repeat(" ", MaxLogoBytes)
logo := strings.Replace(goodLogo, "</svg>", "<!--"+padding+"--></svg>", 1)
if !contains(rulesOf(Validate([]byte(logo))), "size") {
t.Error("an oversized logo was accepted")
}
}
// One rule at a time, against a receiver that shows nothing either way, is a
// miserable way to learn these - so every violation is reported at once.
func TestValidate_ReportsEveryViolationAtOnce(t *testing.T) {
broken := `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 20"><script/></svg>`
rules := rulesOf(Validate([]byte(broken)))
for _, want := range []string{"baseProfile", "version", "viewBox", "title", "forbiddenElement"} {
if !contains(rules, want) {
t.Errorf("missing %s; got %v", want, rules)
}
}
}
@@ -35,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{
@@ -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")
}
@@ -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
}
@@ -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)
}
@@ -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)
+10 -1
View File
@@ -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",
+10 -1
View File
@@ -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",
+10 -1
View File
@@ -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",
@@ -78,6 +78,14 @@ export default function AdminDomainsPage() {
const [error, setError] = useState<string | null>(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<string[]>([]);
const [verification, setVerification] = useState<DnsVerification | null>(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() {
</section>
)}
{/* BIMI: the only mark an external mail client ever renders for this
sender, and the reason a profile photo does not travel. */}
{activeTab === "domains" && selected && (
<section className={panel}>
<h2 className="text-[17px] font-medium leading-tight text-slate-100">
{t("admin.bimiTitle", { domain: selected.name })}
</h2>
<p className="text-[12.5px] leading-relaxed text-slate-400">{t("admin.bimiExplainer")}</p>
<div className="flex flex-wrap items-center gap-2.5">
<Badge variant={bimi?.configured ? "success" : "neutral"}>
{bimi?.configured ? t("admin.bimiConfigured") : t("admin.bimiNotConfigured")}
</Badge>
{bimi?.configured && !bimi.vmc_url && (
<span className="text-[11.5px] leading-relaxed text-amber-400">{t("admin.bimiNoVmc")}</span>
)}
</div>
<div>
<label htmlFor="bimi-svg" className={fieldLabel}>{t("admin.bimiSvg")}</label>
<textarea
id="bimi-svg"
value={bimiSvg}
onChange={(e) => setBimiSvg(e.target.value)}
rows={7}
spellCheck={false}
className={`${input} font-mono text-[12px]`}
/>
</div>
<div>
<label htmlFor="bimi-vmc" className={fieldLabel}>{t("admin.bimiVmc")}</label>
<input
id="bimi-vmc"
type="url"
value={bimiVmc}
onChange={(e) => setBimiVmc(e.target.value)}
placeholder="https://.../vmc.pem"
className={input}
/>
</div>
{bimiProblems.length > 0 && (
<div className="flex flex-col gap-1 rounded-xl bg-rose-900/50 px-3.5 py-3 shadow-edge">
<span className="text-[12.5px] text-rose-100">{t("admin.bimiRejected")}</span>
{bimiProblems.map((p) => (
<span key={p} className="font-mono text-[11.5px] leading-relaxed text-rose-200">{p}</span>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" size="sm" onClick={() => void saveBimi()} disabled={bimiBusy || !bimiSvg.trim()}>
{bimiBusy ? t("common.loading") : t("common.save")}
</Button>
{bimi?.configured && (
<Button variant="ghost" size="sm" onClick={() => void removeBimi()} disabled={bimiBusy}>
{t("common.delete")}
</Button>
)}
</div>
</section>
)}
{activeTab === "dkim" && (
<section className={`${panel} max-w-[720px]`}>
<div>