Host your own email and enhance your privacy
BIMI (“Brand Indicators for Message Identification”) lets receiving mail
clients display a sender’s brand logo alongside each message in the
mailbox list. A sender publishes a TXT record at
default._bimi.<domain> pointing at an SVG Tiny PS logo; the receiving
client looks the record up at render time, fetches the SVG, and shows it
in the message row. It is increasingly the default visual treatment in
Gmail, Apple Mail, Yahoo, Fastmail, and others.
This plan was originally written to fix four problems at once. Two had already landed piecemeal during 0.9.x/0.10.x (see Already shipped); the remaining three phases (A/B/C below) are now implemented on the feature branch and pending deploy + on-device/inspector verification.
Erratum (2026-08-07): Since this was written, phases A, B, and C all merged to
mainand deployed; only on-device/inspector verification of the rendered logos remained outstanding. The as-shipped reference documentation is now docs/bimi.md. The three problems they address:
fetch_bimi is not spec-correct. The Lambda at
lambda/api/fetch_bimi/function.py
has had its DNS timeouts and exception handling hardened, but it still
walks superdomains instead of computing the organizational domain,
parses the TXT record positionally, returns the third-party logo URL
verbatim (no fetch, no validation, no caching on infrastructure we
control), and still falls back to a guessed favicon.ico. See
“Remaining fetch_bimi defects” below.getBimiUrl is exposed (around
line 120) but nothing in react/admin/src/Email/Messages/ calls it.
The Envelope.jsx
row still leads with an unread dot and a selection checkbox, no logo.
The Apple clients already ship the equivalent display (see below);
React needs to reach parity.default._bimi.* TXT records in
terraform/infra/modules/app/,
so mail we originate appears in other clients without our mark even
when those clients support BIMI.Apple client display (was Phase 4). The iOS/iPadOS/macOS/visionOS
clients render a sender avatar in both the message-list row
(MessageListView+Rows.swift)
and the detail header
(MessageDetailView.swift)
via AvatarView, with
a three-tier source precedence: the sender’s Apple Contacts photo, then
the domain’s BIMI logo (resolved through
BimiUrlCache,
a session-scoped, coalescing, domain-keyed actor over
fetchBimiURL), then a deterministic colored initials circle.
This diverged from the original plan in two ways that are now the intended design, not debt:
bimi-glyphs/<letter>.svg assets) is therefore dropped — no
client needs them.fetch_bimi DNS hardening (part of the old Phase 1). Input is
validated with validate_dns_apex (400 on bad input), the resolver is
time-bounded, the whole suffix walk is capped by a wall-clock budget,
and every dns.exception.DNSException is caught so a slow or hostile
authoritative NS can no longer turn into a 5xx. This landed via
application-surface-hardening-plan.md.
The remaining fetch_bimi work below is the spec-correctness half,
which that change deliberately left untouched.
fetch_bimi a spec-correct, defensive proxy: discover the logo
by querying default._bimi at the From domain and then the Public
Suffix List organizational domain, parse TXT tags tolerantly, fetch
and validate the SVG server-side against SVG Tiny PS, rasterize it
to a PNG, cache that in S3, and return a PNG URL on infrastructure
we control (or null). Server-side rasterization is load-bearing:
SwiftUI’s AsyncImage cannot decode SVG, so the shipped Apple
AvatarView silently falls back to initials whenever the endpoint
returns an SVG URL. A PNG fixes Apple and React uniformly with no
client changes.TF_VAR_MAIL_DOMAINS, pointing at an SVG Tiny PS
rendering of the Cabalmail mark derived from
front-door/assets/cabalmail-mark.svg,
hosted on the existing worldwide front_door CloudFront.fetch_bimi defectslambda/api/fetch_bimi/function.py,
after the DNS/exception hardening, still has the spec-correctness
defects below. The favicon fallback path is the whole tail of the file.
a.b.c.com -> b.c.com -> c.com -> com), querying invalid
scopes including TLDs. Real senders publish BIMI at the exact From
(sub)domain - e.g. USPS Informed Delivery’s logo is at its From
subdomain, and default._bimi.usps.com is an unrelated SPF string,
not BIMI. The walk also has no v=BIMI1 gate, so it stops on the
first TXT it finds at any scope. Correct discovery queries
default._bimi at the From domain first, then falls back to the PSL
organizational domain, accepting only a record that begins v=BIMI1.str(answer[0]).split(";")[1].split("=")[1] assumes a fixed tag
order with l= second. A record of
v=BIMI1; a=https://example.com/vmc; l=https://example.com/logo.svg
extracts the VMC URL instead of the logo, and a record with no ;
at all (the SPF strings published at default._bimi.usps.com and
default._bimi.etsy.com) raises IndexError straight out of the
handler -> 500. There is no validation that v=BIMI1 is present.AsyncImage cannot decode at all - so even a
correctly-extracted logo never appears on the Apple clients.https://www.<lasttwo>/favicon.ico
may or may not exist; the path concatenates the last two labels of
the sender domain regardless of whether that is the org domain.terraform/infra/modules/app/global_dns.tf
and
terraform/infra/modules/app/dmarc_user.tf.
DMARC is p=reject on the control domain, which qualifies the domain
for BIMI.terraform/infra/modules/app/cloudfront.tf)
is whitelist-restricted to US. BIMI lookups come from receiving
MTAs and clients worldwide, so the admin distribution is unsuitable
for hosting any BIMI asset.front_door site at www.<control_domain> is already worldwide
(terraform/infra/modules/front_door/main.tf
sets restriction_type = "none"). Content ships through
.github/workflows/app.yml -> .github/scripts/render-front-door.py
-> s3 sync s3://www.<control_domain> plus a CloudFront
invalidation. This is the right surface for the Cabalmail BIMI SVG.front-door/assets/cabalmail-mark.svg
is small, with no styles, scripts, gradients, or external refs - very
close to SVG Tiny PS already. It needs version="1.2",
baseProfile="tiny-ps", a <title> element, and an opaque
background rectangle covering the viewBox.Each phase ships independently. Phase B (React) routes through stage per CLAUDE.md; Phases A and C qualify as direct-to-prod-eligible (no data plane impact, no user-facing surface change in isolation, no IAM implications, purely additive).
Erratum (2026-08-15): The direct-to-prod scaffolding carve-out was retired from CLAUDE.md; Phases A and C also route through
stage->main.
fetch_bimiGoal: turn the Lambda into a defensive proxy that returns either a
validated, rasterized PNG URL on infrastructure we control, or
null. (DNS timeout bounding and broad exception handling are already
in place; do not re-do them.)
Scope discovery (From domain, then org domain).
publicsuffixlist (or tldextract) to
lambda/api/fetch_bimi/requirements.txt,
hash-pinned to match the existing --require-hashes build.default._bimi.<from_domain> first. If no usable record, derive
the PSL organizational domain and query default._bimi.<org_domain>
once more (skip the second query when the From domain already is the
org domain). Stop there - do not walk arbitrary superdomains, and
never query a public suffix / TLD. On NXDOMAIN / NoAnswer at both
scopes, return { "url": null }.v=BIMI1
and yields an l= logo URL. A non-BIMI TXT at the queried name (the
SPF strings at default._bimi.usps.com / default._bimi.etsy.com)
is treated as “no record here” and falls through to the next scope -
it must not crash or short-circuit discovery.Parsing.
;, trimming, and tokenizing
each key=value pair. Require v=BIMI1. Extract l= by name. Ignore
unknown tags (including a=). Reject (treat as no record) when
v=BIMI1 or l= is absent. Never index a positional field.Fetch, validate, rasterize.
<svg>,
no <script> anywhere, no xlink:href/href referring to anything
off-document, no <foreignObject>, no <image> with external href.
Reject otherwise. (Accept the SVG even if baseProfile="tiny-ps" is
absent - several real senders omit it - but enforce the element
allowlist that Tiny PS implies, which is the part that matters for
safety.)AsyncImage cannot decode SVG, so an SVG URL never
renders on the Apple clients. Rendering happens here so every client
gets a format it can display and never touches third-party SVG.
resvg binary, invoked via
subprocess. Python’s SVG rasterizers (cairosvg, modern
reportlab/renderPM) all need cairo native libraries at runtime,
which a zip Lambda does not have; resvg is a single self-contained
binary (no system libs) that drops into the zip and keeps
fetch_bimi in the uniform zip-API build. It is fetched and
sha256-verified at build time by
.github/scripts/fetch-resvg.sh
(never committed), then placed at the zip root by build-api-one.sh
with 0755 preserved, so the handler execs /var/task/resvg.resvg ships a prebuilt binary for linux-x86_64
only - no linux-aarch64 - so fetch_bimi overrides the call
module’s default arm64 to x86_64 (a per-function architecture
variable). It is the one x86_64 function in an otherwise arm64 API
fleet. The bundled binary’s arch and the Lambda’s arch are coupled;
keep them in step.Cache and return.
cache.<control_domain>
bucket at key bimi/<sender_domain>.png (the call module’s Lambda
role already grants s3:GetObject/s3:PutObject there, so no new
bucket and no IAM change). Freshness: on each request head_object
the key and re-render only when it is missing or older than 24 h.
Return a 24 h presigned GET URL (helper.sign_url) - presigned URLs
hit S3 directly, so the admin CloudFront’s US restriction does not
apply and the asset resolves worldwide.null (both clients already draw an initials avatar).Unit tests in lambda/api/fetch_bimi/ cover (DNS, fetch, and S3 stubbed
so the suite stays hermetic; the rasterizer runs on a tiny fixture SVG):
v=BIMI1; l=<url>.a= before l=).v=BIMI1, and missing l=.NXDOMAIN / NoAnswer at both scopes -> { "url": null }.<script> / external <image> href -> rejected.bimi-cache.<control_domain>, and
a warm cache entry is served without re-fetching.Goal: parity with the shipped Apple AvatarView - a BIMI logo, falling
back to a natively drawn initials avatar, in the Envelope.jsx leading
slot. Mirror the Apple precedence and fallback rather than reintroducing
glyph SVGs; the unread dot stays.
react/admin/src/utils/
matching Apple’s AvatarView.initials: take up to two initials from a
non-empty display name, else the first letter of the local-part, else
?. Unit tests cover those cases.useBimiUrl(senderDomain) hook (or extend the existing
envelope-list state) that calls ApiClient.getBimiUrl once per domain
per session and memoizes results in component-tree state. Coalesce
concurrent calls for the same domain. Caching a null (no record /
failed lookup) result, like BimiUrlCache does, avoids re-fetching.Envelope.jsx,
render in the envelope-leading span: if bimi.url is a non-null
string, an <img> (~24 x 24 px) with src={bimi.url} (a PNG from
Phase A); otherwise the initials avatar. Keep the existing unread dot
and selection checkbox.Envelope.test.jsx, Envelopes.test.jsx, and the viewport
tests in Messages.viewport.test.jsx to cover the new slot.Goal: mail Cabalmail sends publishes a working default._bimi record
pointing at the Cabalmail mark, so receivers that support BIMI show our
logo.
front-door/assets/bimi/cabalmail.svg
(ships through the existing front_door pipeline):
version="1.2" and baseProfile="tiny-ps" on the root.<title>Cabalmail</title> as the first child of the root.<rect> covering the viewBox in
--cm-cream (#F4EBD6); render the mark in --cm-forest
(#2E5235). Replace the source’s fill="currentColor" with that
explicit color - Tiny PS has no CSS context to resolve
currentColor.
 whitespace artifacts from the path data.default._bimi.<subdomain>.<domain>, with the
per-address subdomain in the middle; a DNS wildcard only matches a
leftmost label, so default._bimi.*.<domain> is a literal record
that matches nothing. This is the same constraint that makes the
new-address Lambda write _dmarc / cabal._domainkey /
<subdomain> records per address rather than wildcarding them. BIMI
follows that mechanism:
new Lambda’s
create_dns_records adds a
default._bimi.<subdomain>.<tld> TXT
(v=BIMI1; l=https://www.<control_domain>/assets/bimi/cabalmail.svg)
alongside the existing per-address records, so every newly created
address publishes BIMI. (Addresses created before this ships do not
have the record; a one-off backfill over the cabal-addresses
table can add them if desired - tracked as follow-up, not blocking.)mail-admin is provisioned in Terraform, so its
record is added there too, in
dmarc_user.tf
(aws_route53_record.dmarc_subdomain_bimi).dig commands in docs/operations.md.Each phase verifies end-to-end before merging.
cd lambda/api/fetch_bimi && python -m pytest against
the new tests. Smoke-test in stage by hitting
/fetch_bimi?sender_domain=chewy.com, /fetch_bimi?sender_domain=amazon.com,
and a domain known to have no BIMI; observe url is a non-null
presigned cache.<control_domain>/bimi/...png URL in the first two
cases (open it and confirm a rendered logo, not an SVG) and null in
the third.
Then confirm the screenshot case end-to-end on a device: the Apple
list rows that previously showed initials now show the rasterized
logos.cd react/admin && npm run test. Manually load the
admin app, confirm logos render for messages from known
BIMI-publishing senders and initials avatars render for the rest.new Lambda), then
from outside the Cabalmail VPC
dig TXT default._bimi.<that-subdomain>.<cabalmail-domain> returns the
expected record; likewise for mail-admin after the Terraform apply.
Send a test email from a Cabalmail address to a Gmail account and a
Fastmail account; confirm the
Cabalmail mark renders (Gmail will require a VMC, so the mark appears
in Fastmail but not Gmail - that is expected and matches the “VMC
deferred” non-goal).a=https://www.<control_domain>/assets/bimi/cabalmail.pem, with the
certificate file hosted alongside the SVG. The certificate itself is
the cost; the wiring is trivial.fetch_bimi Lambda) is additive and self-contained: no data
plane impact, no user-facing surface change in isolation. The one
infra note is that it becomes the lone x86_64 Lambda (bundled resvg).new-address Lambda’s DNS writes (the BIMI record
is added to the address-creation flow), so it is not
direct-to-prod-eligible - it routes via stage with the rest. The
front_door SVG and the mail-admin Terraform record are additive.