Files
LambdaMail/services/protocols/internal/domain/bimi/check.go
T
Lucas RayanandClaude Sonnet 5.5 6e49ca00e2 feat(bimi): reconcile the BIMI record to Cloudflare and keep it in step with the logo
The reconciler never wrote the BIMI record: only the console's verification
knew about it, so a stored logo was never published to the zone.

- SyncDnsRecordsInput carries the BIMI logo and certificate URLs; the record is
  created, updated (a= added or dropped) and, when the logo is removed,
  deleted. Only a record carrying this reconciler's comment is retired: one
  made by hand is left alone
- bimi.Publish decides the record in one place for the timer sweep, the
  console's button and the panel's verification. A held certificate is
  advertised only while it is valid for the logo being served: replacing the
  logo ends the certificate, and a record that kept naming it makes every
  receiver reject BIMI, including those that need no certificate
- saving or removing the logo or the certificate triggers the reconciliation in
  the background; the panel shows the record as the server writes it, says when
  a certificate is held back and why, and has a publish-now button
- a read error is not 'no logo': the sync leaves the record alone instead of
  deleting it over a database hiccup

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:17:07 -03:00

264 lines
9.3 KiB
Go

package bimi
import (
"bytes"
"context"
"fmt"
"strings"
"time"
)
// What decides whether a receiver shows the logo is spread over six places that
// each fail silently: the logo file, the DMARC policy, the BIMI DNS record, the
// public URL of the logo, the certificate, and the public URL of the
// certificate. RunChecks looks at all of them the way a receiver would and says
// which one is wrong, instead of leaving the operator to wait for a message to
// arrive somewhere and show nothing.
// Status of one check.
type Status string
const (
StatusOK Status = "ok"
StatusWarn Status = "warn"
StatusFail Status = "fail"
)
// Check is the outcome of one step.
type Check struct {
ID string
Status Status
Detail string
}
// Resolver looks up TXT records.
type Resolver interface {
LookupTXT(ctx context.Context, name string) ([]string, error)
}
// Response is what a fetch returned.
type Response struct {
Status int
ContentType string
Body []byte
}
// Fetcher gets a public HTTPS URL.
type Fetcher func(ctx context.Context, url string) (Response, error)
// Inputs describe what should be published.
type Inputs struct {
Domain string
// LogoURL and CertificateURL are where the DNS record is expected to
// point. CertificateURL is empty when no certificate is published.
LogoURL string
CertificateURL string
SVG []byte
// CertificatePEM is the chain held here, if any. ExternalCertificateURL is
// the one the operator keeps elsewhere, used only when none is held.
CertificatePEM []byte
ExternalCertificateURL string
// Withheld is why a held certificate is not in the record (see Publish).
Withheld string
Now time.Time
}
// RunChecks runs every check and returns them in the order a receiver would
// meet them. It never fails as a whole: a lookup that errors is a check that
// fails, with the reason.
func RunChecks(ctx context.Context, in Inputs, dns Resolver, fetch Fetcher) []Check {
checks := []Check{
checkLogo(in),
checkDMARC(ctx, in.Domain, dns),
checkRecord(ctx, in, dns),
checkLogoURL(ctx, in, fetch),
}
checks = append(checks, checkCertificate(ctx, in, fetch)...)
return checks
}
func checkLogo(in Inputs) Check {
violations := Validate(in.SVG)
if len(violations) == 0 {
return Check{ID: "logo", Status: StatusOK, Detail: fmt.Sprintf("SVG Tiny PS valid, %d bytes", len(in.SVG))}
}
reasons := make([]string, 0, len(violations))
for _, v := range violations {
reasons = append(reasons, v.String())
}
return Check{ID: "logo", Status: StatusFail, Detail: strings.Join(reasons, "; ")}
}
// checkDMARC: BIMI is only honoured for a domain that enforces DMARC. A policy
// of "none" is the common reason a correct logo and certificate show nothing.
func checkDMARC(ctx context.Context, domain string, dns Resolver) Check {
records, err := dns.LookupTXT(ctx, "_dmarc."+domain)
if err != nil {
return Check{ID: "dmarc", Status: StatusFail, Detail: fmt.Sprintf("no DMARC record at _dmarc.%s (%v)", domain, err)}
}
var record string
for _, r := range records {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(r)), "v=dmarc1") {
record = r
break
}
}
if record == "" {
return Check{ID: "dmarc", Status: StatusFail, Detail: "no DMARC record: BIMI needs a policy of quarantine or reject"}
}
tags := parseTags(record)
policy := strings.ToLower(tags["p"])
switch policy {
case "quarantine", "reject":
default:
return Check{ID: "dmarc", Status: StatusFail, Detail: fmt.Sprintf("DMARC policy is %q: BIMI needs quarantine or reject", policy)}
}
if pct, ok := tags["pct"]; ok && strings.TrimSpace(pct) != "100" {
return Check{ID: "dmarc", Status: StatusWarn, Detail: fmt.Sprintf("DMARC applies to only %s%% of mail; BIMI needs pct=100 (or no pct)", pct)}
}
return Check{ID: "dmarc", Status: StatusOK, Detail: "DMARC policy is " + policy}
}
// checkRecord compares the published BIMI record with the one the service
// would publish.
func checkRecord(ctx context.Context, in Inputs, dns Resolver) Check {
name := "default._bimi." + in.Domain
records, err := dns.LookupTXT(ctx, name)
if err != nil {
return Check{ID: "record", Status: StatusFail, Detail: fmt.Sprintf("no TXT record at %s (%v)", name, err)}
}
var found map[string]string
for _, r := range records {
tags := parseTags(r)
if strings.EqualFold(tags["v"], "BIMI1") {
found = tags
break
}
}
if found == nil {
return Check{ID: "record", Status: StatusFail, Detail: fmt.Sprintf("%s has no v=BIMI1 record", name)}
}
var problems []string
if found["l"] != in.LogoURL {
problems = append(problems, fmt.Sprintf("l= is %q, expected %q", found["l"], in.LogoURL))
}
if found["a"] != in.CertificateURL {
if in.CertificateURL == "" {
problems = append(problems, fmt.Sprintf("a= is %q but no certificate is published here", found["a"]))
} else {
problems = append(problems, fmt.Sprintf("a= is %q, expected %q", found["a"], in.CertificateURL))
}
}
if len(problems) > 0 {
return Check{ID: "record", Status: StatusFail, Detail: strings.Join(problems, "; ")}
}
return Check{ID: "record", Status: StatusOK, Detail: "the BIMI record points at this logo" + ifs(in.CertificateURL != "", " and certificate")}
}
func checkLogoURL(ctx context.Context, in Inputs, fetch Fetcher) Check {
resp, err := fetch(ctx, in.LogoURL)
if err != nil {
return Check{ID: "logoUrl", Status: StatusFail, Detail: fmt.Sprintf("could not fetch %s (%v)", in.LogoURL, err)}
}
switch {
case resp.Status != 200:
return Check{ID: "logoUrl", Status: StatusFail, Detail: fmt.Sprintf("%s answered %d", in.LogoURL, resp.Status)}
case !strings.HasPrefix(strings.ToLower(resp.ContentType), "image/svg+xml"):
return Check{ID: "logoUrl", Status: StatusFail, Detail: fmt.Sprintf("served as %q, receivers require image/svg+xml", resp.ContentType)}
case !bytes.Equal(resp.Body, in.SVG):
return Check{ID: "logoUrl", Status: StatusFail, Detail: "the public URL serves a different file than the one stored (a cache in front?)"}
}
return Check{ID: "logoUrl", Status: StatusOK, Detail: "the logo is public over HTTPS and matches"}
}
// checkCertificate returns one or two checks: the certificate itself, and for a
// hosted one, that the public URL really serves it.
func checkCertificate(ctx context.Context, in Inputs, fetch Fetcher) []Check {
now := in.Now
if now.IsZero() {
now = time.Now()
}
var pemData []byte
var out []Check
switch {
case len(in.CertificatePEM) > 0:
pemData = in.CertificatePEM
if in.CertificateURL != "" {
out = append(out, checkCertificateURL(ctx, in, fetch))
}
case in.ExternalCertificateURL != "":
resp, err := fetch(ctx, in.ExternalCertificateURL)
if err != nil || resp.Status != 200 {
detail := fmt.Sprintf("could not fetch %s", in.ExternalCertificateURL)
if err != nil {
detail += fmt.Sprintf(" (%v)", err)
} else {
detail += fmt.Sprintf(": it answered %d", resp.Status)
}
return []Check{{ID: "certificate", Status: StatusFail, Detail: detail}}
}
pemData = resp.Body
default:
return []Check{{
ID: "certificate",
Status: StatusWarn,
Detail: "no certificate: Gmail and Apple Mail show the logo only with a VMC or CMC from a certificate authority; some other receivers show it without one",
}}
}
cert, violations := InspectCertificate(pemData, in.Domain, in.SVG, now)
if len(violations) > 0 {
reasons := make([]string, 0, len(violations))
for _, v := range violations {
reasons = append(reasons, v.String())
}
detail := strings.Join(reasons, "; ")
if in.Withheld != "" {
// The record stays valid for receivers that do not need a certificate
detail = "held here but NOT published in the DNS record, because it does not apply: " + detail
}
return append([]Check{{ID: "certificate", Status: StatusFail, Detail: detail}}, out...)
}
if len(cert.Warnings) > 0 {
return append([]Check{{ID: "certificate", Status: StatusWarn, Detail: strings.Join(cert.Warnings, "; ")}}, out...)
}
detail := fmt.Sprintf("%s issued to %s, valid until %s, and it carries this logo", strings.ToUpper(string(cert.Kind)), cert.Organization, cert.NotAfter.UTC().Format("2006-01-02"))
return append([]Check{{ID: "certificate", Status: StatusOK, Detail: detail}}, out...)
}
func checkCertificateURL(ctx context.Context, in Inputs, fetch Fetcher) Check {
resp, err := fetch(ctx, in.CertificateURL)
switch {
case err != nil:
return Check{ID: "certificateUrl", Status: StatusFail, Detail: fmt.Sprintf("could not fetch %s (%v)", in.CertificateURL, err)}
case resp.Status != 200:
return Check{ID: "certificateUrl", Status: StatusFail, Detail: fmt.Sprintf("%s answered %d", in.CertificateURL, resp.Status)}
case !bytes.Equal(bytes.TrimSpace(resp.Body), bytes.TrimSpace(in.CertificatePEM)):
return Check{ID: "certificateUrl", Status: StatusFail, Detail: "the public URL serves a different file than the one stored"}
}
return Check{ID: "certificateUrl", Status: StatusOK, Detail: "the certificate is public over HTTPS and matches"}
}
// parseTags reads "k=v; k=v" records (DMARC, BIMI).
func parseTags(record string) map[string]string {
tags := map[string]string{}
for _, part := range strings.Split(record, ";") {
key, value, found := strings.Cut(strings.TrimSpace(part), "=")
if found {
tags[strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(value)
}
}
return tags
}
func ifs(condition bool, text string) string {
if condition {
return text
}
return ""
}