From cf0eb03c4af2a14ea3c6b6267ecb67d6026ac33b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:12:20 +0100 Subject: [PATCH] feat: show the sender's BIMI logo as the sender icon when the message passes DMARC Stalwart does no BIMI, so sender icons were only ever favicons, which anyone controls for any domain. The webmail server now does the receiver side itself at /api/bimi, and avatars ask it only for messages whose own Authentication-Results say dmarc=pass for the exact From domain. - `lib/bimi/`: DMARC policy check (quarantine or reject, pct=100, the parent domain's sp and p for subdomains), the assertion record with fallback to the parent domain, mark certificate validation (chain to pinned DigiCert, GlobalSign and SSL.com VMC roots, BIMI key purpose, validity, SAN, SCTs present, CRL revocation, logo from the logotype extension), SVG Tiny PS checks and rendering to PNG, caching - logos backed only by the DNS record show by default; the admin setting "Require a mark certificate" (`BIMI_REQUIRE_CERTIFICATE`) limits them to VMC and CMC backed ones - a check mark beside the sender's name for certificate-backed logos - avatars try contact photo, BIMI logo, plugin avatar, favicon; the sender warning shield still wins, and logos follow the sender icons setting and the no-third-party-traffic mode - the two-label public suffix list moved to `lib/root-domain.ts` SCT signatures are not verified and only the default selector is used. --- .env.example | 11 + app/(main)/admin/_pages/settings.tsx | 4 + app/api/bimi/route.ts | 71 ++++ app/api/favicon/route.ts | 367 +---------------- components/email/brand-verified-badge.tsx | 113 ++++++ components/email/email-viewer.tsx | 13 +- components/email/thread-conversation-view.tsx | 2 + components/email/thread-email-item.tsx | 2 + components/email/thread-list-item.tsx | 3 + .../global-search/palette-result-row.tsx | 3 +- components/icons/index.ts | 1 + components/ui/__tests__/avatar.test.tsx | 67 ++++ components/ui/avatar.tsx | 41 +- lib/__tests__/bimi-certificate.test.ts | 130 ++++++ lib/__tests__/bimi-lookup.test.ts | 116 ++++++ lib/__tests__/bimi-records.test.ts | 71 ++++ lib/__tests__/bimi-route.test.ts | 87 ++++ lib/__tests__/bimi-svg.test.ts | 59 +++ lib/__tests__/brand-indicator-domain.test.ts | 38 ++ lib/__tests__/fixtures/bimi/generate.py | 158 ++++++++ lib/__tests__/outgoing-hosts-mode.test.ts | 12 + lib/admin/admin-nav.ts | 1 + lib/admin/outgoing-hosts-registry.ts | 12 + lib/admin/types.ts | 4 + lib/bimi/certificate.ts | 298 ++++++++++++++ lib/bimi/der.ts | 141 +++++++ lib/bimi/lookup.ts | 293 ++++++++++++++ lib/bimi/records.ts | 110 ++++++ lib/bimi/roots.ts | 158 ++++++++ lib/bimi/svg.ts | 95 +++++ lib/bimi/types.ts | 11 + lib/email-headers.ts | 19 + lib/root-domain.ts | 372 ++++++++++++++++++ locales/ar/common.json | 5 + locales/ca/common.json | 5 + locales/cs/common.json | 5 + locales/da/common.json | 5 + locales/de/common.json | 5 + locales/en/common.json | 5 + locales/es/common.json | 5 + locales/et/common.json | 5 + locales/fa/common.json | 5 + locales/fr/common.json | 5 + locales/he/common.json | 5 + locales/hu/common.json | 5 + locales/it/common.json | 5 + locales/ja/common.json | 5 + locales/ko/common.json | 5 + locales/lv/common.json | 5 + locales/mn/common.json | 5 + locales/nb/common.json | 5 + locales/nl/common.json | 5 + locales/pl/common.json | 5 + locales/pt/common.json | 5 + locales/ro/common.json | 5 + locales/ru/common.json | 5 + locales/sk/common.json | 5 + locales/th/common.json | 5 + locales/tr/common.json | 5 + locales/uk/common.json | 5 + locales/zh-TW/common.json | 5 + locales/zh/common.json | 5 + stores/favicon-refresh-store.ts | 3 + 63 files changed, 2651 insertions(+), 380 deletions(-) create mode 100644 app/api/bimi/route.ts create mode 100644 components/email/brand-verified-badge.tsx create mode 100644 lib/__tests__/bimi-certificate.test.ts create mode 100644 lib/__tests__/bimi-lookup.test.ts create mode 100644 lib/__tests__/bimi-records.test.ts create mode 100644 lib/__tests__/bimi-route.test.ts create mode 100644 lib/__tests__/bimi-svg.test.ts create mode 100644 lib/__tests__/brand-indicator-domain.test.ts create mode 100644 lib/__tests__/fixtures/bimi/generate.py create mode 100644 lib/bimi/certificate.ts create mode 100644 lib/bimi/der.ts create mode 100644 lib/bimi/lookup.ts create mode 100644 lib/bimi/records.ts create mode 100644 lib/bimi/roots.ts create mode 100644 lib/bimi/svg.ts create mode 100644 lib/bimi/types.ts create mode 100644 lib/root-domain.ts diff --git a/.env.example b/.env.example index 2ef7d1cb8..d30fa4c61 100644 --- a/.env.example +++ b/.env.example @@ -489,6 +489,17 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org # LIBRETRANSLATE_URL=https://libretranslate.example.com # LIBRETRANSLATE_API_KEY= +# ============================================================================= +# Brand Logos (BIMI) +# ============================================================================= + +# Senders that publish a BIMI record and enforce DMARC get their logo as the +# sender icon, looked up by this server (/api/bimi). By default a logo shows +# when only the DNS record backs it, as Yahoo does. Set to true to show only +# logos backed by a Verified Mark or Common Mark Certificate, as Gmail and +# Apple Mail do. Users turn logos off with the sender icons setting. +# BIMI_REQUIRE_CERTIFICATE=true + # ============================================================================= # Web Push # ============================================================================= diff --git a/app/(main)/admin/_pages/settings.tsx b/app/(main)/admin/_pages/settings.tsx index 8deaacaae..901056059 100644 --- a/app/(main)/admin/_pages/settings.tsx +++ b/app/(main)/admin/_pages/settings.tsx @@ -176,6 +176,10 @@ export function SettingsPage() { + + + + diff --git a/app/api/bimi/route.ts b/app/api/bimi/route.ts new file mode 100644 index 000000000..9e162cacb --- /dev/null +++ b/app/api/bimi/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { configManager } from '@/lib/admin/config-manager'; +import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode'; +import { bimiResolver, normalizeBimiDomain, type BimiIndicator } from '@/lib/bimi/lookup'; +import type { BimiInfo } from '@/lib/bimi/types'; + +/** + * BIMI sender logos. GET ?domain= answers the logo as a PNG, or + * the same 1x1 transparent PNG /api/favicon sends when there is none (the + * avatar falls through on naturalWidth <= 1). With &format=json it answers + * what backs the logo, for the verified badge in the message view. + * + * Ask only for messages that passed DMARC for that domain (see + * brandIndicatorDomain in lib/email-headers.ts): the logo says who the domain + * is, not that a given message came from it. + */ + +const TRANSPARENT_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=', + 'base64', +); + +const IMAGE_HEADERS = { + 'Content-Type': 'image/png', + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "default-src 'none'; sandbox", +}; + +// Short enough that switching "Require a mark certificate" reaches browsers +// the same day. +const LOGO_MAX_AGE = 6 * 60 * 60; +const MISSING_MAX_AGE = 60 * 60; + +async function requireCertificate(): Promise { + await configManager.ensureLoaded(); + return configManager.get('bimiRequireCertificate', false) === true; +} + +function info(indicator: BimiIndicator | null): BimiInfo { + if (!indicator) return { status: 'none' }; + const { status, markType, organization, issuer } = indicator; + return { status, markType, organization, issuer }; +} + +export async function GET(request: NextRequest) { + // The lookups reach the sender's DNS and whatever hosts its record names. + if (await isNoThirdPartyTrafficOn()) return thirdPartyRefusal('Brand logos'); + + const domain = normalizeBimiDomain(request.nextUrl.searchParams.get('domain')); + const asJson = request.nextUrl.searchParams.get('format') === 'json'; + if (!domain) { + return new NextResponse(null, { status: 400, headers: { 'Cache-Control': 'public, max-age=86400' } }); + } + + let indicator = await bimiResolver().resolve(domain); + if (indicator && indicator.status !== 'verified' && await requireCertificate()) indicator = null; + + if (asJson) { + return NextResponse.json(info(indicator), { + headers: { 'Cache-Control': `public, max-age=${indicator ? LOGO_MAX_AGE : MISSING_MAX_AGE}` }, + }); + } + if (!indicator) { + return new NextResponse(new Uint8Array(TRANSPARENT_PNG), { + headers: { ...IMAGE_HEADERS, 'Cache-Control': `public, max-age=${MISSING_MAX_AGE}`, 'X-Bulwark-Bimi': 'missing' }, + }); + } + return new NextResponse(new Uint8Array(indicator.png), { + headers: { ...IMAGE_HEADERS, 'Cache-Control': `public, max-age=${LOGO_MAX_AGE}`, 'X-Bulwark-Bimi': indicator.status }, + }); +} diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts index 311757fbe..2e1ec2299 100644 --- a/app/api/favicon/route.ts +++ b/app/api/favicon/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode'; +import { getRootDomain } from '@/lib/root-domain'; // In-memory LRU cache: domain -> { data, contentType, fetchedAt } const CACHE_MAX_SIZE = 1000; @@ -59,372 +60,6 @@ function isValidDomain(domain: string): boolean { return true; } -// Known multi-part TLDs where the registrable domain includes one extra label. -const MULTI_PART_TLDS = new Set([ - // .ac - "com.ac", "gov.ac", "mil.ac", "net.ac", "org.ac", - // .ae - "ac.ae", "co.ae", "gov.ae", "mil.ae", "name.ae", "net.ae", "org.ae", "pro.ae", "sch.ae", - // .af - "com.af", "edu.af", "gov.af", "net.af", "org.af", - // .al - "com.al", "edu.al", "gov.al", "mil.al", "net.al", "org.al", - // .ao - "co.ao", "ed.ao", "gv.ao", "it.ao", "og.ao", "pb.ao", - // .ar - "com.ar", "edu.ar", "gob.ar", "gov.ar", "int.ar", "mil.ar", "net.ar", "org.ar", "tur.ar", - // .at - "ac.at", "co.at", "gv.at", "or.at", - // .au - "asn.au", "com.au", "csiro.au", "edu.au", "gov.au", "id.au", "net.au", "org.au", - // .ba - "co.ba", "com.ba", "edu.ba", "gov.ba", "mil.ba", "net.ba", "org.ba", "rs.ba", - "unbi.ba", "unmo.ba", "unsa.ba", "untz.ba", "unze.ba", - // .bb - "biz.bb", "co.bb", "com.bb", "edu.bb", "gov.bb", "info.bb", "net.bb", "org.bb", - "store.bb", "tv.bb", - // .bh - "biz.bh", "cc.bh", "com.bh", "edu.bh", "gov.bh", "info.bh", "net.bh", "org.bh", - // .bn - "com.bn", "edu.bn", "gov.bn", "net.bn", "org.bn", - // .bo - "com.bo", "edu.bo", "gob.bo", "gov.bo", "int.bo", "mil.bo", "net.bo", "org.bo", "tv.bo", - // .br - "adm.br", "adv.br", "agr.br", "am.br", "arq.br", "art.br", "ato.br", "b.br", - "bio.br", "blog.br", "bmd.br", "cim.br", "cng.br", "cnt.br", "com.br", "coop.br", - "ecn.br", "edu.br", "eng.br", "esp.br", "etc.br", "eti.br", "far.br", "flog.br", - "fm.br", "fnd.br", "fot.br", "fst.br", "g12.br", "ggf.br", "gov.br", "imb.br", - "ind.br", "inf.br", "jor.br", "jus.br", "lel.br", "mat.br", "med.br", "mil.br", - "mus.br", "net.br", "nom.br", "not.br", "ntr.br", "odo.br", "org.br", "ppg.br", - "pro.br", "psc.br", "psi.br", "qsl.br", "rec.br", "slg.br", "srv.br", "tmp.br", - "trd.br", "tur.br", "tv.br", "vet.br", "vlog.br", "wiki.br", "zlg.br", - // .bs - "com.bs", "edu.bs", "gov.bs", "net.bs", "org.bs", - // .bz - "com.bz", "edu.bz", "gov.bz", "net.bz", "org.bz", - // .ca - "ab.ca", "bc.ca", "mb.ca", "nb.ca", "nf.ca", "nl.ca", "ns.ca", "nt.ca", - "nu.ca", "on.ca", "pe.ca", "qc.ca", "sk.ca", "yk.ca", - // .ck - "biz.ck", "co.ck", "edu.ck", "gen.ck", "gov.ck", "info.ck", "net.ck", "org.ck", - // .cn - "ac.cn", "ah.cn", "bj.cn", "com.cn", "cq.cn", "edu.cn", "fj.cn", "gd.cn", - "gov.cn", "gs.cn", "gx.cn", "gz.cn", "ha.cn", "hb.cn", "he.cn", "hi.cn", - "hl.cn", "hn.cn", "jl.cn", "js.cn", "jx.cn", "ln.cn", "mil.cn", "net.cn", - "nm.cn", "nx.cn", "org.cn", "qh.cn", "sc.cn", "sd.cn", "sh.cn", "sn.cn", - "sx.cn", "tj.cn", "tw.cn", "xj.cn", "xz.cn", "yn.cn", "zj.cn", - // .co - "com.co", "edu.co", "gov.co", "mil.co", "net.co", "nom.co", "org.co", - // .cr - "ac.cr", "co.cr", "ed.cr", "fi.cr", "go.cr", "or.cr", "sa.cr", - // .cy - "ac.cy", "biz.cy", "com.cy", "ekloges.cy", "gov.cy", "ltd.cy", "name.cy", - "net.cy", "org.cy", "parliament.cy", "press.cy", "pro.cy", "tm.cy", - // .do - "art.do", "com.do", "edu.do", "gob.do", "gov.do", "mil.do", "net.do", "org.do", - "sld.do", "web.do", - // .dz - "art.dz", "asso.dz", "com.dz", "edu.dz", "gov.dz", "net.dz", "org.dz", "pol.dz", - // .ec - "com.ec", "edu.ec", "fin.ec", "gov.ec", "info.ec", "med.ec", "mil.ec", "net.ec", - "org.ec", "pro.ec", - // .eg - "com.eg", "edu.eg", "eun.eg", "gov.eg", "mil.eg", "name.eg", "net.eg", "org.eg", "sci.eg", - // .er - "com.er", "edu.er", "gov.er", "ind.er", "mil.er", "net.er", "org.er", "rochest.er", "w.er", - // .es - "com.es", "edu.es", "gob.es", "nom.es", "org.es", - // .et - "biz.et", "com.et", "edu.et", "gov.et", "info.et", "name.et", "net.et", "org.et", - // .fj - "ac.fj", "biz.fj", "com.fj", "info.fj", "mil.fj", "name.fj", "net.fj", "org.fj", "pro.fj", - // .fk - "ac.fk", "co.fk", "gov.fk", "net.fk", "nom.fk", "org.fk", - // .fr - "asso.fr", "com.fr", "gouv.fr", "nom.fr", "prd.fr", "presse.fr", "tm.fr", - // .gg - "co.gg", "net.gg", "org.gg", - // .gh - "com.gh", "edu.gh", "gov.gh", "mil.gh", "org.gh", - // .gn - "ac.gn", "com.gn", "gov.gn", "net.gn", "org.gn", - // .gr - "com.gr", "edu.gr", "gov.gr", "mil.gr", "net.gr", "org.gr", - // .gt - "com.gt", "edu.gt", "gob.gt", "ind.gt", "mil.gt", "net.gt", "org.gt", - // .gu - "com.gu", "edu.gu", "gov.gu", "net.gu", "org.gu", - // .hk - "com.hk", "edu.hk", "gov.hk", "idv.hk", "net.hk", "org.hk", - // .id - "ac.id", "co.id", "go.id", "mil.id", "net.id", "or.id", "sch.id", "web.id", - // .il - "ac.il", "co.il", "gov.il", "idf.il", "k12.il", "muni.il", "net.il", "org.il", - // .in - "4fd.in", "ac.in", "co.in", "edu.in", "ernet.in", "firm.in", "gen.in", "gov.in", - "ind.in", "mil.in", "net.in", "nic.in", "org.in", "res.in", - // .iq - "com.iq", "edu.iq", "gov.iq", "mil.iq", "net.iq", "org.iq", - // .ir - "ac.ir", "co.ir", "dnssec.ir", "gov.ir", "id.ir", "net.ir", "org.ir", "sch.ir", - // .it - "edu.it", "gov.it", - // .je - "co.je", "net.je", "org.je", - // .jo - "com.jo", "edu.jo", "gov.jo", "mil.jo", "name.jo", "net.jo", "org.jo", "sch.jo", - // .jp - "ac.jp", "ad.jp", "co.jp", "ed.jp", "go.jp", "gr.jp", "lg.jp", "ne.jp", "or.jp", - // .ke - "ac.ke", "co.ke", "go.ke", "info.ke", "me.ke", "mobi.ke", "ne.ke", "or.ke", "sc.ke", - // .kh - "com.kh", "edu.kh", "gov.kh", "mil.kh", "net.kh", "org.kh", "per.kh", - // .ki - "biz.ki", "com.ki", "de.ki", "edu.ki", "gov.ki", "info.ki", "mob.ki", "net.ki", - "org.ki", "tel.ki", - // .km - "asso.km", "com.km", "coop.km", "edu.km", "gouv.km", "medecin.km", "mil.km", - "nom.km", "notaires.km", "pharmaciens.km", "presse.km", "tm.km", "veterinaire.km", - // .kn - "edu.kn", "gov.kn", "net.kn", "org.kn", - // .kr - "ac.kr", "busan.kr", "chungbuk.kr", "chungnam.kr", "co.kr", "daegu.kr", - "daejeon.kr", "es.kr", "gangwon.kr", "go.kr", "gwangju.kr", "gyeongbuk.kr", - "gyeonggi.kr", "gyeongnam.kr", "hs.kr", "incheon.kr", "jeju.kr", "jeonbuk.kr", - "jeonnam.kr", "kg.kr", "mil.kr", "ms.kr", "ne.kr", "or.kr", "pe.kr", "re.kr", - "sc.kr", "seoul.kr", "ulsan.kr", - // .kw - "com.kw", "edu.kw", "gov.kw", "net.kw", "org.kw", - // .ky - "com.ky", "edu.ky", "gov.ky", "net.ky", "org.ky", - // .kz - "com.kz", "edu.kz", "gov.kz", "mil.kz", "net.kz", "org.kz", - // .lb - "com.lb", "edu.lb", "gov.lb", "net.lb", "org.lb", - // .lk - "assn.lk", "com.lk", "edu.lk", "gov.lk", "grp.lk", "hotel.lk", "int.lk", "ltd.lk", - "net.lk", "ngo.lk", "org.lk", "sch.lk", "soc.lk", "web.lk", - // .lr - "com.lr", "edu.lr", "gov.lr", "net.lr", "org.lr", - // .lv - "asn.lv", "com.lv", "conf.lv", "edu.lv", "gov.lv", "id.lv", "mil.lv", "net.lv", "org.lv", - // .ly - "com.ly", "edu.ly", "gov.ly", "id.ly", "med.ly", "net.ly", "org.ly", "plc.ly", "sch.ly", - // .ma - "ac.ma", "co.ma", "gov.ma", "net.ma", "org.ma", "press.ma", - // .mc - "asso.mc", "tm.mc", - // .me - "ac.me", "co.me", "edu.me", "gov.me", "its.me", "net.me", "org.me", "priv.me", - // .mg - "com.mg", "edu.mg", "gov.mg", "mil.mg", "nom.mg", "org.mg", "prd.mg", "tm.mg", - // .mk - "com.mk", "edu.mk", "gov.mk", "inf.mk", "name.mk", "net.mk", "org.mk", "pro.mk", - // .ml - "com.ml", "edu.ml", "gov.ml", "net.ml", "org.ml", "presse.ml", - // .mn - "edu.mn", "gov.mn", "org.mn", - // .mo - "com.mo", "edu.mo", "gov.mo", "net.mo", "org.mo", - // .mt - "com.mt", "edu.mt", "gov.mt", "net.mt", "org.mt", - // .mu - "ac.mu", "co.mu", "com.mu", "gov.mu", "net.mu", "or.mu", "org.mu", - // .mv - "aero.mv", "biz.mv", "com.mv", "coop.mv", "edu.mv", "gov.mv", "info.mv", - "int.mv", "mil.mv", "museum.mv", "name.mv", "net.mv", "org.mv", "pro.mv", - // .mw - "ac.mw", "co.mw", "com.mw", "coop.mw", "edu.mw", "gov.mw", "int.mw", - "museum.mw", "net.mw", "org.mw", - // .mx - "com.mx", "edu.mx", "gob.mx", "net.mx", "org.mx", - // .my - "com.my", "edu.my", "gov.my", "mil.my", "name.my", "net.my", "org.my", "sch.my", - // .mz - "ac.mz", "co.mz", "edu.mz", "gov.mz", "org.mz", - // .na - "co.na", "com.na", - // .nf - "arts.nf", "com.nf", "firm.nf", "info.nf", "net.nf", "other.nf", "per.nf", - "rec.nf", "store.nf", "web.nf", - // .ng - "biz.ng", "com.ng", "edu.ng", "gov.ng", "mil.ng", "mobi.ng", "name.ng", - "net.ng", "org.ng", "sch.ng", - // .ni - "ac.ni", "co.ni", "com.ni", "edu.ni", "gob.ni", "mil.ni", "net.ni", "nom.ni", "org.ni", - // .np - "com.np", "edu.np", "gov.np", "mil.np", "net.np", "org.np", - // .nr - "biz.nr", "com.nr", "edu.nr", "gov.nr", "info.nr", "net.nr", "org.nr", - // .nz - "ac.nz", "co.nz", "cri.nz", "geek.nz", "gen.nz", "govt.nz", "health.nz", - "iwi.nz", "maori.nz", "mil.nz", "net.nz", "org.nz", "parliament.nz", "school.nz", - // .om - "ac.om", "biz.om", "co.om", "com.om", "edu.om", "gov.om", "med.om", "mil.om", - "museum.om", "net.om", "org.om", "pro.om", "sch.om", - // .pa - "abo.pa", "ac.pa", "com.pa", "edu.pa", "gob.pa", "ing.pa", "med.pa", "net.pa", - "nom.pa", "org.pa", "sld.pa", - // .pe - "com.pe", "edu.pe", "gob.pe", "mil.pe", "net.pe", "nom.pe", "org.pe", "sld.pe", - // .ph - "com.ph", "edu.ph", "gov.ph", "i.ph", "mil.ph", "net.ph", "ngo.ph", "org.ph", - // .pk - "biz.pk", "com.pk", "edu.pk", "fam.pk", "gob.pk", "gok.pk", "gon.pk", "gop.pk", - "gos.pk", "gov.pk", "net.pk", "org.pk", "web.pk", - // .pl - "art.pl", "bialystok.pl", "biz.pl", "com.pl", "edu.pl", "gda.pl", "gdansk.pl", - "gorzow.pl", "gov.pl", "info.pl", "katowice.pl", "krakow.pl", "lodz.pl", - "lublin.pl", "mil.pl", "net.pl", "ngo.pl", "olsztyn.pl", "org.pl", "poznan.pl", - "pwr.pl", "radom.pl", "slupsk.pl", "szczecin.pl", "torun.pl", "warszawa.pl", - "waw.pl", "wroc.pl", "wroclaw.pl", "zgora.pl", - // .pr - "ac.pr", "biz.pr", "com.pr", "edu.pr", "est.pr", "gov.pr", "info.pr", "isla.pr", - "name.pr", "net.pr", "org.pr", "pro.pr", "prof.pr", - // .ps - "com.ps", "edu.ps", "gov.ps", "net.ps", "org.ps", "plo.ps", "sec.ps", - // .pt - "com.pt", "edu.pt", "gov.pt", "int.pt", "net.pt", "nome.pt", "org.pt", "publ.pt", - // .pw - "belau.pw", "co.pw", "ed.pw", "go.pw", "ne.pw", "or.pw", - // .py - "com.py", "edu.py", "gov.py", "mil.py", "net.py", "org.py", - // .qa - "com.qa", "edu.qa", "gov.qa", "mil.qa", "net.qa", "org.qa", - // .re - "asso.re", "com.re", "nom.re", - // .ro - "arts.ro", "com.ro", "firm.ro", "info.ro", "nom.ro", "nt.ro", "org.ro", - "rec.ro", "store.ro", "tm.ro", "www.ro", - // .rs - "ac.rs", "co.rs", "edu.rs", "gov.rs", "in.rs", "org.rs", - // .ru - "ac.ru", "adygeya.ru", "altai.ru", "amur.ru", "arkhangelsk.ru", "astrakhan.ru", - "bashkiria.ru", "belgorod.ru", "bir.ru", "bryansk.ru", "buryatia.ru", "cbg.ru", - "chel.ru", "chelyabinsk.ru", "chita.ru", "chukotka.ru", "chuvashia.ru", "com.ru", - "dagestan.ru", "e-burg.ru", "edu.ru", "gov.ru", "grozny.ru", "int.ru", - "irkutsk.ru", "ivanovo.ru", "izhevsk.ru", "jar.ru", "joshkar-ola.ru", - "kalmykia.ru", "kaluga.ru", "kamchatka.ru", "karelia.ru", "kazan.ru", "kchr.ru", - "kemerovo.ru", "khabarovsk.ru", "khakassia.ru", "khv.ru", "kirov.ru", - "koenig.ru", "komi.ru", "kostroma.ru", "kranoyarsk.ru", "kuban.ru", "kurgan.ru", - "kursk.ru", "lipetsk.ru", "magadan.ru", "mari.ru", "mari-el.ru", "marine.ru", - "mil.ru", "mordovia.ru", "mosreg.ru", "msk.ru", "murmansk.ru", "nalchik.ru", - "net.ru", "nnov.ru", "nov.ru", "novosibirsk.ru", "nsk.ru", "omsk.ru", - "orenburg.ru", "org.ru", "oryol.ru", "penza.ru", "perm.ru", "pp.ru", "pskov.ru", - "ptz.ru", "rnd.ru", "ryazan.ru", "sakhalin.ru", "samara.ru", "saratov.ru", - "simbirsk.ru", "smolensk.ru", "spb.ru", "stavropol.ru", "stv.ru", "surgut.ru", - "tambov.ru", "tatarstan.ru", "tom.ru", "tomsk.ru", "tsaritsyn.ru", "tsk.ru", - "tula.ru", "tuva.ru", "tver.ru", "tyumen.ru", "udm.ru", "udmurtia.ru", - "ulan-ude.ru", "vladikavkaz.ru", "vladimir.ru", "vladivostok.ru", "volgograd.ru", - "vologda.ru", "voronezh.ru", "vrn.ru", "vyatka.ru", "yakutia.ru", "yamal.ru", - "yekaterinburg.ru", "yuzhno-sakhalinsk.ru", - // .rw - "ac.rw", "co.rw", "com.rw", "edu.rw", "gouv.rw", "gov.rw", "int.rw", "mil.rw", "net.rw", - // .sa - "com.sa", "edu.sa", "gov.sa", "med.sa", "net.sa", "org.sa", "pub.sa", "sch.sa", - // .sb - "com.sb", "edu.sb", "gov.sb", "net.sb", "org.sb", - // .sc - "com.sc", "edu.sc", "gov.sc", "net.sc", "org.sc", - // .sd - "com.sd", "edu.sd", "gov.sd", "info.sd", "med.sd", "net.sd", "org.sd", "tv.sd", - // .se - "a.se", "ac.se", "b.se", "bd.se", "c.se", "d.se", "e.se", "f.se", "g.se", - "h.se", "i.se", "k.se", "l.se", "m.se", "n.se", "o.se", "org.se", "p.se", - "parti.se", "pp.se", "press.se", "r.se", "s.se", "t.se", "tm.se", "u.se", - "w.se", "x.se", "y.se", "z.se", - // .sg - "com.sg", "edu.sg", "gov.sg", "idn.sg", "net.sg", "org.sg", "per.sg", - // .sh - "co.sh", "com.sh", "edu.sh", "gov.sh", "net.sh", "nom.sh", "org.sh", - // .sl - "com.sl", "edu.sl", "gov.sl", "net.sl", "org.sl", - // .sn - "art.sn", "com.sn", "edu.sn", "gouv.sn", "org.sn", "perso.sn", "univ.sn", - // .st - "co.st", "com.st", "consulado.st", "edu.st", "embaixada.st", "gov.st", "mil.st", - "net.st", "org.st", "principe.st", "saotome.st", "store.st", - // .sv - "com.sv", "edu.sv", "gob.sv", "org.sv", "red.sv", - // .sy - "com.sy", "edu.sy", "gov.sy", "mil.sy", "net.sy", "news.sy", "org.sy", - // .sz - "ac.sz", "co.sz", "org.sz", - // .th - "ac.th", "co.th", "go.th", "in.th", "mi.th", "net.th", "or.th", - // .tj - "ac.tj", "biz.tj", "co.tj", "com.tj", "edu.tj", "go.tj", "gov.tj", "info.tj", - "int.tj", "mil.tj", "name.tj", "net.tj", "nic.tj", "org.tj", "test.tj", "web.tj", - // .tn - "agrinet.tn", "com.tn", "defense.tn", "edunet.tn", "ens.tn", "fin.tn", "gov.tn", - "ind.tn", "info.tn", "intl.tn", "mincom.tn", "nat.tn", "net.tn", "org.tn", - "perso.tn", "rnrt.tn", "rns.tn", "rnu.tn", "tourism.tn", - // .tr - "av.tr", "bbs.tr", "bel.tr", "biz.tr", "com.tr", "dr.tr", "edu.tr", "gen.tr", - "gov.tr", "info.tr", "k12.tr", "name.tr", "net.tr", "org.tr", "pol.tr", - "tel.tr", "tsk.tr", "tv.tr", "web.tr", - // .tt - "aero.tt", "biz.tt", "cat.tt", "co.tt", "com.tt", "coop.tt", "edu.tt", "gov.tt", - "info.tt", "int.tt", "jobs.tt", "mil.tt", "mobi.tt", "museum.tt", "name.tt", - "net.tt", "org.tt", "pro.tt", "tel.tt", "travel.tt", - // .tw - "club.tw", "com.tw", "ebiz.tw", "edu.tw", "game.tw", "gov.tw", "idv.tw", - "mil.tw", "net.tw", "org.tw", - // .tz - "ac.tz", "co.tz", "go.tz", "ne.tz", "or.tz", - // .ua - "biz.ua", "cherkassy.ua", "chernigov.ua", "chernovtsy.ua", "ck.ua", "cn.ua", - "co.ua", "com.ua", "crimea.ua", "cv.ua", "dn.ua", "dnepropetrovsk.ua", - "donetsk.ua", "dp.ua", "edu.ua", "gov.ua", "if.ua", "in.ua", - "ivano-frankivsk.ua", "kh.ua", "kharkov.ua", "kherson.ua", "khmelnitskiy.ua", - "kiev.ua", "kirovograd.ua", "km.ua", "kr.ua", "ks.ua", "kv.ua", "lg.ua", - "lugansk.ua", "lutsk.ua", "lviv.ua", "me.ua", "mk.ua", "net.ua", - "nikolaev.ua", "od.ua", "odessa.ua", "org.ua", "pl.ua", "poltava.ua", "pp.ua", - "rovno.ua", "rv.ua", "sebastopol.ua", "sumy.ua", "te.ua", "ternopil.ua", - "uzhgorod.ua", "vinnica.ua", "vn.ua", "zaporizhzhe.ua", "zhitomir.ua", - "zp.ua", "zt.ua", - // .ug - "ac.ug", "co.ug", "go.ug", "ne.ug", "or.ug", "org.ug", "sc.ug", - // .uk - "ac.uk", "bl.uk", "british-library.uk", "co.uk", "cym.uk", "gov.uk", "govt.uk", - "icnet.uk", "jet.uk", "lea.uk", "ltd.uk", "me.uk", "mil.uk", "mod.uk", - "national-library-scotland.uk", "nel.uk", "net.uk", "nhs.uk", "nic.uk", - "nls.uk", "org.uk", "orgn.uk", "parliament.uk", "plc.uk", "police.uk", - "sch.uk", "scot.uk", "soc.uk", - // .us - "4fd.us", "dni.us", "fed.us", "isa.us", "kids.us", "nsn.us", - // .uy - "com.uy", "edu.uy", "gub.uy", "mil.uy", "net.uy", "org.uy", - // .ve - "co.ve", "com.ve", "edu.ve", "gob.ve", "info.ve", "mil.ve", "net.ve", "org.ve", "web.ve", - // .vi - "co.vi", "com.vi", "k12.vi", "net.vi", "org.vi", - // .vn - "ac.vn", "biz.vn", "com.vn", "edu.vn", "gov.vn", "health.vn", "info.vn", - "int.vn", "name.vn", "net.vn", "org.vn", "pro.vn", - // .ye - "co.ye", "com.ye", "gov.ye", "ltd.ye", "me.ye", "net.ye", "org.ye", "plc.ye", - // .yu - "ac.yu", "co.yu", "edu.yu", "gov.yu", "org.yu", - // .za - "ac.za", "agric.za", "alt.za", "bourse.za", "city.za", "co.za", "cybernet.za", - "db.za", "edu.za", "gov.za", "grondar.za", "iaccess.za", "imt.za", "inca.za", - "landesign.za", "law.za", "mil.za", "net.za", "ngo.za", "nis.za", "nom.za", - "olivetti.za", "org.za", "pix.za", "school.za", "tm.za", "web.za", - // .zm - "ac.zm", "co.zm", "com.zm", "edu.zm", "gov.zm", "net.zm", "org.zm", "sch.zm", -]); - -function getRootDomain(domain: string): string { - const parts = domain.split("."); - if (parts.length <= 2) return domain; - const lastTwo = parts.slice(-2).join("."); - if (MULTI_PART_TLDS.has(lastTwo)) { - return parts.length >= 3 ? parts.slice(-3).join(".") : domain; - } - return parts.slice(-2).join("."); -} - function evictOldest() { if (cache.size < CACHE_MAX_SIZE) return; // Evict the oldest entry diff --git a/components/email/brand-verified-badge.tsx b/components/email/brand-verified-badge.tsx new file mode 100644 index 000000000..139300bc6 --- /dev/null +++ b/components/email/brand-verified-badge.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { useEffect, useId, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { BadgeCheck } from '@/components/icons'; +import { cn } from '@/lib/utils'; +import { IS_LITE } from '@/lib/lite'; +import { withBasePath } from '@/lib/browser-navigation'; +import { senderFaviconsAllowed } from '@/lib/outgoing-hosts'; +import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode'; +import { useSettingsStore } from '@/stores/settings-store'; +import type { BimiInfo } from '@/lib/bimi/types'; + +// One request per domain and tab; the answer is the same for every message. +const infoByDomain = new Map>(); + +function fetchInfo(domain: string): Promise { + let pending = infoByDomain.get(domain); + if (!pending) { + pending = fetch(withBasePath(`/api/bimi?domain=${encodeURIComponent(domain)}&format=json`)) + .then((response) => (response.ok ? (response.json() as Promise) : { status: 'none' as const })) + .catch(() => { + infoByDomain.delete(domain); + return { status: 'none' as const }; + }); + infoByDomain.set(domain, pending); + } + return pending; +} + +/** + * A check mark beside the sender's name when a Verified Mark or Common Mark + * Certificate backs the domain's BIMI logo. A logo the sender only published + * in DNS gets none: anyone can do that for a lookalike domain. + * + * `domain` comes from brandIndicatorDomain (the message passed DMARC for it). + * A tap opens a line naming who the logo is verified for and by whom. + */ +export function BrandVerifiedBadge({ domain, className }: { domain?: string; className?: string }) { + const t = useTranslations('email_viewer.brand_logo'); + const senderFavicons = useSettingsStore((s) => s.senderFavicons); + useNoThirdPartyTraffic(); + const enabled = !!domain && !IS_LITE && senderFavicons && senderFaviconsAllowed(); + const [info, setInfo] = useState<{ domain: string; info: BimiInfo } | null>(null); + const [open, setOpen] = useState(false); + const wrapRef = useRef(null); + const popoverId = useId(); + + useEffect(() => { + if (!enabled || !domain) return; + let cancelled = false; + void fetchInfo(domain).then((result) => { + if (!cancelled) setInfo({ domain, info: result }); + }); + return () => { cancelled = true; }; + }, [enabled, domain]); + + useEffect(() => { + if (!open) return; + const close = (e: Event) => { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('pointerdown', close); + document.addEventListener('focusin', close); + return () => { + document.removeEventListener('pointerdown', close); + document.removeEventListener('focusin', close); + }; + }, [open]); + + const current = enabled && info?.domain === domain ? info.info : null; + if (!current || current.status !== 'verified') return null; + + const organization = current.organization || domain!; + const description = current.issuer + ? t('verified_by', { organization, issuer: current.issuer }) + : t('verified', { organization }); + + return ( + + + {open && ( + + {description} + + )} + + ); +} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 9e66a84ba..cc7d6537b 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -48,7 +48,7 @@ import { useMeasuredTagDisplay } from "@/hooks/use-tag-display"; import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { useAccountTags, useEmailTagScope, type TagScope } from "@/hooks/use-account-tags"; import { getEmailTagIds, emailKeyFor } from "@/lib/thread-utils"; -import { getSecurityStatus, extractListHeaders, getSenderVerification } from "@/lib/email-headers"; +import { getSecurityStatus, extractListHeaders, getSenderVerification, brandIndicatorDomain } from "@/lib/email-headers"; import { emailToReadView } from "@/lib/plugin-projection"; import type { EmailReadView, ExternalLinkContext } from "@/lib/plugin-types"; import { ViewSourceDialog } from "./view-source-dialog"; @@ -170,6 +170,7 @@ import type { IJMAPClient } from "@/lib/jmap/client-interface"; import { useArchiveActionEnabled } from "@/lib/archive-gate"; import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode"; import { useSenderWarning } from "@/hooks/use-sender-warning"; +import { BrandVerifiedBadge } from "@/components/email/brand-verified-badge"; /** The More menu's two drill-downs: a folder list and a tag list. */ type MoreMenuSub = MessageSubmenuKey; @@ -751,7 +752,7 @@ function MvConversationCard({ email, onOpen, timeFormat, compact }: { email: Ema data-email-id={email.id} className={cn("w-full flex items-start py-3 border-t border-border text-start hover:bg-muted/40 transition-colors", compact ? "gap-3 px-4" : "gap-4 px-6")} > - + @@ -875,7 +876,7 @@ export function MvExpandedConversationCard({ data-mv-conversation-header="" className={cn("flex items-start pt-3 pb-2 cursor-pointer", compact ? "gap-3 px-4" : "gap-4 px-6")} > - + {sender?.name || sender?.email || ''} @@ -888,6 +889,7 @@ export function MvExpandedConversationCard({ {!compact && sender?.name && sender.email && ( <{sender.email}> )} + {compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)} @@ -4930,6 +4932,7 @@ export function EmailViewer({ size="lg" className="shadow-sm w-10 h-10 group-hover:ring-2 group-hover:ring-primary/30 transition-all" senderWarning={senderVerification ? senderVerificationLabel : undefined} + brandDomain={brandIndicatorDomain(email)} /> @@ -4952,6 +4955,7 @@ export function EmailViewer({ {mvViewer && sender?.email && sender?.name && ( <{sender.email}> )} + {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( @@ -5239,6 +5243,7 @@ export function EmailViewer({ size="lg" className="shadow-sm w-10 h-10 group-hover:ring-2 group-hover:ring-primary/30 transition-all" senderWarning={senderVerification ? senderVerificationLabel : undefined} + brandDomain={brandIndicatorDomain(email)} />
@@ -5254,6 +5259,7 @@ export function EmailViewer({ ) : ( {t('unknown_sender')} )} + {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( @@ -5447,6 +5453,7 @@ export function EmailViewer({ onViewContact={handleViewContactSidebar} className="text-sm text-start" /> + {senderVerification && ( )}
diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index 4bae67c11..89793f0e0 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -8,6 +8,7 @@ import { Email } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { Avatar } from "@/components/ui/avatar"; import { useSenderWarning } from "@/hooks/use-sender-warning"; +import { brandIndicatorDomain } from "@/lib/email-headers"; import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward, CalendarClock } from "@/components/icons"; import { useEmailDrag } from "@/hooks/use-email-drag"; import { useLongPress } from "@/hooks/use-long-press"; @@ -296,6 +297,7 @@ export function ThreadEmailItem({ size="sm" className="flex-shrink-0" senderWarning={senderWarning} + brandDomain={brandIndicatorDomain(email)} /> )} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 2f245cb50..f13bc0980 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -37,6 +37,7 @@ import { SearchSnippetText } from "./search-snippet-text"; import { PluginRowBadges } from "./plugin-row-badges"; import { useOwnDomainAddress } from "@/hooks/use-own-domain-address"; import { useSenderWarning } from "@/hooks/use-sender-warning"; +import { brandIndicatorDomain } from "@/lib/email-headers"; import { useTranslations } from "next-intl"; /** @@ -744,6 +745,7 @@ const SingleEmailItem = React.forwardRef( className="shadow-sm" disableImages={hideJunkAvatarImages} senderWarning={senderWarning} + brandDomain={showRecipient ? undefined : brandIndicatorDomain(email)} checked={isChecked} onToggle={handleCheckboxClick} selectLabel={selectRowLabel} @@ -1447,6 +1449,7 @@ export const ThreadListItem = React.forwardRef['email'] }) { const from = email.from?.[0]; const senderWarning = useSenderWarning(email); - return ; + return ; } /** diff --git a/components/icons/index.ts b/components/icons/index.ts index 6b1972953..a53e8ebb6 100644 --- a/components/icons/index.ts +++ b/components/icons/index.ts @@ -30,6 +30,7 @@ export { IconArrowsSort as ArrowUpDown, IconCircleArrowUp as ArrowUpCircle, IconBan as Ban, + IconRosetteDiscountCheckFilled as BadgeCheck, IconBaseline as Baseline, IconBell as Bell, IconBellOff as BellOff, diff --git a/components/ui/__tests__/avatar.test.tsx b/components/ui/__tests__/avatar.test.tsx index ea356ff44..a3e59c3c7 100644 --- a/components/ui/__tests__/avatar.test.tsx +++ b/components/ui/__tests__/avatar.test.tsx @@ -7,6 +7,7 @@ import { useFaviconRefreshStore } from '@/stores/favicon-refresh-store'; import { clearContactPhotoCache } from '@/lib/contact-photo-cache'; import type { ContactCard } from '@/lib/jmap/types'; import { setNoThirdPartyTraffic } from '@/lib/third-party-mode'; +import { useSettingsStore } from '@/stores/settings-store'; describe('Avatar', () => { it('renders initials from full name', () => { @@ -263,5 +264,71 @@ describe('Avatar', () => { expect(container.querySelector('img')).toBeNull(); expect(fetchBlob).not.toHaveBeenCalled(); }); + + it('shows the shield, not the brand logo, when both are given', () => { + const { container } = render(); + expect(container.querySelector('img')).toBeNull(); + }); + }); + + describe('brand logo (BIMI)', () => { + beforeEach(() => { + useFaviconRefreshStore.setState({ refreshedAt: 0 }); + useFaviconRefreshStore.getState().refresh(); + useFaviconRefreshStore.setState({ refreshedAt: 0 }); + }); + + it('shows the logo of a domain the message passed DMARC for, before the favicon', () => { + const { container } = render(); + expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/bimi?domain=brand.org'); + }); + + it('asks for no logo without a DMARC pass', () => { + const { container } = render(); + expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/favicon?domain=brand.org'); + }); + + it('asks for no logo when the avatar shows someone from another domain', () => { + const { container } = render(); + expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/favicon?domain=other.org'); + }); + + it('falls through to the favicon when the domain has no logo', () => { + const { container } = render(); + const img = container.querySelector('img')!; + // /api/bimi answers a 1x1 placeholder when there is no logo. + Object.defineProperty(img, 'naturalWidth', { value: 1 }); + fireEvent.load(img); + expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/favicon?domain=brand.org'); + // Other avatars of the domain don't ask again. + const { container: second } = render(); + expect(second.querySelector('img')?.getAttribute('src')).toBe('/api/favicon?domain=brand.org'); + }); + + it('keeps a contact photo first', async () => { + clearContactPhotoCache(); + const fetchBlob = vi.fn(async () => new Blob([new Uint8Array([0xff, 0xd8, 0xff])], { type: 'image/jpeg' })); + vi.spyOn(URL, 'createObjectURL').mockImplementation(() => 'blob:photo'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + useAuthStore.setState({ client: { fetchBlob } as never }); + try { + const { container } = render(); + await waitFor(() => expect(container.querySelector('img')?.getAttribute('src')).toBe('blob:photo')); + } finally { + useAuthStore.setState({ client: null }); + clearContactPhotoCache(); + vi.restoreAllMocks(); + } + }); + + it('asks for no logo while sender icons are off', () => { + useSettingsStore.setState({ senderFavicons: false }); + try { + const { container } = render(); + expect(container.querySelector('img')).toBeNull(); + } finally { + useSettingsStore.setState({ senderFavicons: true }); + } + }); }); }); diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx index 3171c3e0e..15b92c42b 100644 --- a/components/ui/avatar.tsx +++ b/components/ui/avatar.tsx @@ -13,7 +13,7 @@ import { withBasePath } from "@/lib/browser-navigation"; import { IS_LITE } from "@/lib/lite"; import { avatarUrlAllowed, sampleAvatarsAllowed, senderFaviconsAllowed } from "@/lib/outgoing-hosts"; import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode"; -import { useFaviconRefreshStore, failedFaviconDomains } from "@/stores/favicon-refresh-store"; +import { useFaviconRefreshStore, failedFaviconDomains, failedBrandDomains } from "@/stores/favicon-refresh-store"; const IS_DEV = process.env.NODE_ENV !== "production"; @@ -162,6 +162,12 @@ interface AvatarProps { * From doesn't get the real sender's logo or photo (#1439). */ senderWarning?: string; + /** + * The From domain of a message that passed DMARC for it (see + * brandIndicatorDomain): its BIMI logo, when it publishes one, comes before + * every source but a contact photo. + */ + brandDomain?: string; } /** @@ -176,8 +182,10 @@ function initialsColor(background: string): string { return 1.05 / (l + 0.05) >= (l + 0.05) / 0.05 ? '#ffffff' : '#000000'; } -export function Avatar({ name, email, contactPhoto, size = "md", className, disableImages = false, disableFavicon = false, fallbackColor, senderWarning }: AvatarProps) { +export function Avatar({ name, email, contactPhoto, size = "md", className, disableImages = false, disableFavicon = false, fallbackColor, senderWarning, brandDomain }: AvatarProps) { const [imgError, setImgError] = useState(false); + // The BIMI logo of this domain answered with no logo (or didn't load). + const [failedBrand, setFailedBrand] = useState(null); const [pluginAvatarUrl, setPluginAvatarUrl] = useState(null); const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false); // A contact photo that downloaded but would not render (an SVG made inert, @@ -216,6 +224,7 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa // A refresh of the sender icons gives a failed favicon another chance. useEffect(() => { setImgError(false); + setFailedBrand(null); }, [faviconRefreshedAt]); const domain = email?.split("@")[1]?.toLowerCase(); @@ -271,20 +280,32 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa const showFavicon = !IS_LITE && senderFaviconsAllowed() && !disableFavicon && senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed; - // Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials + // Priority: contact photo > BIMI logo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials const customAvatar = devMode && email && sampleAvatarsAllowed() ? CUSTOM_AVATARS[email.toLowerCase()] : null; // Gravatar and the like are outside hosts: initials instead in the no-third-party mode. const pluginAvatar = pluginAvatarFailed || !avatarUrlAllowed(pluginAvatarUrl) ? null : pluginAvatarUrl; - const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null; + // BIMI logos come from /api/bimi under the same switches as favicons, and + // only for the address shown: a thread row may show another participant + // than the message whose DMARC pass named the domain. + const showBrand = + !IS_LITE && senderFaviconsAllowed() && senderFavicons && !!brandDomain && domain === brandDomain && brandDomain !== failedBrand && !failedBrandDomains.has(brandDomain); + const brandSrc = showBrand ? withBasePath(`/api/bimi?domain=${encodeURIComponent(brandDomain!)}${faviconRefreshedAt ? `&v=${faviconRefreshedAt}` : ""}`) : null; + const photoSrc = resolvedContactPhoto || brandSrc || pluginAvatar || customAvatar || profilePic || null; const faviconSrc = !imgError && !domainFailed && showFavicon ? withBasePath(`/api/favicon?domain=${encodeURIComponent(faviconDomain!)}${faviconRefreshedAt ? `&v=${faviconRefreshedAt}` : ""}`) : null; const imgSrc = disableImages ? null : (photoSrc || faviconSrc); const isFavicon = imgSrc !== null && imgSrc === faviconSrc; + const isBrand = imgSrc !== null && imgSrc === brandSrc; const handleImgError = useCallback(() => { if (resolvedContactPhoto && imgSrc === resolvedContactPhoto) { setFailedContactPhoto(resolvedContactPhoto); return; } + if (brandSrc && brandDomain && imgSrc === brandSrc) { + failedBrandDomains.add(brandDomain); + setFailedBrand(brandDomain); + return; + } // If the plugin avatar just failed, mark it and fall through to the next source if (pluginAvatar && imgSrc === pluginAvatar) { setPluginAvatarFailed(true); @@ -295,7 +316,7 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa if (faviconDomain && !resolvedContactPhoto && !pluginAvatar && !customAvatar && !profilePic) { failedFaviconDomains.add(faviconDomain); } - }, [imgSrc, pluginAvatar, faviconDomain, resolvedContactPhoto, customAvatar, profilePic]); + }, [imgSrc, pluginAvatar, faviconDomain, resolvedContactPhoto, brandSrc, brandDomain, customAvatar, profilePic]); const initialsBackground = fallbackColor ?? getBackgroundColor(); @@ -324,7 +345,7 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa className )} style={{ - backgroundColor: imgSrc ? (isFavicon ? "#ffffff" : "transparent") : initialsBackground, + backgroundColor: imgSrc ? (isFavicon || isBrand ? "#ffffff" : "transparent") : initialsBackground, color: imgSrc ? undefined : initialsColor(initialsBackground), }} title={name || email} @@ -335,12 +356,12 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa alt="" className="w-full h-full object-cover" onError={handleImgError} - // /api/favicon returns a 1x1 transparent PNG (HTTP 200) when no real - // favicon exists, to avoid spamming the DevTools console with 404s. - // Detect that sentinel by naturalWidth and fall back to initials. + // /api/favicon and /api/bimi return a 1x1 transparent PNG (HTTP 200) + // when there is no icon, to avoid spamming the DevTools console with + // 404s. Detect that sentinel by naturalWidth and fall through. onLoad={(e) => { const img = e.currentTarget; - if (isFavicon && img.naturalWidth <= 1) { + if ((isFavicon || isBrand) && img.naturalWidth <= 1) { handleImgError(); } }} diff --git a/lib/__tests__/bimi-certificate.test.ts b/lib/__tests__/bimi-certificate.test.ts new file mode 100644 index 000000000..4cab392fa --- /dev/null +++ b/lib/__tests__/bimi-certificate.test.ts @@ -0,0 +1,130 @@ +// @vitest-environment node +import { describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { X509Certificate } from 'node:crypto'; +import path from 'node:path'; +import { parseCrl, sanMatches, validateMarkCertificate, type CertificateOptions } from '@/lib/bimi/certificate'; +import { bimiRoots } from '@/lib/bimi/roots'; + +// The test PKI comes from fixtures/bimi/generate.py; cnn-vmc.pem is a real VMC. +const dir = path.join(__dirname, 'fixtures', 'bimi'); +const pem = (name: string) => readFileSync(path.join(dir, `${name}.pem`), 'utf8'); +const root = new X509Certificate(pem('root')); +const otherRoot = new X509Certificate(pem('other-root')); +const intermediate = new X509Certificate(pem('intermediate')); +const crlDer = (() => { + const text = pem('intermediate.crl'); + return new Uint8Array(Buffer.from(text.replace(/-----[^-]+-----/g, '').replace(/\s+/g, ''), 'base64')); +})(); + +const NOW = Date.UTC(2026, 9, 29); +const base: CertificateOptions = { domains: ['brand.example'], selector: 'default', roots: [root], now: NOW }; +/** The leaf followed by its intermediate, as a sender's PEM file has them. */ +const bundle = (leaf: string) => `${pem(leaf)}\n${pem('intermediate')}`; + +describe('validateMarkCertificate', () => { + it('accepts a VMC chaining to a pinned root and returns the logo inside it', async () => { + const result = await validateMarkCertificate(bundle('leaf'), base); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.mark.svg).toContain('Brand Example'); + expect(result.mark.markType).toBe('verified'); + expect(result.mark.organization).toBe('Brand Example Ltd'); + expect(result.mark.issuer).toBe('Test Mark Authority'); + }); + + it('tells a CMC (prior use mark) from a VMC', async () => { + const result = await validateMarkCertificate(bundle('leaf-cmc'), base); + expect(result.ok && result.mark.markType).toBe('common'); + }); + + it('refuses a chain whose root is not pinned, even when the PEM carries that root', async () => { + const result = await validateMarkCertificate(`${pem('leaf-other')}\n${pem('other-root')}`, base); + expect(result).toEqual({ ok: false, reason: 'chain does not reach a trusted root' }); + // The same chain passes once its root is trusted, so the refusal is about the root. + expect((await validateMarkCertificate(pem('leaf-other'), { ...base, roots: [otherRoot] })).ok).toBe(true); + }); + + it('refuses a chain that leaves out the intermediate', async () => { + expect((await validateMarkCertificate(pem('leaf'), base)).ok).toBe(false); + }); + + it('refuses another domain', async () => { + const result = await validateMarkCertificate(bundle('leaf'), { ...base, domains: ['brand-example.com'] }); + expect(result).toEqual({ ok: false, reason: 'domain not in certificate' }); + }); + + it('refuses an expired certificate', async () => { + const result = await validateMarkCertificate(bundle('leaf-expired'), base); + expect(result).toEqual({ ok: false, reason: 'certificate outside its validity period' }); + }); + + it('refuses a certificate without the BIMI key purpose', async () => { + expect((await validateMarkCertificate(bundle('leaf-server'), base)).ok).toBe(false); + }); + + it('refuses a certificate without SCTs', async () => { + const result = await validateMarkCertificate(bundle('leaf-no-sct'), base); + expect(result).toEqual({ ok: false, reason: 'not logged to certificate transparency' }); + }); + + it('refuses a certificate without a logo', async () => { + const result = await validateMarkCertificate(bundle('leaf-no-logo'), base); + expect(result).toEqual({ ok: false, reason: 'no logo in certificate' }); + }); + + it('refuses a certificate its CA revoked, and asks the URL in the certificate', async () => { + const fetchCrl = vi.fn(async () => crlDer); + const revoked = await validateMarkCertificate(bundle('leaf-revoked'), { ...base, fetchCrl }); + expect(revoked).toEqual({ ok: false, reason: 'certificate revoked' }); + expect(fetchCrl).toHaveBeenCalledWith('http://crl.brand-ca.test/vmc.crl'); + expect((await validateMarkCertificate(bundle('leaf'), { ...base, fetchCrl })).ok).toBe(true); + }); + + it('accepts the certificate when the CRL cannot be fetched', async () => { + const result = await validateMarkCertificate(bundle('leaf-revoked'), { ...base, fetchCrl: async () => null }); + expect(result.ok).toBe(true); + }); + + it('ignores a CRL that the issuer did not sign', async () => { + // A CRL listing the leaf, but checked against the wrong key, proves nothing. + expect(parseCrl(crlDer, intermediate)?.revoked.has('BADC0DE')).toBe(true); + expect(parseCrl(crlDer, root)).toBeNull(); + }); + + it('accepts a SAN that pins the selector', async () => { + const options = { ...base, selector: 'brand' }; + expect((await validateMarkCertificate(bundle('leaf-selector'), options)).ok).toBe(true); + expect((await validateMarkCertificate(bundle('leaf-selector'), base)).ok).toBe(false); + }); + + it('accepts CNN\'s real VMC from DigiCert with the shipped roots', async () => { + const result = await validateMarkCertificate(pem('cnn-vmc'), { domains: ['cnn.com'], selector: 'default', roots: bimiRoots(), now: NOW }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.mark.organization).toBe('Cable News Network, Inc.'); + expect(result.mark.issuer).toBe('DigiCert, Inc.'); + expect(result.mark.svg).toMatch(/ { + it('matches the domain set for the default selector and the selector set otherwise', () => { + expect(sanMatches(['brand.example'], ['brand.example'], 'default')).toBe(true); + expect(sanMatches(['sel._bimi.brand.example'], ['brand.example'], 'sel')).toBe(true); + expect(sanMatches(['sel._bimi.brand.example'], ['brand.example'], 'default')).toBe(false); + expect(sanMatches(['other.example'], ['brand.example'], 'default')).toBe(false); + }); +}); + +describe('bimiRoots', () => { + it('parses every pinned root', () => { + const names = bimiRoots().map((cert) => /CN=([^\n]+)/.exec(cert.subject)?.[1]); + expect(names).toEqual([ + 'DigiCert Verified Mark Root CA', + 'GlobalSign Verified Mark Root R42', + 'SSL.com VMC RSA Root CA 2024', + 'SSL.com VMC ECC Root CA 2024', + ]); + }); +}); diff --git a/lib/__tests__/bimi-lookup.test.ts b/lib/__tests__/bimi-lookup.test.ts new file mode 100644 index 000000000..70ad925e9 --- /dev/null +++ b/lib/__tests__/bimi-lookup.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment node +import { describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { X509Certificate } from 'node:crypto'; +import path from 'node:path'; +import { createBimiResolver, normalizeBimiDomain } from '@/lib/bimi/lookup'; + +const LOGO = 'Brand'; +const fixtures = path.join(__dirname, 'fixtures', 'bimi'); +const fixture = (name: string) => readFileSync(path.join(fixtures, `${name}.pem`), 'utf8'); + +function dnsError(code: string) { + return Object.assign(new Error(code), { code }); +} + +/** A resolver over fake DNS records and URLs; unknown names are NXDOMAIN. */ +function setup(records: Record, urls: Record = {}, now = Date.UTC(2026, 9, 29)) { + const clock = { now }; + const resolveTxt = vi.fn(async (name: string) => { + if (records[name]) return records[name].map((r) => [r]); + throw dnsError('ENOTFOUND'); + }); + const fetchBytes = vi.fn(async (url: string) => (url in urls ? new Uint8Array(Buffer.from(urls[url])) : null)); + const resolver = createBimiResolver({ + resolveTxt, + fetchBytes, + roots: () => [new X509Certificate(fixture('root'))], + now: () => clock.now, + }); + return { resolver, resolveTxt, fetchBytes, clock }; +} + +describe('BIMI resolver', () => { + it('shows a self-published logo when DMARC enforces', async () => { + const { resolver } = setup( + { '_dmarc.brand.org': ['v=DMARC1; p=reject'], 'default._bimi.brand.org': ['v=BIMI1; l=https://cdn.brand.org/logo.svg'] }, + { 'https://cdn.brand.org/logo.svg': LOGO }, + ); + const indicator = await resolver.resolve('brand.org'); + expect(indicator?.status).toBe('self-asserted'); + expect(indicator?.png.subarray(1, 4).toString()).toBe('PNG'); + }); + + it('looks no further when DMARC does not enforce', async () => { + const { resolver, fetchBytes, resolveTxt } = setup( + { '_dmarc.brand.org': ['v=DMARC1; p=none'], 'default._bimi.brand.org': ['v=BIMI1; l=https://cdn.brand.org/logo.svg'] }, + { 'https://cdn.brand.org/logo.svg': LOGO }, + ); + expect(await resolver.resolve('brand.org')).toBeNull(); + expect(fetchBytes).not.toHaveBeenCalled(); + expect(resolveTxt).not.toHaveBeenCalledWith('default._bimi.brand.org'); + }); + + it('falls back to the organizational domain for a subdomain', async () => { + const { resolver, resolveTxt } = setup( + { '_dmarc.brand.org': ['v=DMARC1; p=reject'], 'default._bimi.brand.org': ['v=BIMI1; l=https://cdn.brand.org/logo.svg'] }, + { 'https://cdn.brand.org/logo.svg': LOGO }, + ); + expect((await resolver.resolve('news.brand.org'))?.status).toBe('self-asserted'); + expect(resolveTxt).toHaveBeenCalledWith('default._bimi.news.brand.org'); + }); + + it('shows the certificate\'s logo, not the l= one, when a valid VMC backs the record', async () => { + const { resolver } = setup( + { '_dmarc.brand.example': ['v=DMARC1; p=quarantine'], 'default._bimi.brand.example': ['v=BIMI1; l=https://cdn.brand.example/logo.svg; a=https://cdn.brand.example/vmc.pem'] }, + { 'https://cdn.brand.example/vmc.pem': `${fixture('leaf')}\n${fixture('intermediate')}` }, + ); + const indicator = await resolver.resolve('brand.example'); + expect(indicator?.status).toBe('verified'); + expect(indicator?.organization).toBe('Brand Example Ltd'); + // The certificate's logo is green (#0a6); the l= URL was never fetched. + const sharp = (await import('sharp')).default; + const { data } = await sharp(indicator!.png).raw().toBuffer({ resolveWithObject: true }); + expect([data[0], data[1], data[2]]).toEqual([0x00, 0xaa, 0x66]); + }); + + it('refuses the logo when the certificate does not validate, even with a valid l=', async () => { + const { resolver } = setup( + { '_dmarc.brand.org': ['v=DMARC1; p=reject'], 'default._bimi.brand.org': ['v=BIMI1; l=https://cdn.brand.org/logo.svg; a=https://cdn.brand.org/vmc.pem'] }, + // The test leaf names brand.example, not brand.org. + { 'https://cdn.brand.org/logo.svg': LOGO, 'https://cdn.brand.org/vmc.pem': `${fixture('leaf')}\n${fixture('intermediate')}` }, + ); + expect(await resolver.resolve('brand.org')).toBeNull(); + }); + + it('caches per domain and shares a lookup between concurrent callers', async () => { + const { resolver, resolveTxt } = setup( + { '_dmarc.brand.org': ['v=DMARC1; p=reject'], 'default._bimi.brand.org': ['v=BIMI1; l=https://cdn.brand.org/logo.svg'] }, + { 'https://cdn.brand.org/logo.svg': LOGO }, + ); + await Promise.all([resolver.resolve('brand.org'), resolver.resolve('Brand.org.')]); + await resolver.resolve('brand.org'); + expect(resolveTxt.mock.calls.filter(([name]) => name === '_dmarc.brand.org')).toHaveLength(1); + }); + + it('asks again soon after a DNS failure instead of hiding the logo for hours', async () => { + const { resolver, resolveTxt, clock } = setup({}); + resolveTxt.mockRejectedValueOnce(dnsError('ESERVFAIL')); + expect(await resolver.resolve('brand.org')).toBeNull(); + const calls = resolveTxt.mock.calls.length; + await resolver.resolve('brand.org'); + expect(resolveTxt.mock.calls.length).toBe(calls); + clock.now += 11 * 60 * 1000; + await resolver.resolve('brand.org'); + expect(resolveTxt.mock.calls.length).toBeGreaterThan(calls); + }); + + it('refuses names it must not look up', async () => { + const { resolver, resolveTxt } = setup({}); + for (const name of ['localhost', 'printer.local', 'nas.lan', 'x', '10.0.0.1.in-addr.arpa', 'a..b', 'brand.org/x']) { + expect(normalizeBimiDomain(name)).toBeNull(); + expect(await resolver.resolve(name)).toBeNull(); + } + expect(resolveTxt).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/__tests__/bimi-records.test.ts b/lib/__tests__/bimi-records.test.ts new file mode 100644 index 000000000..7e0cc2a41 --- /dev/null +++ b/lib/__tests__/bimi-records.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { dmarcAllowsBimi, pickBimiRecord, pickDmarcRecord, type DmarcRecord } from '@/lib/bimi/records'; + +describe('pickBimiRecord', () => { + it('reads the logo and certificate locations', () => { + expect(pickBimiRecord(['v=BIMI1; l=https://brand.example/logo.svg; a=https://brand.example/vmc.pem;'])).toEqual({ + logoUrl: 'https://brand.example/logo.svg', + evidenceUrl: 'https://brand.example/vmc.pem', + declined: false, + }); + }); + + it('ignores the SPF record a wildcard TXT answers with', () => { + // netflix.com and coinbase.com answer default._bimi queries this way. + expect(pickBimiRecord(['v=spf1 include:_spf.example.com -all'])).toBeNull(); + expect(pickBimiRecord(['v=spf1 -all', 'v=BIMI1;l=https://brand.example/logo.svg'])?.logoUrl).toBe('https://brand.example/logo.svg'); + }); + + it('refuses two assertion records', () => { + expect(pickBimiRecord(['v=BIMI1; l=https://a.example/1.svg', 'v=BIMI1; l=https://a.example/2.svg'])).toBeNull(); + }); + + it('treats empty l= and a= as declining', () => { + expect(pickBimiRecord(['v=BIMI1; l=; a=;'])?.declined).toBe(true); + // uber.com: a logo, an empty a=. + expect(pickBimiRecord(['v=BIMI1; l=https://brand.example/logo.svg; a='])).toEqual({ + logoUrl: 'https://brand.example/logo.svg', evidenceUrl: undefined, declined: false, + }); + }); + + it('refuses locations that are not https', () => { + expect(pickBimiRecord(['v=BIMI1; l=http://brand.example/logo.svg'])).toBeNull(); + expect(pickBimiRecord(['v=BIMI1; l=https://brand.example/logo.svg; a=ftp://brand.example/vmc.pem'])).toBeNull(); + }); +}); + +describe('pickDmarcRecord', () => { + it('reads p, sp and pct', () => { + expect(pickDmarcRecord(['v=DMARC1; p=quarantine; sp=reject; pct=100; rua=mailto:d@example.com'])).toEqual({ p: 'quarantine', sp: 'reject', pct: 100 }); + expect(pickDmarcRecord(['v=DMARC1;p=reject'])).toEqual({ p: 'reject', sp: undefined, pct: 100 }); + }); + + it('refuses a record without a valid p', () => { + expect(pickDmarcRecord(['v=DMARC1; rua=mailto:d@example.com'])).toBeNull(); + expect(pickDmarcRecord(['v=spf1 -all'])).toBeNull(); + }); +}); + +describe('dmarcAllowsBimi', () => { + const reject: DmarcRecord = { p: 'reject', pct: 100 }; + const quarantine: DmarcRecord = { p: 'quarantine', pct: 100 }; + const none: DmarcRecord = { p: 'none', pct: 100 }; + + it('needs quarantine or reject for every message', () => { + expect(dmarcAllowsBimi(reject, reject, false)).toBe(true); + expect(dmarcAllowsBimi(quarantine, quarantine, false)).toBe(true); + expect(dmarcAllowsBimi(none, none, false)).toBe(false); + expect(dmarcAllowsBimi({ p: 'reject', pct: 50 }, null, false)).toBe(false); + expect(dmarcAllowsBimi(null, null, false)).toBe(false); + }); + + it('applies the organizational domain\'s sp to a subdomain without its own record', () => { + expect(dmarcAllowsBimi(null, { p: 'reject', sp: 'none', pct: 100 }, true)).toBe(false); + expect(dmarcAllowsBimi(null, { p: 'quarantine', sp: 'reject', pct: 100 }, true)).toBe(true); + expect(dmarcAllowsBimi(null, reject, true)).toBe(true); + }); + + it('refuses a subdomain whose organizational domain is p=none, whatever the subdomain says', () => { + expect(dmarcAllowsBimi(reject, none, true)).toBe(false); + }); +}); diff --git a/lib/__tests__/bimi-route.test.ts b/lib/__tests__/bimi-route.test.ts new file mode 100644 index 000000000..086f5fe7d --- /dev/null +++ b/lib/__tests__/bimi-route.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment node +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { NextRequest } from 'next/server'; +import type { BimiIndicator } from '@/lib/bimi/lookup'; + +const resolve = vi.hoisted(() => vi.fn(async (_domain: string): Promise => null)); +vi.mock('@/lib/bimi/lookup', async (importOriginal) => ({ + ...(await importOriginal()), + bimiResolver: () => ({ resolve, clear: () => {} }), +})); + +import { configManager } from '@/lib/admin/config-manager'; +import { GET } from '@/app/api/bimi/route'; + +const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452000000c0000000c0', 'hex'); +const verified: BimiIndicator = { status: 'verified', png: PNG, markType: 'verified', organization: 'Brand Ltd', issuer: 'DigiCert, Inc.' }; +const selfAsserted: BimiIndicator = { status: 'self-asserted', png: PNG }; + +let dir: string; +async function setConfig(config: Record) { + writeFileSync(path.join(dir, 'config.json'), JSON.stringify(config)); + await configManager.load(); +} +const get = (query: string) => GET(new NextRequest(`https://webmail.example/api/bimi?${query}`)); + +beforeAll(() => { + dir = mkdtempSync(path.join(tmpdir(), 'bulwark-bimi-')); + process.env.ADMIN_CONFIG_DIR = dir; + delete process.env.NO_THIRD_PARTY_TRAFFIC; + delete process.env.BIMI_REQUIRE_CERTIFICATE; +}); +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + delete process.env.ADMIN_CONFIG_DIR; +}); +beforeEach(async () => { + resolve.mockReset(); + await setConfig({}); +}); + +describe('/api/bimi', () => { + it('answers the logo as a PNG that cannot run anything', async () => { + resolve.mockResolvedValue(selfAsserted); + const res = await get('domain=brand.org'); + expect(res.headers.get('content-type')).toBe('image/png'); + expect(res.headers.get('x-bulwark-bimi')).toBe('self-asserted'); + expect(res.headers.get('content-security-policy')).toContain('sandbox'); + expect(Buffer.from(await res.arrayBuffer())).toEqual(PNG); + expect(resolve).toHaveBeenCalledWith('brand.org'); + }); + + it('answers the 1x1 placeholder when there is no logo', async () => { + resolve.mockResolvedValue(null); + const res = await get('domain=brand.org'); + expect(res.status).toBe(200); + expect(res.headers.get('x-bulwark-bimi')).toBe('missing'); + const bytes = Buffer.from(await res.arrayBuffer()); + // PNG width and height from the IHDR chunk. + expect([bytes.readUInt32BE(16), bytes.readUInt32BE(20)]).toEqual([1, 1]); + }); + + it('drops self-published logos when the admin requires a mark certificate', async () => { + await setConfig({ bimiRequireCertificate: true }); + resolve.mockResolvedValue(selfAsserted); + expect((await get('domain=brand.org')).headers.get('x-bulwark-bimi')).toBe('missing'); + expect(await (await get('domain=brand.org&format=json')).json()).toEqual({ status: 'none' }); + + resolve.mockResolvedValue(verified); + expect((await get('domain=brand.org')).headers.get('x-bulwark-bimi')).toBe('verified'); + }); + + it('describes what backs the logo in JSON', async () => { + resolve.mockResolvedValue(verified); + expect(await (await get('domain=brand.org&format=json')).json()).toEqual({ + status: 'verified', markType: 'verified', organization: 'Brand Ltd', issuer: 'DigiCert, Inc.', + }); + }); + + it('refuses a missing or local domain without a lookup', async () => { + expect((await get('')).status).toBe(400); + expect((await get('domain=localhost')).status).toBe(400); + expect(resolve).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/__tests__/bimi-svg.test.ts b/lib/__tests__/bimi-svg.test.ts new file mode 100644 index 000000000..28e7ccb05 --- /dev/null +++ b/lib/__tests__/bimi-svg.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import sharp from 'sharp'; +import { checkSvg, rasterize, INDICATOR_SIZE } from '@/lib/bimi/svg'; + +const svg = (body: string, root = 'version="1.2" baseProfile="tiny-ps" viewBox="0 0 100 100"') => + `Brand${body}`; +const strict = { strictProfile: true }; +const lenient = { strictProfile: false }; + +describe('checkSvg', () => { + it('accepts a Tiny PS logo', () => { + expect(checkSvg(svg(''), strict)).toBeNull(); + }); + + it('refuses scripts, event handlers and foreign content', () => { + expect(checkSvg(svg(''), lenient)).toMatch(/script/); + expect(checkSvg(svg(''), lenient)).toBe('event handler'); + expect(checkSvg(svg('
'), lenient)).toMatch(/foreignobject/); + expect(checkSvg(svg(''), lenient)).toMatch(/animate/); + }); + + it('refuses anything loaded from elsewhere', () => { + expect(checkSvg(svg(''), lenient)).toBe('external reference'); + expect(checkSvg(svg(''), lenient)).toBe('external reference'); + expect(checkSvg(svg(''), lenient)).toBe('external reference'); + // References inside the document are fine. + expect(checkSvg(svg(''), lenient)).toBeNull(); + }); + + it('refuses entity declarations', () => { + expect(checkSvg(`]>${svg('')}`, lenient)).toBe('doctype'); + }); + + it('refuses a logo that isn\'t square', () => { + expect(checkSvg(svg('', 'version="1.2" baseProfile="tiny-ps" viewBox="0 0 200 100"'), lenient)).toBe('not square'); + }); + + it('holds self-published logos to the profile, certificate logos only to safety', () => { + const plain = svg('', 'viewBox="0 0 100 100"'); + expect(checkSvg(plain, strict)).toBe('not tiny-ps'); + expect(checkSvg(plain, lenient)).toBeNull(); + const big = svg(`${'x'.repeat(40 * 1024)}`); + expect(checkSvg(big, strict)).toBe('too large'); + expect(checkSvg(big, lenient)).toBeNull(); + }); +}); + +describe('rasterize', () => { + it('draws a square PNG of the indicator size', async () => { + const png = await rasterize(svg('')); + const meta = await sharp(png).metadata(); + expect(meta.format).toBe('png'); + expect([meta.width, meta.height]).toEqual([INDICATOR_SIZE, INDICATOR_SIZE]); + const { data } = await sharp(png).raw().toBuffer({ resolveWithObject: true }); + // The fill colour, not an empty canvas. + expect([data[0], data[1], data[2]]).toEqual([0x00, 0xaa, 0x66]); + }); +}); diff --git a/lib/__tests__/brand-indicator-domain.test.ts b/lib/__tests__/brand-indicator-domain.test.ts new file mode 100644 index 000000000..217d6a0d2 --- /dev/null +++ b/lib/__tests__/brand-indicator-domain.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { brandIndicatorDomain, parseAuthenticationResults } from '@/lib/email-headers'; + +const message = (from: string, ...headers: string[]) => ({ + from: [{ email: from }], + authenticationResults: headers.length ? parseAuthenticationResults(headers) : undefined, +}); + +describe('brandIndicatorDomain', () => { + it('names the From domain of a message that passed DMARC for it', () => { + const email = message('news@Brand.org', 'mx.example; dkim=pass header.d=brand.org; spf=pass smtp.mailfrom=bounce.brand.org; dmarc=pass header.from=brand.org'); + expect(brandIndicatorDomain(email)).toBe('brand.org'); + }); + + it('needs a DMARC verdict, not only an aligned DKIM pass', () => { + expect(brandIndicatorDomain(message('news@brand.org', 'mx.example; dkim=pass header.d=brand.org; spf=pass smtp.mailfrom=brand.org'))).toBeUndefined(); + expect(brandIndicatorDomain(message('news@brand.org', 'mx.example; dkim=pass header.d=brand.org; dmarc=none header.from=brand.org'))).toBeUndefined(); + }); + + it('refuses a pass the server recorded for another header.from', () => { + expect(brandIndicatorDomain(message('ceo@bank.com', 'mx.example; dkim=pass header.d=lookalike.com; dmarc=pass header.from=lookalike.com'))).toBeUndefined(); + }); + + it('trusts only the receiving server\'s own header, the topmost', () => { + const forged = message( + 'ceo@bank.com', + 'mx.example; spf=fail smtp.mailfrom=bank.com; dmarc=fail header.from=bank.com', + 'mx.example; dkim=pass header.d=bank.com; dmarc=pass header.from=bank.com', + ); + expect(brandIndicatorDomain(forged)).toBeUndefined(); + }); + + it('gives nothing without results or a From', () => { + expect(brandIndicatorDomain(message('news@brand.org'))).toBeUndefined(); + expect(brandIndicatorDomain({ from: [], authenticationResults: parseAuthenticationResults('mx.example; dmarc=pass header.from=brand.org') })).toBeUndefined(); + expect(brandIndicatorDomain(undefined)).toBeUndefined(); + }); +}); diff --git a/lib/__tests__/fixtures/bimi/generate.py b/lib/__tests__/fixtures/bimi/generate.py new file mode 100644 index 000000000..8dcb58660 --- /dev/null +++ b/lib/__tests__/fixtures/bimi/generate.py @@ -0,0 +1,158 @@ +""" +Makes the test mark-certificate PKI the BIMI tests use (lib/__tests__/bimi-certificate.test.ts): +a root, an intermediate with the BIMI key purpose, leaves with and without +each required extension, and a CRL from the intermediate that revokes +leaf-revoked. Needs OpenSSL 3.4 or newer. + + python generate.py + +Copy the .pem files (never the keys) back here. The certificates run from +2026 to 2036; the tests pin the time they check at. + +cnn-vmc.pem is not made here: it is CNN's real VMC from DigiCert, as its +BIMI record served it in October 2026. +""" +import base64, gzip, hashlib, os, subprocess, sys + +out = sys.argv[1] +os.makedirs(out, exist_ok=False) +os.chdir(out) + + +def run(*args): + r = subprocess.run(args, capture_output=True, text=True) + if r.returncode: + print(' '.join(args), r.stderr) + sys.exit(1) + return r.stdout + + +def tlv(tag, content): + n = len(content) + if n < 0x80: + length = bytes([n]) + else: + b = n.to_bytes((n.bit_length() + 7) // 8, 'big') + length = bytes([0x80 | len(b)]) + b + return bytes([tag]) + length + content + + +def oid(s): + p = [int(x) for x in s.split('.')] + body = bytes([40 * p[0] + p[1]]) + for v in p[2:]: + enc = [v & 0x7f] + v >>= 7 + while v: + enc.append(0x80 | (v & 0x7f)) + v >>= 7 + body += bytes(reversed(enc)) + return tlv(0x06, body) + + +SVG = b'Brand Example' +gz = gzip.compress(SVG, mtime=0) +uri = b'data:image/svg+xml;base64,' + base64.b64encode(gz) +hav = tlv(0x30, tlv(0x30, oid('2.16.840.1.101.3.4.2.1') + b'\x05\x00') + tlv(0x04, hashlib.sha256(gz).digest())) +details = tlv(0x30, tlv(0x16, b'image/svg+xml') + tlv(0x30, hav) + tlv(0x30, tlv(0x16, uri))) +image = tlv(0x30, details) +logotype = tlv(0x30, tlv(0xa2, tlv(0xa0, tlv(0x30, image)))) +LOGO = logotype.hex() +SCT = tlv(0x04, b'\x00\x00').hex() +BIMI = '1.3.6.1.5.5.7.3.31' + +with open('openssl.cnf', 'w') as f: + f.write(f""" +oid_section = new_oids +[ new_oids ] +markType = 1.3.6.1.4.1.53087.1.13 +[ req ] +distinguished_name = dn +prompt = no +[ dn ] +CN = placeholder +[ root_ext ] +basicConstraints = critical,CA:true +keyUsage = critical,keyCertSign,cRLSign +subjectKeyIdentifier = hash +[ int_ext ] +basicConstraints = critical,CA:true,pathlen:0 +keyUsage = critical,keyCertSign,cRLSign +extendedKeyUsage = {BIMI} +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid +[ leaf_ext ] +basicConstraints = critical,CA:false +keyUsage = critical,digitalSignature +extendedKeyUsage = {BIMI} +subjectAltName = DNS:brand.example +crlDistributionPoints = URI:http://crl.brand-ca.test/vmc.crl +1.3.6.1.5.5.7.1.12 = DER:{LOGO} +1.3.6.1.4.1.11129.2.4.2 = DER:{SCT} +authorityKeyIdentifier = keyid +[ leaf_selector_ext ] +basicConstraints = critical,CA:false +extendedKeyUsage = {BIMI} +subjectAltName = DNS:brand._bimi.brand.example +crlDistributionPoints = URI:http://crl.brand-ca.test/vmc.crl +1.3.6.1.5.5.7.1.12 = DER:{LOGO} +1.3.6.1.4.1.11129.2.4.2 = DER:{SCT} +[ leaf_no_sct_ext ] +basicConstraints = critical,CA:false +extendedKeyUsage = {BIMI} +subjectAltName = DNS:brand.example +1.3.6.1.5.5.7.1.12 = DER:{LOGO} +[ leaf_no_logo_ext ] +basicConstraints = critical,CA:false +extendedKeyUsage = {BIMI} +subjectAltName = DNS:brand.example +1.3.6.1.4.1.11129.2.4.2 = DER:{SCT} +[ leaf_server_ext ] +basicConstraints = critical,CA:false +extendedKeyUsage = serverAuth +subjectAltName = DNS:brand.example +1.3.6.1.5.5.7.1.12 = DER:{LOGO} +1.3.6.1.4.1.11129.2.4.2 = DER:{SCT} +[ ca ] +default_ca = int_ca +[ int_ca ] +database = index.txt +crlnumber = crlnumber +default_md = sha256 +default_crl_days = 3650 +""") +open('index.txt', 'w').close() +with open('crlnumber', 'w') as f: + f.write('01\n') + + +def cert(name, subj, issuer, ext, nb='20260101000000Z', na='20360101000000Z', serial=None): + run('openssl', 'genrsa', '-out', f'{name}.key', '2048') + run('openssl', 'req', '-new', '-key', f'{name}.key', '-subj', subj, '-out', f'{name}.csr', '-config', 'openssl.cnf') + args = ['openssl', 'x509', '-req', '-in', f'{name}.csr', '-out', f'{name}.pem', '-extfile', 'openssl.cnf', + '-extensions', ext, '-not_before', nb, '-not_after', na, '-sha256', + '-set_serial', serial or str(int.from_bytes(os.urandom(8), 'big'))] + if issuer: + args += ['-CA', f'{issuer}.pem', '-CAkey', f'{issuer}.key'] + else: + args += ['-key', f'{name}.key'] + run(*args) + + +cert('root', '/C=XX/O=Test Mark Authority/CN=Test Verified Mark Root', None, 'root_ext') +cert('other-root', '/C=XX/O=Other Authority/CN=Test Verified Mark Root', None, 'root_ext') +cert('intermediate', '/C=XX/O=Test Mark Authority/CN=Test Verified Mark CA', 'root', 'int_ext') +LEAF = '/C=XX/O=Brand Example Ltd/CN=Brand Example Ltd/markType=Registered Mark' +cert('leaf', LEAF, 'intermediate', 'leaf_ext') +cert('leaf-cmc', '/C=XX/O=Brand Example Ltd/CN=Brand Example Ltd/markType=Prior Use Mark', 'intermediate', 'leaf_ext') +cert('leaf-selector', LEAF, 'intermediate', 'leaf_selector_ext') +cert('leaf-no-sct', LEAF, 'intermediate', 'leaf_no_sct_ext') +cert('leaf-no-logo', LEAF, 'intermediate', 'leaf_no_logo_ext') +cert('leaf-server', LEAF, 'intermediate', 'leaf_server_ext') +cert('leaf-expired', LEAF, 'intermediate', 'leaf_ext', nb='20240101000000Z', na='20250101000000Z') +cert('leaf-revoked', LEAF, 'intermediate', 'leaf_ext', serial='0x0BADC0DE') +cert('leaf-other', LEAF, 'other-root', 'leaf_ext') +run('openssl', 'ca', '-config', 'openssl.cnf', '-revoke', 'leaf-revoked.pem', '-keyfile', 'intermediate.key', '-cert', 'intermediate.pem') +run('openssl', 'ca', '-config', 'openssl.cnf', '-gencrl', '-keyfile', 'intermediate.key', '-cert', 'intermediate.pem', '-out', 'intermediate.crl.pem') +run('openssl', 'crl', '-in', 'intermediate.crl.pem', '-outform', 'DER', '-out', 'intermediate.crl') +print(run('openssl', 'x509', '-in', 'leaf.pem', '-noout', '-text')[:1800]) diff --git a/lib/__tests__/outgoing-hosts-mode.test.ts b/lib/__tests__/outgoing-hosts-mode.test.ts index 71f52778c..fd4b4d713 100644 --- a/lib/__tests__/outgoing-hosts-mode.test.ts +++ b/lib/__tests__/outgoing-hosts-mode.test.ts @@ -25,6 +25,12 @@ vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: vi.fn(async () => ({ serverUrl: 'https://mail.example.com', authHeader: 'Basic x', username: 'u' })), })); vi.mock('@/lib/security/same-origin', () => ({ rejectCrossOriginRequest: () => null })); +// The BIMI route would query real DNS; the probe only needs to see whether it gets that far. +const bimiResolve = vi.hoisted(() => vi.fn(async (_domain: string) => null)); +vi.mock('@/lib/bimi/lookup', async (importOriginal) => ({ + ...(await importOriginal()), + bimiResolver: () => ({ resolve: bimiResolve, clear: () => {} }), +})); import { configManager } from '@/lib/admin/config-manager'; @@ -70,6 +76,12 @@ const probes: Record Promise> = { await GET(new NextRequest(`https://webmail.example/api/favicon?domain=probe${Date.now()}.org`)); return fetchedHosts().includes('icons.duckduckgo.com') || senderFaviconsAllowed(); }, + bimi: async () => { + const { GET } = await import('@/app/api/bimi/route'); + bimiResolve.mockClear(); + await GET(new NextRequest('https://webmail.example/api/bimi?domain=probe.org')); + return bimiResolve.mock.calls.length > 0; + }, translate: async () => { const { POST } = await import('@/app/api/translate/route'); await POST(new NextRequest('https://webmail.example/api/translate', { diff --git a/lib/admin/admin-nav.ts b/lib/admin/admin-nav.ts index 98ae3791d..5b336e453 100644 --- a/lib/admin/admin-nav.ts +++ b/lib/admin/admin-nav.ts @@ -80,6 +80,7 @@ export const ADMIN_PAGES: readonly AdminPageDef[] = [ { id: 'login', title: 'Login page', keywords: 'version 2fa totp code access token remember me heading subtitle' }, { id: 'servers', title: 'JMAP servers', keywords: 'multi server domain auto pick list' }, { id: 'wopi', title: 'Document editing', keywords: 'wopi collabora onlyoffice office files host url' }, + { id: 'bimi', title: 'Brand logos', keywords: 'bimi vmc cmc mark certificate sender logo icon dmarc' }, { id: 'logging', title: 'Logging', keywords: 'log level format json text debug' }, { id: 'sync', title: 'Settings sync', keywords: 'session secret devices' }, ], diff --git a/lib/admin/outgoing-hosts-registry.ts b/lib/admin/outgoing-hosts-registry.ts index 2eb0ab5e1..9fab19789 100644 --- a/lib/admin/outgoing-hosts-registry.ts +++ b/lib/admin/outgoing-hosts-registry.ts @@ -94,6 +94,18 @@ export const OUTGOING_HOSTS: readonly OutgoingHost[] = [ inMode: 'Avatars show initials; /api/favicon answers 403 before any request.', lite: false, }, + { + id: 'bimi', + host: ' (BIMI)', + // The hosts come from the sender's DNS records; no literal in the source. + literals: [], + files: [], + side: 'server', + usedBy: 'Brand logos (BIMI) for messages that pass DMARC, looked up by /api/bimi: TXT records at the sender\'s domain, then the logo, mark certificate and CRL they name.', + off: 'Users can turn sender icons off in Settings > Message list.', + inMode: 'Avatars show no brand logos; /api/bimi answers 403 before any lookup.', + lite: false, + }, { id: 'translate', host: 'api.mymemory.translated.net', diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 3908ce360..45c005261 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -380,6 +380,10 @@ export const CONFIG_ENV_MAP: Record/hosting/discovery` unless the URL already carries a path. diff --git a/lib/bimi/certificate.ts b/lib/bimi/certificate.ts new file mode 100644 index 000000000..0be758853 --- /dev/null +++ b/lib/bimi/certificate.ts @@ -0,0 +1,298 @@ +import { X509Certificate, verify as verifySignature } from 'node:crypto'; +import { gunzipSync } from 'node:zlib'; +import { + certificateExtensions, + children, + content, + decodeOid, + encoding, + integerHex, + normalizeSerial, + readNode, + stringsIn, + type DerNode, +} from './der'; + +/** + * Validation of a BIMI evidence document: the PEM chain of a Verified Mark + * Certificate (VMC) or Common Mark Certificate (CMC), following + * draft-fetch-validation-vmc-wchuang. The indicator shown is the SVG inside + * the certificate, so a valid chain also fixes which logo appears. + * + * Not checked: the signatures on the embedded SCTs (that needs the CT log + * list); the certificate only has to carry them. + */ + +/** id-kp-BrandIndicatorforMessageIdentification */ +export const BIMI_EKU = '1.3.6.1.5.5.7.3.31'; +const LOGOTYPE_OID = '1.3.6.1.5.5.7.1.12'; +const SCT_LIST_OID = '1.3.6.1.4.1.11129.2.4.2'; +const CRL_DISTRIBUTION_POINTS_OID = '2.5.29.31'; +const MARK_TYPE_OID = '1.3.6.1.4.1.53087.1.13'; +const MAX_CHAIN = 6; +const MAX_SVG_BYTES = 64 * 1024; + +export type MarkType = 'verified' | 'common'; + +export interface MarkCertificate { + svg: string; + /** VMC (registered or government mark) or CMC (prior use mark). */ + markType: MarkType; + /** The organization the mark belongs to, from the certificate subject. */ + organization?: string; + /** The issuing CA's organization. */ + issuer?: string; + /** When the certificate expires (ms since the epoch). */ + notAfter: number; +} + +export type CertificateCheck = + | { ok: true; mark: MarkCertificate } + | { ok: false; reason: string }; + +export interface CertificateOptions { + /** Where the assertion record was found and its organizational domain. */ + domains: readonly string[]; + selector: string; + roots: readonly X509Certificate[]; + now?: number; + /** + * Fetches a CRL. Null when it can't be had, which counts as not revoked: + * like browsers, an unreachable CRL doesn't fail the check. + */ + fetchCrl?: (url: string) => Promise; +} + +const PEM_BLOCK = /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g; + +export function splitPem(pem: string): X509Certificate[] { + return (pem.match(PEM_BLOCK) ?? []).map((block) => new X509Certificate(block)); +} + +function hasBimiEku(cert: X509Certificate): boolean { + return Array.isArray(cert.keyUsage) && cert.keyUsage.includes(BIMI_EKU); +} + +function within(cert: X509Certificate, now: number): boolean { + const from = Date.parse(cert.validFrom); + const to = Date.parse(cert.validTo); + return Number.isFinite(from) && Number.isFinite(to) && from <= now && now <= to; +} + +function issuedBy(cert: X509Certificate, issuer: X509Certificate): boolean { + try { + return cert.checkIssued(issuer) && cert.verify(issuer.publicKey); + } catch { + return false; + } +} + +/** Values of one attribute in an X509Certificate subject or issuer string. */ +function attribute(name: string, field: string): string | undefined { + for (const line of name.split('\n')) { + const eq = line.indexOf('='); + if (eq > 0 && line.slice(0, eq) === field) return line.slice(eq + 1).replace(/\\(.)/g, '$1'); + } + return undefined; +} + +function sanDnsNames(cert: X509Certificate): string[] { + return (cert.subjectAltName ?? '') + .split(/,\s*/) + .filter((entry) => entry.startsWith('DNS:')) + .map((entry) => entry.slice(4).trim().toLowerCase().replace(/\.$/, '')); +} + +/** + * The SAN rule of the draft (section 5.2): names with a "_bimi" label pin a + * selector, the others cover the default one. + */ +export function sanMatches(names: readonly string[], domains: readonly string[], selector: string): boolean { + const selectorSet = names.filter((name) => name.split('.').includes('_bimi')); + const domainSet = names.filter((name) => !name.split('.').includes('_bimi')); + return domains.some((domain) => { + const d = domain.toLowerCase(); + return selectorSet.includes(`${selector.toLowerCase()}._bimi.${d}`) || domainSet.includes(d); + }); +} + +/** The SVG in the logotype extension: a data: URI holding gzip-compressed, base64-encoded SVG. */ +export function logotypeSvg(extension: Uint8Array): string | null { + for (const uri of stringsIn(extension, 0x16)) { + const match = /^data:image\/svg\+xml(?:;[^,]*)?;base64,([\s\S]+)$/i.exec(uri.trim()); + if (!match) continue; + let bytes: Buffer = Buffer.from(match[1].replace(/\s+/g, ''), 'base64'); + if (bytes[0] === 0x1f && bytes[1] === 0x8b) { + try { + bytes = gunzipSync(bytes, { maxOutputLength: MAX_SVG_BYTES }); + } catch { + return null; + } + } + if (bytes.length === 0 || bytes.length > MAX_SVG_BYTES) return null; + return bytes.toString('utf8'); + } + return null; +} + +function crlUrls(cert: X509Certificate): string[] { + const value = certificateExtensions(new Uint8Array(cert.raw)).get(CRL_DISTRIBUTION_POINTS_OID); + if (!value) return []; + // GeneralName uniformResourceIdentifier is [6] IMPLICIT IA5String. + return stringsIn(value, 0x86).filter((url) => /^https?:\/\//i.test(url)); +} + +const SIGNATURE_HASHES: Record = { + '1.2.840.113549.1.1.11': 'sha256', + '1.2.840.113549.1.1.12': 'sha384', + '1.2.840.113549.1.1.13': 'sha512', + '1.2.840.10045.4.3.2': 'sha256', + '1.2.840.10045.4.3.3': 'sha384', + '1.2.840.10045.4.3.4': 'sha512', +}; + +function parseTime(buf: Uint8Array, node: DerNode): number { + const text = Buffer.from(content(buf, node)).toString('latin1'); + const m = node.tag === 0x17 + ? /^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(text) + : /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(text); + if (!m) return NaN; + let year = Number(m[1]); + if (node.tag === 0x17) year += year < 50 ? 2000 : 1900; + return Date.UTC(year, Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6])); +} + +export interface ParsedCrl { + revoked: Set; + nextUpdate?: number; +} + +/** + * Read a DER CertificateList and check its signature with the issuer's key. + * Null when it doesn't parse, uses an algorithm we don't know, or isn't + * signed by `issuer`. + */ +export function parseCrl(der: Uint8Array, issuer: X509Certificate): ParsedCrl | null { + try { + const list = readNode(der, 0); + const [tbs, algorithm, signature] = children(der, list); + if (!tbs || !algorithm || !signature || signature.tag !== 0x03) return null; + const algorithmOid = children(der, algorithm)[0]; + const hash = SIGNATURE_HASHES[decodeOid(content(der, algorithmOid))]; + if (!hash) return null; + // BIT STRING: the first content byte counts the unused bits. + const signatureBytes = content(der, signature).subarray(1); + if (!verifySignature(hash, encoding(der, tbs), issuer.publicKey, signatureBytes)) return null; + + const fields = children(der, tbs); + let i = fields[0]?.tag === 0x02 ? 1 : 0; // version + i += 2; // signature algorithm, issuer + i += 1; // thisUpdate + let nextUpdate: number | undefined; + if (fields[i] && (fields[i].tag === 0x17 || fields[i].tag === 0x18)) { + nextUpdate = parseTime(der, fields[i]); + i++; + } + const revoked = new Set(); + if (fields[i]?.tag === 0x30) { + for (const entry of children(der, fields[i])) { + const serial = children(der, entry)[0]; + if (serial?.tag === 0x02) revoked.add(integerHex(content(der, serial))); + } + } + return { revoked, nextUpdate }; + } catch { + return null; + } +} + +async function isRevoked( + cert: X509Certificate, + issuer: X509Certificate, + fetchCrl: NonNullable, + now: number, +): Promise { + const serial = normalizeSerial(cert.serialNumber); + for (const url of crlUrls(cert)) { + const der = await fetchCrl(url); + if (!der) continue; + const crl = parseCrl(der, issuer); + // A stale CRL still lists what it lists; it just can't clear anything new. + if (!crl) continue; + if (crl.revoked.has(serial)) return true; + if (crl.nextUpdate === undefined || crl.nextUpdate >= now) return false; + } + return false; +} + +export async function validateMarkCertificate(pem: string, options: CertificateOptions): Promise { + const now = options.now ?? Date.now(); + let certs: X509Certificate[]; + try { + certs = splitPem(pem); + } catch { + return { ok: false, reason: 'unparsable PEM' }; + } + const leaves = certs.filter((cert) => !cert.ca && hasBimiEku(cert)); + if (leaves.length !== 1) return { ok: false, reason: leaves.length ? 'more than one mark certificate' : 'no mark certificate' }; + const leaf = leaves[0]; + + // Walk up through the certificates the sender supplied until one is + // signed by a pinned root's key. A root in the PEM itself proves nothing: + // it can only ever be a step on the way, never the end. + const chain = [leaf]; + let root: X509Certificate | undefined; + while (!root && chain.length <= MAX_CHAIN) { + const current = chain[chain.length - 1]; + root = options.roots.find((candidate) => issuedBy(current, candidate)); + if (root) break; + const next = certs.find((cert) => cert.ca && !chain.includes(cert) && issuedBy(current, cert)); + if (!next) return { ok: false, reason: 'chain does not reach a trusted root' }; + chain.push(next); + } + if (!root) return { ok: false, reason: 'chain too long' }; + + for (const cert of [...chain, root]) { + if (!within(cert, now)) return { ok: false, reason: 'certificate outside its validity period' }; + } + // The leaf and the CA that issued it both carry the BIMI key purpose. + const issuing = chain[1] ?? root; + if (!hasBimiEku(leaf) || (issuing !== root && !hasBimiEku(issuing))) return { ok: false, reason: 'missing BIMI key purpose' }; + + if (!sanMatches(sanDnsNames(leaf), options.domains, options.selector)) return { ok: false, reason: 'domain not in certificate' }; + + let extensions: Map; + try { + extensions = certificateExtensions(new Uint8Array(leaf.raw)); + } catch { + return { ok: false, reason: 'unparsable certificate' }; + } + if (!extensions.has(SCT_LIST_OID)) return { ok: false, reason: 'not logged to certificate transparency' }; + const logotype = extensions.get(LOGOTYPE_OID); + let svg: string | null = null; + try { + svg = logotype ? logotypeSvg(logotype) : null; + } catch { + svg = null; + } + if (!svg) return { ok: false, reason: 'no logo in certificate' }; + + if (options.fetchCrl) { + const path = [...chain, root]; + for (let i = 0; i < chain.length; i++) { + if (await isRevoked(path[i], path[i + 1], options.fetchCrl, now)) return { ok: false, reason: 'certificate revoked' }; + } + } + + const markTypeName = attribute(leaf.subject, MARK_TYPE_OID); + return { + ok: true, + mark: { + svg, + markType: markTypeName === 'Prior Use Mark' ? 'common' : 'verified', + organization: attribute(leaf.subject, 'O'), + issuer: attribute(leaf.issuer, 'O'), + notAfter: Date.parse(leaf.validTo), + }, + }; +} diff --git a/lib/bimi/der.ts b/lib/bimi/der.ts new file mode 100644 index 000000000..c9c40b302 --- /dev/null +++ b/lib/bimi/der.ts @@ -0,0 +1,141 @@ +/** + * Just enough DER to read the parts of a mark certificate and its CRL that + * node:crypto's X509Certificate doesn't expose: extensions by OID, the + * logotype's data: URI and the serial numbers of a certificate list. + * Definite lengths only, as DER requires. + */ + +export interface DerNode { + tag: number; + /** Offset of the tag byte in the buffer. */ + start: number; + /** Offset of the first content byte. */ + contentStart: number; + /** Offset just past the content. */ + end: number; +} + +export class DerError extends Error {} + +export function readNode(buf: Uint8Array, offset: number, limit = buf.length): DerNode { + if (offset + 2 > limit) throw new DerError('truncated'); + const tag = buf[offset]; + // Multi-byte tag numbers never occur in certificates. + if ((tag & 0x1f) === 0x1f) throw new DerError('long tag'); + let length = buf[offset + 1]; + let contentStart = offset + 2; + if (length & 0x80) { + const count = length & 0x7f; + if (count === 0 || count > 4 || contentStart + count > limit) throw new DerError('bad length'); + length = 0; + for (let i = 0; i < count; i++) length = length * 256 + buf[contentStart + i]; + contentStart += count; + } + const end = contentStart + length; + if (end > limit) throw new DerError('truncated'); + return { tag, start: offset, contentStart, end }; +} + +/** The children of a constructed node. */ +export function children(buf: Uint8Array, node: DerNode): DerNode[] { + if (!(node.tag & 0x20)) throw new DerError('not constructed'); + const out: DerNode[] = []; + let offset = node.contentStart; + while (offset < node.end) { + const child = readNode(buf, offset, node.end); + out.push(child); + offset = child.end; + } + return out; +} + +export function content(buf: Uint8Array, node: DerNode): Uint8Array { + return buf.subarray(node.contentStart, node.end); +} + +/** The whole encoding of a node, tag and length included. */ +export function encoding(buf: Uint8Array, node: DerNode): Uint8Array { + return buf.subarray(node.start, node.end); +} + +export function decodeOid(bytes: Uint8Array): string { + if (bytes.length === 0) throw new DerError('empty oid'); + const parts: number[] = []; + let value = 0; + for (const byte of bytes) { + value = value * 128 + (byte & 0x7f); + if (!(byte & 0x80)) { + parts.push(value); + value = 0; + } + } + const first = parts.shift()!; + const head = first < 80 ? [Math.floor(first / 40), first % 40] : [2, first - 80]; + return [...head, ...parts].join('.'); +} + +/** + * A serial number as upper-case hex without leading zeros. X509Certificate + * keeps whole bytes ("0BADC0DE"), DER may add a zero byte for the sign, so + * both sides go through this before they are compared. + */ +export function normalizeSerial(hex: string): string { + return hex.toUpperCase().replace(/^0+(?=.)/, ''); +} + +/** An INTEGER as a normalized serial (see normalizeSerial). */ +export function integerHex(bytes: Uint8Array): string { + return normalizeSerial(Buffer.from(bytes).toString('hex')); +} + +const TAG_SEQUENCE = 0x30; +const TAG_OID = 0x06; +const TAG_BOOLEAN = 0x01; +const TAG_OCTET_STRING = 0x04; +const TAG_EXTENSIONS = 0xa3; + +/** + * The extnValue contents of every extension of a certificate, by OID. + * Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signature }, + * and the extensions are the [3] field of tbsCertificate. + */ +export function certificateExtensions(der: Uint8Array): Map { + const out = new Map(); + const cert = readNode(der, 0); + if (cert.tag !== TAG_SEQUENCE) throw new DerError('not a certificate'); + const [tbs] = children(der, cert); + if (!tbs || tbs.tag !== TAG_SEQUENCE) throw new DerError('no tbsCertificate'); + const wrapper = children(der, tbs).find((node) => node.tag === TAG_EXTENSIONS); + if (!wrapper) return out; + const [list] = children(der, wrapper); + if (!list || list.tag !== TAG_SEQUENCE) throw new DerError('bad extensions'); + for (const extension of children(der, list)) { + const parts = children(der, extension); + const oid = parts[0]; + const value = parts.find((node, i) => i > 0 && node.tag === TAG_OCTET_STRING); + if (oid?.tag !== TAG_OID || !value) continue; + // A boolean (critical) may sit between the two. + if (parts.length > 3 || (parts.length === 3 && parts[1].tag !== TAG_BOOLEAN)) continue; + out.set(decodeOid(content(der, oid)), content(der, value)); + } + return out; +} + +/** Every primitive node with `tag` anywhere below `node`, in order. */ +export function findAll(buf: Uint8Array, node: DerNode, tag: number, out: DerNode[] = [], depth = 0): DerNode[] { + if (depth > 32) throw new DerError('too deep'); + if (node.tag === tag) { + out.push(node); + return out; + } + if (node.tag & 0x20) { + for (const child of children(buf, node)) findAll(buf, child, tag, out, depth + 1); + } + return out; +} + +/** The text of every string with `tag` (IA5String 0x16, a [6] URI 0x86) inside an extension value. */ +export function stringsIn(value: Uint8Array, tag: number): string[] { + const root = readNode(value, 0); + return findAll(value, root, tag).map((node) => Buffer.from(content(value, node)).toString('latin1')); +} diff --git a/lib/bimi/lookup.ts b/lib/bimi/lookup.ts new file mode 100644 index 000000000..287a1bb0f --- /dev/null +++ b/lib/bimi/lookup.ts @@ -0,0 +1,293 @@ +import { resolveTxt } from 'node:dns/promises'; +import type { X509Certificate } from 'node:crypto'; +import { fetchPublicUrl } from '@/lib/security/url-guard'; +import { getRootDomain } from '@/lib/root-domain'; +import { logger } from '@/lib/logger'; +import { validateMarkCertificate, type MarkType } from './certificate'; +import { dmarcAllowsBimi, pickBimiRecord, pickDmarcRecord } from './records'; +import { bimiRoots } from './roots'; +import { checkSvg, rasterize, MAX_SELF_ASSERTED_BYTES } from './svg'; + +/** + * The receiver side of BIMI, which Stalwart doesn't do: for a From domain, + * check its DMARC policy, find its assertion record, validate the mark + * certificate when there is one, and turn the logo into a PNG. + * + * The caller decides whether the message itself passed DMARC; this only + * answers per domain, so one lookup serves every message from it. + */ + +export type BimiStatus = 'verified' | 'self-asserted'; + +export interface BimiIndicator { + /** `verified`: a VMC or CMC backs the logo. `self-asserted`: only the DNS record does. */ + status: BimiStatus; + png: Buffer; + markType?: MarkType; + organization?: string; + issuer?: string; +} + +const SELECTOR = 'default'; +const MAX_PEM_BYTES = 128 * 1024; +const MAX_CRL_BYTES = 2 * 1024 * 1024; +const FETCH_TIMEOUT_MS = 5000; +const MAX_REDIRECTS = 3; +const POSITIVE_TTL_MS = 24 * 60 * 60 * 1000; +const NEGATIVE_TTL_MS = 6 * 60 * 60 * 1000; +/** After a DNS or network failure: try again soon rather than hide the logo for hours. */ +const ERROR_TTL_MS = 10 * 60 * 1000; +const CRL_TTL_MS = 6 * 60 * 60 * 1000; +const CACHE_MAX = 2000; +const CRL_CACHE_MAX = 50; +/** Concurrent uncached lookups; over it a domain is simply missing for now. */ +const MAX_IN_FLIGHT = 16; + +const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; + +/** A host name we may look up: public-looking, lower case, no trailing dot. */ +export function normalizeBimiDomain(domain: string | null | undefined): string | null { + const lower = domain?.trim().toLowerCase().replace(/\.$/, ''); + if (!lower || lower.length > 253 || !DOMAIN_RE.test(lower)) return null; + // Names a local resolver may answer for the LAN rather than the sender. + if (/(^|\.)(localhost|local|internal|lan|home|arpa)$/.test(lower)) return null; + return lower; +} + +class TemporaryError extends Error {} + +export interface BimiResolverDeps { + resolveTxt: (name: string) => Promise; + /** GET a URL with a byte ceiling; null on 4xx/5xx, throws on network failure. */ + fetchBytes: (url: string, maxBytes: number, allowHttp: boolean) => Promise; + roots: () => readonly X509Certificate[]; + now: () => number; +} + +async function readCapped(body: ReadableStream | null, maxBytes: number): Promise { + if (!body) return new Uint8Array(); + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel().catch(() => {}); + return null; + } + chunks.push(value); + } + return new Uint8Array(Buffer.concat(chunks)); +} + +async function defaultFetchBytes(url: string, maxBytes: number, allowHttp: boolean): Promise { + let current = url; + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + const parsed = new URL(current); + if (parsed.protocol !== 'https:' && !(allowHttp && parsed.protocol === 'http:')) return null; + // fetchPublicUrl refuses private addresses at connect time; the URLs + // come from DNS records any sender controls. + const get = () => fetchPublicUrl(current, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + headers: { 'User-Agent': 'Bulwark-Webmail-BIMI' }, + }); + let response: Awaited>; + try { + response = await get(); + } catch (err) { + // Some logo hosts reset the first connection now and then (seen with + // Valimail's); one more try saves showing no logo for ten minutes. + if ((err as { cause?: { code?: string } }).cause?.code !== 'ECONNRESET') throw err; + response = await get(); + } + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location'); + await response.body?.cancel().catch(() => {}); + if (!location) return null; + current = new URL(location, current).href; + continue; + } + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + return null; + } + return readCapped(response.body as ReadableStream | null, maxBytes); + } + return null; +} + +async function defaultResolveTxt(name: string): Promise { + return resolveTxt(name); +} + +const defaultDeps: BimiResolverDeps = { + resolveTxt: defaultResolveTxt, + fetchBytes: defaultFetchBytes, + roots: bimiRoots, + now: () => Date.now(), +}; + +interface CacheEntry { + indicator: BimiIndicator | null; + expiresAt: number; +} + +export function createBimiResolver(overrides: Partial = {}) { + const deps = { ...defaultDeps, ...overrides }; + const cache = new Map(); + const crlCache = new Map(); + const inFlight = new Map>(); + + function remember(domain: string, indicator: BimiIndicator | null, ttl: number) { + cache.delete(domain); + // Map keeps insertion order: the first key is the oldest. + while (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value!); + cache.set(domain, { indicator, expiresAt: deps.now() + ttl }); + } + + async function txt(name: string): Promise { + try { + return (await deps.resolveTxt(name)).map((chunks) => chunks.join('')); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOTFOUND' || code === 'ENODATA') return []; + throw new TemporaryError(`DNS ${code ?? 'error'} for ${name}`); + } + } + + async function fetchCrl(url: string): Promise { + const cached = crlCache.get(url); + if (cached && cached.expiresAt > deps.now()) return cached.bytes; + let bytes: Uint8Array | null = null; + try { + bytes = await deps.fetchBytes(url, MAX_CRL_BYTES, true); + } catch { + bytes = null; + } + while (crlCache.size >= CRL_CACHE_MAX) crlCache.delete(crlCache.keys().next().value!); + // An unreachable CRL is asked for again after the error delay, not hours later. + crlCache.set(url, { bytes, expiresAt: deps.now() + (bytes ? CRL_TTL_MS : ERROR_TTL_MS) }); + return bytes; + } + + async function fetchText(url: string, maxBytes: number): Promise { + try { + const bytes = await deps.fetchBytes(url, maxBytes, false); + return bytes ? Buffer.from(bytes).toString('utf8') : null; + } catch (err) { + // undici reports "fetch failed" and keeps the reason in `cause`. + const cause = (err as { cause?: { code?: string; message?: string } }).cause; + const reason = cause ? ` (${cause.code ?? cause.message})` : ''; + throw new TemporaryError(`fetch failed for ${url}: ${(err as Error).message}${reason}`); + } + } + + async function lookup(domain: string): Promise<{ indicator: BimiIndicator | null; ttl: number }> { + const none = { indicator: null, ttl: NEGATIVE_TTL_MS }; + const organizational = getRootDomain(domain); + const isSubdomain = organizational !== domain; + + const [authorDmarc, orgDmarc] = await Promise.all([ + txt(`_dmarc.${domain}`).then(pickDmarcRecord), + isSubdomain ? txt(`_dmarc.${organizational}`).then(pickDmarcRecord) : Promise.resolve(null), + ]); + if (!dmarcAllowsBimi(authorDmarc, isSubdomain ? orgDmarc : authorDmarc, isSubdomain)) return none; + + // The author domain's record, else the organizational domain's. A record + // that declines BIMI stops the search. + let record = pickBimiRecord(await txt(`${SELECTOR}._bimi.${domain}`)); + if (!record && isSubdomain) record = pickBimiRecord(await txt(`${SELECTOR}._bimi.${organizational}`)); + if (!record || record.declined) return none; + + let svg: string | null; + let indicator: Omit; + let ttl = POSITIVE_TTL_MS; + if (record.evidenceUrl) { + const pem = await fetchText(record.evidenceUrl, MAX_PEM_BYTES); + if (!pem) return none; + const check = await validateMarkCertificate(pem, { + domains: [...new Set([domain, organizational])], + selector: SELECTOR, + roots: deps.roots(), + now: deps.now(), + fetchCrl, + }); + // An evidence document that doesn't validate fails BIMI outright; the + // self-published logo next to it doesn't count instead. + if (!check.ok) { + logger.debug('BIMI certificate rejected', { domain, reason: check.reason }); + return none; + } + svg = check.mark.svg; + indicator = { + status: 'verified', + markType: check.mark.markType, + organization: check.mark.organization, + issuer: check.mark.issuer, + }; + ttl = Math.max(0, Math.min(ttl, check.mark.notAfter - deps.now())); + } else { + if (!record.logoUrl) return none; + svg = await fetchText(record.logoUrl, MAX_SELF_ASSERTED_BYTES); + if (!svg) return none; + indicator = { status: 'self-asserted' }; + } + + const problem = checkSvg(svg, { strictProfile: indicator.status === 'self-asserted' }); + if (problem) { + logger.debug('BIMI logo rejected', { domain, reason: problem }); + return none; + } + let png: Buffer; + try { + png = await rasterize(svg); + } catch { + return none; + } + return { indicator: { ...indicator, png }, ttl }; + } + + /** + * The indicator for a From domain, or null. Cached per domain; concurrent + * calls for one domain share a lookup. + */ + async function resolve(rawDomain: string): Promise { + const domain = normalizeBimiDomain(rawDomain); + if (!domain) return null; + const cached = cache.get(domain); + if (cached && cached.expiresAt > deps.now()) return cached.indicator; + const pending = inFlight.get(domain); + if (pending) return pending; + if (inFlight.size >= MAX_IN_FLIGHT) return cached?.indicator ?? null; + + const run = (async () => { + try { + const { indicator, ttl } = await lookup(domain); + remember(domain, indicator, ttl); + return indicator; + } catch (err) { + if (err instanceof TemporaryError) logger.debug('BIMI lookup failed, retrying later', { domain, error: err.message }); + else logger.warn('BIMI lookup failed', { domain, error: (err as Error).message }); + // Keep showing what we had; otherwise ask again after a short pause. + const fallback = cached?.indicator ?? null; + remember(domain, fallback, ERROR_TTL_MS); + return fallback; + } finally { + inFlight.delete(domain); + } + })(); + inFlight.set(domain, run); + return run; + } + + return { resolve, clear: () => { cache.clear(); crlCache.clear(); } }; +} + +let shared: ReturnType | null = null; + +export function bimiResolver() { + if (!shared) shared = createBimiResolver(); + return shared; +} diff --git a/lib/bimi/records.ts b/lib/bimi/records.ts new file mode 100644 index 000000000..910f69ed3 --- /dev/null +++ b/lib/bimi/records.ts @@ -0,0 +1,110 @@ +/** + * The DNS side of BIMI: the assertion record at ._bimi. + * and the DMARC policy that has to back it. Pure parsing; lib/bimi/lookup.ts + * does the queries. + */ + +export interface BimiRecord { + /** The indicator (SVG) location, https only. Undefined when the tag is empty or missing. */ + logoUrl?: string; + /** The evidence document (mark certificate PEM) location, https only. */ + evidenceUrl?: string; + /** l= and a= both empty: the domain asks for no indicator. */ + declined: boolean; +} + +export type DmarcPolicy = 'none' | 'quarantine' | 'reject'; + +export interface DmarcRecord { + p: DmarcPolicy; + sp?: DmarcPolicy; + pct: number; +} + +function tags(record: string): Map { + const out = new Map(); + for (const part of record.split(';')) { + const eq = part.indexOf('='); + if (eq < 0) continue; + const name = part.slice(0, eq).trim().toLowerCase(); + if (name && !out.has(name)) out.set(name, part.slice(eq + 1).trim()); + } + return out; +} + +function httpsUrl(value: string | undefined): string | undefined { + // Early drafts allowed a comma-separated list; the first entry counts. + const first = value?.split(',')[0]?.trim(); + if (!first) return undefined; + try { + const url = new URL(first); + return url.protocol === 'https:' ? url.href : undefined; + } catch { + return undefined; + } +} + +function isBimi(record: string): boolean { + return /^v\s*=\s*BIMI1\s*(;|$)/i.test(record.trim()); +} + +function isDmarc(record: string): boolean { + return /^v\s*=\s*DMARC1\s*(;|$)/i.test(record.trim()); +} + +/** + * The one record that starts with `v=BIMI1` among the TXT answers. Zero or + * several make it null: a domain with a wildcard TXT answers BIMI queries + * with its SPF record, and two assertion records are an error. + */ +export function pickBimiRecord(answers: readonly string[]): BimiRecord | null { + const records = answers.filter(isBimi); + if (records.length !== 1) return null; + const parsed = tags(records[0]); + const logo = parsed.get('l') ?? ''; + const evidence = parsed.get('a') ?? ''; + const logoUrl = httpsUrl(logo); + const evidenceUrl = httpsUrl(evidence); + // A tag that names a non-https location is invalid, not a declination. + if ((logo && !logoUrl) || (evidence && !evidenceUrl)) return null; + return { logoUrl, evidenceUrl, declined: !logoUrl && !evidenceUrl }; +} + +function policy(value: string | undefined): DmarcPolicy | undefined { + const lower = value?.toLowerCase(); + return lower === 'none' || lower === 'quarantine' || lower === 'reject' ? lower : undefined; +} + +/** The one DMARC record among the TXT answers, or null. */ +export function pickDmarcRecord(answers: readonly string[]): DmarcRecord | null { + const records = answers.filter(isDmarc); + if (records.length !== 1) return null; + const parsed = tags(records[0]); + const p = policy(parsed.get('p')); + if (!p) return null; + const pctText = parsed.get('pct'); + const pct = pctText === undefined ? 100 : Number(pctText); + return { p, sp: policy(parsed.get('sp')), pct: Number.isInteger(pct) ? pct : 0 }; +} + +/** + * Whether the published DMARC policy is strict enough for BIMI: quarantine + * or reject for every message (pct=100), and for a subdomain an + * organizational domain that isn't p=none either. + * + * `author` is the record at _dmarc., `organizational` the one + * at _dmarc.; pass the same record twice when they + * are the same domain. + */ +export function dmarcAllowsBimi( + author: DmarcRecord | null, + organizational: DmarcRecord | null, + isSubdomain: boolean, +): boolean { + const applied = author ?? (isSubdomain ? organizational : null); + if (!applied) return false; + const effective = author ? author.p : (applied.sp ?? applied.p); + if (effective === 'none' || applied.pct !== 100) return false; + if (isSubdomain && organizational && organizational.p === 'none') return false; + return true; +} diff --git a/lib/bimi/roots.ts b/lib/bimi/roots.ts new file mode 100644 index 000000000..af554e889 --- /dev/null +++ b/lib/bimi/roots.ts @@ -0,0 +1,158 @@ +import { X509Certificate } from 'node:crypto'; + +/** + * Root CAs of the Mark Verifying Authorities whose VMCs and CMCs we accept. + * Each was downloaded from the CA itself (the URL above it) and pinned here; + * the root a sender's PEM carries is never trusted on its own. SSL.com's + * roots also anchor the mark certificates Sectigo issues. + * + * To add a root, take it from the CA's repository, not from a sender's PEM, + * and list its SHA-256 fingerprint here as well. + */ +const ROOT_PEMS: readonly { name: string; sha256: string; pem: string }[] = [ + // From cacerts.digicert.com/DigiCertVerifiedMarkRootCA.crt + { + name: 'DigiCert Verified Mark Root CA', + sha256: '50:43:86:C9:EE:89:32:FE:CC:95:FA:DE:42:7F:69:C3:E2:53:4B:73:10:48:9E:30:0F:EE:44:8E:33:C4:6B:42', + pem: `-----BEGIN CERTIFICATE----- +MIIF3jCCA8agAwIBAgIQBsFnz+v0jTXWJBAYXhHF6zANBgkqhkiG9w0BAQsFADCB +iDELMAkGA1UEBhMCVVMxDTALBgNVBAgTBFV0YWgxDTALBgNVBAcTBExlaGkxFzAV +BgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29t +MScwJQYDVQQDEx5EaWdpQ2VydCBWZXJpZmllZCBNYXJrIFJvb3QgQ0EwHhcNMTkw +OTIzMTIxMjA2WhcNNDkwOTIzMTIxMjA2WjCBiDELMAkGA1UEBhMCVVMxDTALBgNV +BAgTBFV0YWgxDTALBgNVBAcTBExlaGkxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMu +MRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMScwJQYDVQQDEx5EaWdpQ2VydCBW +ZXJpZmllZCBNYXJrIFJvb3QgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK +AoICAQDawvvIO7cL04ptZxgLw/YwqDuluiFsMvGsr+vZcfq5c3hKuX0uMrslza91 +OFB6SPmbkG2hLErOcaVH0nMnG0RE3AM6dpfhw7qU+n3c6XPS7HlO9ZC57GJeaOXy +b0cmcK2G96WC/VRuB1ZgjqYoq6PP4yjn/DB/Pc+7kjwJ2EDH5BFEnywVq4rH1a+Q +AbVDpxJfCfQZV1VKW+JNtO/KKKX+NlPrtHroSgKiRZ019oWptImyfgpg7j6FNNAT +R8uPsvU5zYJyCDOxKv4MqllMJmUVwGUHF61WnbiZeJsxzb5H5wMpikX4mfdKaIm0 +ym2QsHVRazST1bIVvAZThcKPd2EnysQi6XpYpMcpiSRo58ENXZW47M/Ocu7mBCLP +TJEPEC9YG2aCfHxFSz/n6xZR+1rvNPUxcLZ+FNOwZRnHqcqe5TDNQewoC8/AWR0O +dKqu2WgBF40ncXmtm5QnYhlTmBcoPUWfR40bCLJsm4fV2B4hkC5ZCHV/91jpsv7j +hsGkpQpY6n9XWBABW6ZGQWM4jXxybbNmb3u21xx8rEkaIh22is08i41xeV9iLYec +Pup6npZnZbiKSOEFQ3WAwzi3TtABmRknOMybFJKSlJQXMfHqENfwKpNvMMRVO8Pl +J+Oh6AN8l75vZaFF27gqBhbmjJ2Y9ioqTI7g+Dg4qClUQqXPCQIDAQABo0IwQDAd +BgNVHQ4EFgQU7G8ipLME4sFjh+Z3Y+pGaU7u/OswDgYDVR0PAQH/BAQDAgGGMA8G +A1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBAC832YLVevVWINnr3vWC +XNvLPtmPOPLKO5cHupQpkcug+IOli2FAxnC8JDlbOT6hiMK7MYaurag9QvDI/As0 +4cNOa+4sqKCxQR3aLEyyqeLA4WdA6UFIHdMSIzLHZylzjuwciI706x83Ib17DMKO +cpO2QVB7Beqv240TWxKxH21pFZsl44OgI+HcAPDbfJe3PEzwEZKNcKRkMWa/FFu2 +ckQxpTcfZABrarnuRLcSINiodSW7VfxctzegXWM4WmQeutPBOicceV3J4ZVkhthB +m784vES1DIuDTqT9/iqStBGN8eOGx9qKvjaXT8SdcrP58FpXrtm/xKgtILptxfVT +042oogQfb2cNahKRSvs0xH3jyhO944t0zMH/bEpRdU36wR1/Fo56zXy2Zv4czMwg +3Hg7mbAalJvcnBvH+NHPgucQI432XX11K29vz7HuNC7P9yKhxns+MbOQDMDPOhtS +LUpBmzRNG4+2BZJZyKGqYd+STHisEGYeYCi3MVrwSe2UqcDi9f2UAWVbkDE/YB6/ +e7+C7o6UWkXSU7dzR7FwFsfBHi6EqgIb2e9pINAxdvlc/3E19Ld/GJEtlw7nSdzp +71eMp5Z48iY54fV2lM/rXogS1R4r3p2oPe9efG0XaJMd0v1gom5Da/khJA7+wjRB +0wberd/tg3N0dJsSSznZjwYB +-----END CERTIFICATE-----`, + }, + // From secure.globalsign.com/cacert/gsverifiedmarkrootr42.crt + { + name: 'GlobalSign Verified Mark Root R42', + sha256: 'CD:12:2C:B8:77:C6:92:8B:90:17:B0:F0:B8:0D:BD:50:81:96:30:0B:BD:03:CD:73:56:C3:BE:EF:52:4E:7E:0B', + pem: `-----BEGIN CERTIFICATE----- +MIIFdDCCA1ygAwIBAgIQf+UwA4GYp199F8APJCyr8zANBgkqhkiG9w0BAQwFADBU +MQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEqMCgGA1UE +AxMhR2xvYmFsU2lnbiBWZXJpZmllZCBNYXJrIFJvb3QgUjQyMB4XDTIzMTExNTAz +NDA1MloXDTQyMTExNTAwMDAwMFowVDELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEds +b2JhbFNpZ24gbnYtc2ExKjAoBgNVBAMTIUdsb2JhbFNpZ24gVmVyaWZpZWQgTWFy +ayBSb290IFI0MjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANzqTcvu +bRbqX0CzP7sZbHfd1usqWOPrHzoLvbZTnJqt9WgBwQwcGEFl8rmIcOss7XmewAqj +oCx7PQp1FJ+/o/yGezMbnPCglAVwWptUOMIa7UJ/8qrV50/qQSRN8s8MAcnhj3Ab +Ja/mVjlMHzjYCo0O+jc24uHlEHcIo3vJPsP2Sy9HwcMH4wB2rQ4xDN7kAn5NmqXI +xAvSnJ2KGrZsoNx8MGAfX5dl0ACOpaaqbonBGtawvRGAE6f747E+pUamjGjisQ8y +ZC0X5Ht1s53sliy9AHDpOUWVcCLOoQCP/Y8yDjkjE1VWiL1Y7LlCnYfAVfY87DRD +81toIOLjFNACxeG02Qvgzg0JMKh81seiZfSLbq8DgepDABUTs8WYpN/u52/kdM+/ +UeaQEn+q2HDDI8OMmBqwVo98k+dCQ0QgKLkHaLxpm5EjacSkNZFeqp1ttDZsGKQt +9REPJxpY6OO9KSYuFNFpnmLfaDNA/VngLw+Z4hYH59RnE2p3s95CfUa+CT4BUvDy +Wefc9RNiJVppbQNiZ4DKKpixVfXptgukbnh5l7vyDVonKGdgNEZeN5YjopM5V2+J +eIrsC3u0OmDV41cSFfe/1dmoUhy5EfTgWXqLOOgoUxqy62Yl7DDwoyXerEzDzZAq +nSBE/HAra3/d5pdj8/5WXoybjbXYM/bsKALLAgMBAAGjQjBAMA4GA1UdDwEB/wQE +AwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQ9s+P1IK6506ybFLxwJe4P +Cn6VyTANBgkqhkiG9w0BAQwFAAOCAgEA24nHkncSyqeuk1lkpRq/MLVHdVONJnZ6 +t8OZEu3nr6DHAuImAonIqO8HcBcgwydDIAeD7GOafOZ47mRkYOFIa+tkJ21pfmJv +KUt3ASW1uLAHCJNYHVLXs7txnq1mRKuwyuaeb6JdTazzjXgNDLZQgvOfM7mkDf6f +S2Z+dbKOZBYwo6zspqoQc1s6k0Xg9h7oEiJCC+3FUfJQSYyi2bynZSEn2c67OSFT +fqXVr9nhlaawLGEKUaFlYeQspUO5MUFYmGWqnf38M8ZM0rPx5R00jZ/E5sKcSqFH +CaVghDPiVv9Jzs8gNllwDbi7rXwMstaKATJJXiWgSdsxrfQr7+FRyXQJmRVXKxZ6 +6uNe1ZgAzgNbjCvGmwF1StBtr1DrdOSk7sOMBMuJftB2g9/K67VUfz+jkylIbv5y +bvnCqBoAvq2DSQ76O5WyWiTBkPYp2Fh/WMHkXVIQpT6Q7FMTiGxtAxv69sq1CeJ9 +i9g/iAJBdToQwJwH80Ah3hSPg1DQ8LTIjTtOxxkJ6Aeh/XU1/w9lW91XGLgXye1U +fjUIWKsoe1rE2RK+RSP6BZWcRNhJOyqi40gdTRLJkbc0Itq/62NFV4mIWcWKafhb +4ZOU1rvk1woeteJJZHd7cLakW6ONJAEtWGe43ZVx/lqFXQH4kOMPuR/7h2G1Aie6 +Fk6hsIjUv20= +-----END CERTIFICATE-----`, + }, + // From repo-certs.ssl.com/repo/certs/SSL.com-VMC-Root-2024-RSA.pem + { + name: 'SSL.com VMC RSA Root CA 2024', + sha256: '8F:9D:1B:76:98:88:67:82:A5:99:B4:85:10:65:1C:66:A1:AA:0C:5C:A3:19:20:97:BD:C6:85:34:15:4B:D3:0D', + pem: `-----BEGIN CERTIFICATE----- +MIIFiTCCA3GgAwIBAgIQcJRvrr6zzODYb7h2d4BhyzANBgkqhkiG9w0BAQsFADBO +MQswCQYDVQQGEwJVUzEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMSUwIwYDVQQD +DBxTU0wuY29tIFZNQyBSU0EgUm9vdCBDQSAyMDI0MB4XDTI0MDIxOTE2MjU1MVoX +DTQ4MDIxMzE2MjU1MFowTjELMAkGA1UEBhMCVVMxGDAWBgNVBAoMD1NTTCBDb3Jw +b3JhdGlvbjElMCMGA1UEAwwcU1NMLmNvbSBWTUMgUlNBIFJvb3QgQ0EgMjAyNDCC +AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAIyEglUdOfMiwwS13+Q9dho5 +swa3HbdPZByWNPwgD1tDdPYyCdox2F0bJxfrBnUFKCf46SZV7DESkn/YTKj7tMSk +Vr4c9GzNbr7WWSMprVEsgDrwDw/RM9YcwsFNlx1PFt+gDQhitA/ezIRO0uextBp4 +0/HP7HQ7ZeOKVB0W71WoBty6TBCN+l5LVQGGDSvRGmQndf9zsmITQpddp1xAbUMA +uxFnkS22vYCHdEWzSVuEWLakVh53qcYF7krDZY82kIurOC1wEwGNkdsfQ0HLpf8Y +VhHMOm/JoCOXcA79nvjehykZb+7c1zgPgUjRd4d/UbwoDBy0Y4GrGw4Q+c0bscz/ +xlrxf+SYIozASUEFjNcfvOkPCLlTHj7V8yhQRm58Q36/GdnahwLgdRwhk4WP5p7W +tkxJrHIvdsQJgIH9OVL089MHfOHQHuAcxV54+/YKr/AT6m3pgr+Zs067rQYMMc/k +TCGial39KMnkHydy8AduW+17U1auIpqt7XOtroC46IMbL2zVQDY1lyvUfvwc+5Kt +fjYVpnsle7g+fkEupWvbQHCWZR4iE9jFNR6ETF2damAMx+yuFP+dfyc4CAZUoGwz +0+ZhUIYuTeEMCPSuZAvHy0ugTPEuSc5LKnJ5JK+bOeqtbRkQVmFC74fkAqhkCfzt +vkckHbTCI5ukoeKueCRJAgMBAAGjYzBhMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0j +BBgwFoAUwTWi9FnZB3aX/croa3fWJq56WvswHQYDVR0OBBYEFME1ovRZ2Qd2l/3K +6Gt31iauelr7MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAIet3 ++K2+sef6SZBE+5cFsnKYtagi50I1iECJQziusyw1wK9Keman4TZshwio4Z/nvaq2 +ed3s3YYFL6BGksJTJPImrEaQI3aqHa16AEzw6F2k3gz78wbcpjygGJMekVrhyF5l +E+ErRcY9JGo1IGscBYlZcXkHJThNO9gEyoH+os73KIsY4120CBe3jbfOhRpDZioF +wUxepdzEzx8ZRXGsz58m+0NS0cBcvkqpIpnI2TFDT/lK0s2ys65h2YLhDsUnhtJ2 +Kd8boP+ImazgpfpjgVEeCCpoPzWmmlMzxr083f0Mya4uSPidk/FkVYo3l+NAUcfH +WcfH6ca7Xrm5DXsOaP0cDWaYphbEKVVi164V03fNClW3p7xzLCDUfzdAaQs1JY1G +h0GgH4dNdp+BgGjjUPZ86IZhCuOq0rm3czfxb25l6WuAf7A0gq4oVgMNbbIepap8 +YRbbU3nGl4A+s6qjtIhy6QGVWYqwQVElP7ba1sbhqLu5Uu/QyI/B5uEBO3oTvNgc +pld8jFKTqIO9yFgzvIO6XhfwW5VvLHozlzb7fM8hPKnrdd5HT3XnFTkKl/y4zLrw +OnvNhh5QMQe+HEnpScufX/cFPh568bVicV061IvLoGr84rlgxO+RjWuILOiXCVi0 +7JsQLmpgD+ebpTit7HrVpCJa1SZXeHlzkc/yI74= +-----END CERTIFICATE-----`, + }, + // From repo-certs.ssl.com/repo/certs/SSL.com-VMC-Root-2024-ECC.pem + { + name: 'SSL.com VMC ECC Root CA 2024', + sha256: '1B:82:E7:F4:91:0B:51:E3:E8:02:A4:93:AC:DC:17:FF:58:EA:C8:B9:EB:7C:09:B5:2A:C6:CD:2E:FB:83:59:8C', + pem: `-----BEGIN CERTIFICATE----- +MIICOjCCAcCgAwIBAgIQEUfBai0/SvdnXWXlwaytjjAKBggqhkjOPQQDAzBOMQsw +CQYDVQQGEwJVUzEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMSUwIwYDVQQDDBxT +U0wuY29tIFZNQyBFQ0MgUm9vdCBDQSAyMDI0MB4XDTI0MDIxOTE2MjUzMFoXDTQ4 +MDIxMzE2MjUyOVowTjELMAkGA1UEBhMCVVMxGDAWBgNVBAoMD1NTTCBDb3Jwb3Jh +dGlvbjElMCMGA1UEAwwcU1NMLmNvbSBWTUMgRUNDIFJvb3QgQ0EgMjAyNDB2MBAG +ByqGSM49AgEGBSuBBAAiA2IABAlYNKIok2H2MoERpO66/tfLns/aRbbVN8HhNGbe +znXOJhQozKiHoCOCN7bCCuMdpAXVosUptXs+8Sksd8Hluh91J1W1QQVi9KyFSwgM +VTAynuA64X9MbrQNwkZaCQXPtKNjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSME +GDAWgBQl8MYq5N4ipgAt8VK4J42gUeKgpTAdBgNVHQ4EFgQUJfDGKuTeIqYALfFS +uCeNoFHioKUwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2gAMGUCMHkYwAy0 +rQ/BXQCanvUeTylOOCbaIO+rbFxOY8KF+AHmv0MJI7t++BnWUE1XwYABXAIxAKbi +1Mpz6UpoC5yWsed9Z1rhT+pMwzPwNrdsRMLAesSq2Jbv0UGQ/1uJcM/6OtDFgQ== +-----END CERTIFICATE-----`, + }, +]; + +let parsed: X509Certificate[] | null = null; + +export function bimiRoots(): X509Certificate[] { + if (!parsed) { + parsed = ROOT_PEMS.map(({ name, sha256, pem }) => { + const cert = new X509Certificate(pem); + if (cert.fingerprint256 !== sha256) throw new Error(`BIMI root ${name} does not match its pinned fingerprint`); + return cert; + }); + } + return parsed; +} diff --git a/lib/bimi/svg.ts b/lib/bimi/svg.ts new file mode 100644 index 000000000..039ffc410 --- /dev/null +++ b/lib/bimi/svg.ts @@ -0,0 +1,95 @@ +import sharp from 'sharp'; + +/** + * BIMI indicators are SVG Tiny Portable/Secure (draft-svg-tiny-ps-abrotman): + * no script, no animation, nothing loaded from elsewhere. The browser never + * gets the sender's SVG: it is checked here and drawn to a PNG, so whatever + * the checks miss can't run or call out on the webmail origin anyway. + */ + +/** The size the BIMI Group recommends as the ceiling for a self-published logo. */ +export const MAX_SELF_ASSERTED_BYTES = 32 * 1024; +/** Mark certificates carry larger logos now and then; the CA checked those. */ +export const MAX_CERTIFIED_BYTES = 64 * 1024; +/** Avatars are at most 48 CSS px; this covers 4x screens. */ +export const INDICATOR_SIZE = 192; + +const FORBIDDEN_ELEMENTS = new Set([ + 'script', 'foreignobject', 'image', 'iframe', 'video', 'audio', 'canvas', 'embed', 'object', + 'animate', 'animatemotion', 'animatetransform', 'animatecolor', 'set', 'discard', 'handler', 'listener', +]); + +export interface SvgCheckOptions { + /** + * Hold the logo to the profile itself: baseProfile="tiny-ps", a title and + * the size ceiling. For self-published logos; a mark certificate's logo was + * checked by the CA that issued it. + */ + strictProfile: boolean; +} + +function attributesOf(tag: string): Map { + const out = new Map(); + for (const m of tag.matchAll(/([\w:.-]+)\s*=\s*("([^"]*)"|'([^']*)')/g)) { + out.set(m[1].toLowerCase(), m[3] ?? m[4] ?? ''); + } + return out; +} + +/** Why the SVG can't be shown, or null when it can. */ +export function checkSvg(svg: string, { strictProfile }: SvgCheckOptions): string | null { + const bytes = Buffer.byteLength(svg, 'utf8'); + if (bytes > (strictProfile ? MAX_SELF_ASSERTED_BYTES : MAX_CERTIFIED_BYTES)) return 'too large'; + // Entities can expand without bound or pull in files. + if (/]*>/i.exec(svg); + if (!root) return 'not svg'; + const rootAttributes = attributesOf(root[0]); + + for (const m of svg.matchAll(/<\s*([\w:.-]+)/g)) { + const name = m[1].toLowerCase().replace(/^[\w.-]+:/, ''); + if (FORBIDDEN_ELEMENTS.has(name)) return `forbidden element <${name}>`; + } + for (const m of svg.matchAll(/<[^>]+>/g)) { + for (const [name, value] of attributesOf(m[0])) { + if (/^on/.test(name)) return 'event handler'; + if ((name === 'href' || name.endsWith(':href')) && !value.trim().startsWith('#')) return 'external reference'; + } + } + // url(...) in attributes and style sheets may only point inside the document. + for (const m of svg.matchAll(/url\(\s*['"]?\s*([^)'"\s]*)/gi)) { + if (!m[1].startsWith('#')) return 'external reference'; + } + if (/@import/i.test(svg)) return 'external reference'; + + const viewBox = rootAttributes.get('viewbox')?.trim().split(/[\s,]+/).map(Number); + if (viewBox && viewBox.length === 4) { + const [, , width, height] = viewBox; + if (!(width > 0 && height > 0)) return 'empty viewBox'; + if (Math.abs(width - height) / Math.max(width, height) > 0.01) return 'not square'; + } + + if (strictProfile) { + if (rootAttributes.get('baseprofile')?.toLowerCase() !== 'tiny-ps') return 'not tiny-ps'; + if (!/]*>[^<]*\S[^<]*<\/title>/i.test(svg)) return 'no title'; + // The profile forbids positioning the root element. + if (rootAttributes.has('x') || rootAttributes.has('y')) return 'positioned root'; + } + return null; +} + +/** Draw the SVG to a square PNG of INDICATOR_SIZE pixels. */ +export async function rasterize(svg: string): Promise { + const input = Buffer.from(svg, 'utf8'); + // librsvg draws at 72 dpi from the intrinsic size; raise the density so a + // small viewBox isn't scaled up blurred, and cap it so a huge one doesn't + // allocate a giant canvas. + const meta = await sharp(input).metadata(); + const intrinsic = Math.max(1, Math.min(meta.width ?? INDICATOR_SIZE, meta.height ?? INDICATOR_SIZE)); + const density = Math.max(1, Math.min(2400, Math.ceil((72 * INDICATOR_SIZE) / intrinsic))); + return sharp(input, { density, limitInputPixels: 4096 * 4096 }) + .resize(INDICATOR_SIZE, INDICATOR_SIZE, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .png() + .toBuffer(); +} diff --git a/lib/bimi/types.ts b/lib/bimi/types.ts new file mode 100644 index 000000000..d01ce3c26 --- /dev/null +++ b/lib/bimi/types.ts @@ -0,0 +1,11 @@ +/** What /api/bimi?format=json answers for a From domain. Shared with the browser. */ +export type BimiInfo = + | { status: 'none' } + | { + /** `verified`: a VMC or CMC backs the logo. `self-asserted`: only the DNS record does. */ + status: 'verified' | 'self-asserted'; + /** VMC (registered or government mark) or CMC (prior use mark). */ + markType?: 'verified' | 'common'; + organization?: string; + issuer?: string; + }; diff --git a/lib/email-headers.ts b/lib/email-headers.ts index 79a021a68..c4ed679f6 100644 --- a/lib/email-headers.ts +++ b/lib/email-headers.ts @@ -133,6 +133,25 @@ export function getSenderVerification( return { status: 'unverified', domain, sentFrom }; } +/** + * The From domain whose BIMI logo this message may show, or undefined. BIMI + * needs a DMARC pass for the very domain in From: an aligned SPF or DKIM + * pass without a DMARC verdict isn't enough, and neither is a pass that the + * receiving server recorded for another header.from. Whether the domain + * enforces DMARC and publishes a logo is /api/bimi's to find out. + */ +export function brandIndicatorDomain( + email: { from?: { email?: string | null }[] | null; authenticationResults?: AuthenticationResults } | undefined, +): string | undefined { + const auth = email?.authenticationResults; + const fromEmail = email?.from?.[0]?.email ?? undefined; + if (!auth?.dmarc || auth.dmarc.result !== 'pass' || !fromEmail) return undefined; + const domain = domainOf(fromEmail); + if (!domain || isAuthenticationSpoofed(auth)) return undefined; + const checked = auth.dmarc.domain ? domainOf(`@${auth.dmarc.domain}`) : domain; + return checked === domain ? domain : undefined; +} + interface ResInfo { method: string; result: string; diff --git a/lib/root-domain.ts b/lib/root-domain.ts new file mode 100644 index 000000000..67161f123 --- /dev/null +++ b/lib/root-domain.ts @@ -0,0 +1,372 @@ +/** + * The registrable ("organizational") domain of a host name, from a list of + * the common two-label public suffixes rather than the full Public Suffix + * List. Shared by the sender icon proxy and the BIMI lookups, which both + * fall back from a subdomain to the domain it belongs to. + */ + +// Known multi-part TLDs where the registrable domain includes one extra label. +const MULTI_PART_TLDS = new Set([ + // .ac + "com.ac", "gov.ac", "mil.ac", "net.ac", "org.ac", + // .ae + "ac.ae", "co.ae", "gov.ae", "mil.ae", "name.ae", "net.ae", "org.ae", "pro.ae", "sch.ae", + // .af + "com.af", "edu.af", "gov.af", "net.af", "org.af", + // .al + "com.al", "edu.al", "gov.al", "mil.al", "net.al", "org.al", + // .ao + "co.ao", "ed.ao", "gv.ao", "it.ao", "og.ao", "pb.ao", + // .ar + "com.ar", "edu.ar", "gob.ar", "gov.ar", "int.ar", "mil.ar", "net.ar", "org.ar", "tur.ar", + // .at + "ac.at", "co.at", "gv.at", "or.at", + // .au + "asn.au", "com.au", "csiro.au", "edu.au", "gov.au", "id.au", "net.au", "org.au", + // .ba + "co.ba", "com.ba", "edu.ba", "gov.ba", "mil.ba", "net.ba", "org.ba", "rs.ba", + "unbi.ba", "unmo.ba", "unsa.ba", "untz.ba", "unze.ba", + // .bb + "biz.bb", "co.bb", "com.bb", "edu.bb", "gov.bb", "info.bb", "net.bb", "org.bb", + "store.bb", "tv.bb", + // .bh + "biz.bh", "cc.bh", "com.bh", "edu.bh", "gov.bh", "info.bh", "net.bh", "org.bh", + // .bn + "com.bn", "edu.bn", "gov.bn", "net.bn", "org.bn", + // .bo + "com.bo", "edu.bo", "gob.bo", "gov.bo", "int.bo", "mil.bo", "net.bo", "org.bo", "tv.bo", + // .br + "adm.br", "adv.br", "agr.br", "am.br", "arq.br", "art.br", "ato.br", "b.br", + "bio.br", "blog.br", "bmd.br", "cim.br", "cng.br", "cnt.br", "com.br", "coop.br", + "ecn.br", "edu.br", "eng.br", "esp.br", "etc.br", "eti.br", "far.br", "flog.br", + "fm.br", "fnd.br", "fot.br", "fst.br", "g12.br", "ggf.br", "gov.br", "imb.br", + "ind.br", "inf.br", "jor.br", "jus.br", "lel.br", "mat.br", "med.br", "mil.br", + "mus.br", "net.br", "nom.br", "not.br", "ntr.br", "odo.br", "org.br", "ppg.br", + "pro.br", "psc.br", "psi.br", "qsl.br", "rec.br", "slg.br", "srv.br", "tmp.br", + "trd.br", "tur.br", "tv.br", "vet.br", "vlog.br", "wiki.br", "zlg.br", + // .bs + "com.bs", "edu.bs", "gov.bs", "net.bs", "org.bs", + // .bz + "com.bz", "edu.bz", "gov.bz", "net.bz", "org.bz", + // .ca + "ab.ca", "bc.ca", "mb.ca", "nb.ca", "nf.ca", "nl.ca", "ns.ca", "nt.ca", + "nu.ca", "on.ca", "pe.ca", "qc.ca", "sk.ca", "yk.ca", + // .ck + "biz.ck", "co.ck", "edu.ck", "gen.ck", "gov.ck", "info.ck", "net.ck", "org.ck", + // .cn + "ac.cn", "ah.cn", "bj.cn", "com.cn", "cq.cn", "edu.cn", "fj.cn", "gd.cn", + "gov.cn", "gs.cn", "gx.cn", "gz.cn", "ha.cn", "hb.cn", "he.cn", "hi.cn", + "hl.cn", "hn.cn", "jl.cn", "js.cn", "jx.cn", "ln.cn", "mil.cn", "net.cn", + "nm.cn", "nx.cn", "org.cn", "qh.cn", "sc.cn", "sd.cn", "sh.cn", "sn.cn", + "sx.cn", "tj.cn", "tw.cn", "xj.cn", "xz.cn", "yn.cn", "zj.cn", + // .co + "com.co", "edu.co", "gov.co", "mil.co", "net.co", "nom.co", "org.co", + // .cr + "ac.cr", "co.cr", "ed.cr", "fi.cr", "go.cr", "or.cr", "sa.cr", + // .cy + "ac.cy", "biz.cy", "com.cy", "ekloges.cy", "gov.cy", "ltd.cy", "name.cy", + "net.cy", "org.cy", "parliament.cy", "press.cy", "pro.cy", "tm.cy", + // .do + "art.do", "com.do", "edu.do", "gob.do", "gov.do", "mil.do", "net.do", "org.do", + "sld.do", "web.do", + // .dz + "art.dz", "asso.dz", "com.dz", "edu.dz", "gov.dz", "net.dz", "org.dz", "pol.dz", + // .ec + "com.ec", "edu.ec", "fin.ec", "gov.ec", "info.ec", "med.ec", "mil.ec", "net.ec", + "org.ec", "pro.ec", + // .eg + "com.eg", "edu.eg", "eun.eg", "gov.eg", "mil.eg", "name.eg", "net.eg", "org.eg", "sci.eg", + // .er + "com.er", "edu.er", "gov.er", "ind.er", "mil.er", "net.er", "org.er", "rochest.er", "w.er", + // .es + "com.es", "edu.es", "gob.es", "nom.es", "org.es", + // .et + "biz.et", "com.et", "edu.et", "gov.et", "info.et", "name.et", "net.et", "org.et", + // .fj + "ac.fj", "biz.fj", "com.fj", "info.fj", "mil.fj", "name.fj", "net.fj", "org.fj", "pro.fj", + // .fk + "ac.fk", "co.fk", "gov.fk", "net.fk", "nom.fk", "org.fk", + // .fr + "asso.fr", "com.fr", "gouv.fr", "nom.fr", "prd.fr", "presse.fr", "tm.fr", + // .gg + "co.gg", "net.gg", "org.gg", + // .gh + "com.gh", "edu.gh", "gov.gh", "mil.gh", "org.gh", + // .gn + "ac.gn", "com.gn", "gov.gn", "net.gn", "org.gn", + // .gr + "com.gr", "edu.gr", "gov.gr", "mil.gr", "net.gr", "org.gr", + // .gt + "com.gt", "edu.gt", "gob.gt", "ind.gt", "mil.gt", "net.gt", "org.gt", + // .gu + "com.gu", "edu.gu", "gov.gu", "net.gu", "org.gu", + // .hk + "com.hk", "edu.hk", "gov.hk", "idv.hk", "net.hk", "org.hk", + // .id + "ac.id", "co.id", "go.id", "mil.id", "net.id", "or.id", "sch.id", "web.id", + // .il + "ac.il", "co.il", "gov.il", "idf.il", "k12.il", "muni.il", "net.il", "org.il", + // .in + "4fd.in", "ac.in", "co.in", "edu.in", "ernet.in", "firm.in", "gen.in", "gov.in", + "ind.in", "mil.in", "net.in", "nic.in", "org.in", "res.in", + // .iq + "com.iq", "edu.iq", "gov.iq", "mil.iq", "net.iq", "org.iq", + // .ir + "ac.ir", "co.ir", "dnssec.ir", "gov.ir", "id.ir", "net.ir", "org.ir", "sch.ir", + // .it + "edu.it", "gov.it", + // .je + "co.je", "net.je", "org.je", + // .jo + "com.jo", "edu.jo", "gov.jo", "mil.jo", "name.jo", "net.jo", "org.jo", "sch.jo", + // .jp + "ac.jp", "ad.jp", "co.jp", "ed.jp", "go.jp", "gr.jp", "lg.jp", "ne.jp", "or.jp", + // .ke + "ac.ke", "co.ke", "go.ke", "info.ke", "me.ke", "mobi.ke", "ne.ke", "or.ke", "sc.ke", + // .kh + "com.kh", "edu.kh", "gov.kh", "mil.kh", "net.kh", "org.kh", "per.kh", + // .ki + "biz.ki", "com.ki", "de.ki", "edu.ki", "gov.ki", "info.ki", "mob.ki", "net.ki", + "org.ki", "tel.ki", + // .km + "asso.km", "com.km", "coop.km", "edu.km", "gouv.km", "medecin.km", "mil.km", + "nom.km", "notaires.km", "pharmaciens.km", "presse.km", "tm.km", "veterinaire.km", + // .kn + "edu.kn", "gov.kn", "net.kn", "org.kn", + // .kr + "ac.kr", "busan.kr", "chungbuk.kr", "chungnam.kr", "co.kr", "daegu.kr", + "daejeon.kr", "es.kr", "gangwon.kr", "go.kr", "gwangju.kr", "gyeongbuk.kr", + "gyeonggi.kr", "gyeongnam.kr", "hs.kr", "incheon.kr", "jeju.kr", "jeonbuk.kr", + "jeonnam.kr", "kg.kr", "mil.kr", "ms.kr", "ne.kr", "or.kr", "pe.kr", "re.kr", + "sc.kr", "seoul.kr", "ulsan.kr", + // .kw + "com.kw", "edu.kw", "gov.kw", "net.kw", "org.kw", + // .ky + "com.ky", "edu.ky", "gov.ky", "net.ky", "org.ky", + // .kz + "com.kz", "edu.kz", "gov.kz", "mil.kz", "net.kz", "org.kz", + // .lb + "com.lb", "edu.lb", "gov.lb", "net.lb", "org.lb", + // .lk + "assn.lk", "com.lk", "edu.lk", "gov.lk", "grp.lk", "hotel.lk", "int.lk", "ltd.lk", + "net.lk", "ngo.lk", "org.lk", "sch.lk", "soc.lk", "web.lk", + // .lr + "com.lr", "edu.lr", "gov.lr", "net.lr", "org.lr", + // .lv + "asn.lv", "com.lv", "conf.lv", "edu.lv", "gov.lv", "id.lv", "mil.lv", "net.lv", "org.lv", + // .ly + "com.ly", "edu.ly", "gov.ly", "id.ly", "med.ly", "net.ly", "org.ly", "plc.ly", "sch.ly", + // .ma + "ac.ma", "co.ma", "gov.ma", "net.ma", "org.ma", "press.ma", + // .mc + "asso.mc", "tm.mc", + // .me + "ac.me", "co.me", "edu.me", "gov.me", "its.me", "net.me", "org.me", "priv.me", + // .mg + "com.mg", "edu.mg", "gov.mg", "mil.mg", "nom.mg", "org.mg", "prd.mg", "tm.mg", + // .mk + "com.mk", "edu.mk", "gov.mk", "inf.mk", "name.mk", "net.mk", "org.mk", "pro.mk", + // .ml + "com.ml", "edu.ml", "gov.ml", "net.ml", "org.ml", "presse.ml", + // .mn + "edu.mn", "gov.mn", "org.mn", + // .mo + "com.mo", "edu.mo", "gov.mo", "net.mo", "org.mo", + // .mt + "com.mt", "edu.mt", "gov.mt", "net.mt", "org.mt", + // .mu + "ac.mu", "co.mu", "com.mu", "gov.mu", "net.mu", "or.mu", "org.mu", + // .mv + "aero.mv", "biz.mv", "com.mv", "coop.mv", "edu.mv", "gov.mv", "info.mv", + "int.mv", "mil.mv", "museum.mv", "name.mv", "net.mv", "org.mv", "pro.mv", + // .mw + "ac.mw", "co.mw", "com.mw", "coop.mw", "edu.mw", "gov.mw", "int.mw", + "museum.mw", "net.mw", "org.mw", + // .mx + "com.mx", "edu.mx", "gob.mx", "net.mx", "org.mx", + // .my + "com.my", "edu.my", "gov.my", "mil.my", "name.my", "net.my", "org.my", "sch.my", + // .mz + "ac.mz", "co.mz", "edu.mz", "gov.mz", "org.mz", + // .na + "co.na", "com.na", + // .nf + "arts.nf", "com.nf", "firm.nf", "info.nf", "net.nf", "other.nf", "per.nf", + "rec.nf", "store.nf", "web.nf", + // .ng + "biz.ng", "com.ng", "edu.ng", "gov.ng", "mil.ng", "mobi.ng", "name.ng", + "net.ng", "org.ng", "sch.ng", + // .ni + "ac.ni", "co.ni", "com.ni", "edu.ni", "gob.ni", "mil.ni", "net.ni", "nom.ni", "org.ni", + // .np + "com.np", "edu.np", "gov.np", "mil.np", "net.np", "org.np", + // .nr + "biz.nr", "com.nr", "edu.nr", "gov.nr", "info.nr", "net.nr", "org.nr", + // .nz + "ac.nz", "co.nz", "cri.nz", "geek.nz", "gen.nz", "govt.nz", "health.nz", + "iwi.nz", "maori.nz", "mil.nz", "net.nz", "org.nz", "parliament.nz", "school.nz", + // .om + "ac.om", "biz.om", "co.om", "com.om", "edu.om", "gov.om", "med.om", "mil.om", + "museum.om", "net.om", "org.om", "pro.om", "sch.om", + // .pa + "abo.pa", "ac.pa", "com.pa", "edu.pa", "gob.pa", "ing.pa", "med.pa", "net.pa", + "nom.pa", "org.pa", "sld.pa", + // .pe + "com.pe", "edu.pe", "gob.pe", "mil.pe", "net.pe", "nom.pe", "org.pe", "sld.pe", + // .ph + "com.ph", "edu.ph", "gov.ph", "i.ph", "mil.ph", "net.ph", "ngo.ph", "org.ph", + // .pk + "biz.pk", "com.pk", "edu.pk", "fam.pk", "gob.pk", "gok.pk", "gon.pk", "gop.pk", + "gos.pk", "gov.pk", "net.pk", "org.pk", "web.pk", + // .pl + "art.pl", "bialystok.pl", "biz.pl", "com.pl", "edu.pl", "gda.pl", "gdansk.pl", + "gorzow.pl", "gov.pl", "info.pl", "katowice.pl", "krakow.pl", "lodz.pl", + "lublin.pl", "mil.pl", "net.pl", "ngo.pl", "olsztyn.pl", "org.pl", "poznan.pl", + "pwr.pl", "radom.pl", "slupsk.pl", "szczecin.pl", "torun.pl", "warszawa.pl", + "waw.pl", "wroc.pl", "wroclaw.pl", "zgora.pl", + // .pr + "ac.pr", "biz.pr", "com.pr", "edu.pr", "est.pr", "gov.pr", "info.pr", "isla.pr", + "name.pr", "net.pr", "org.pr", "pro.pr", "prof.pr", + // .ps + "com.ps", "edu.ps", "gov.ps", "net.ps", "org.ps", "plo.ps", "sec.ps", + // .pt + "com.pt", "edu.pt", "gov.pt", "int.pt", "net.pt", "nome.pt", "org.pt", "publ.pt", + // .pw + "belau.pw", "co.pw", "ed.pw", "go.pw", "ne.pw", "or.pw", + // .py + "com.py", "edu.py", "gov.py", "mil.py", "net.py", "org.py", + // .qa + "com.qa", "edu.qa", "gov.qa", "mil.qa", "net.qa", "org.qa", + // .re + "asso.re", "com.re", "nom.re", + // .ro + "arts.ro", "com.ro", "firm.ro", "info.ro", "nom.ro", "nt.ro", "org.ro", + "rec.ro", "store.ro", "tm.ro", "www.ro", + // .rs + "ac.rs", "co.rs", "edu.rs", "gov.rs", "in.rs", "org.rs", + // .ru + "ac.ru", "adygeya.ru", "altai.ru", "amur.ru", "arkhangelsk.ru", "astrakhan.ru", + "bashkiria.ru", "belgorod.ru", "bir.ru", "bryansk.ru", "buryatia.ru", "cbg.ru", + "chel.ru", "chelyabinsk.ru", "chita.ru", "chukotka.ru", "chuvashia.ru", "com.ru", + "dagestan.ru", "e-burg.ru", "edu.ru", "gov.ru", "grozny.ru", "int.ru", + "irkutsk.ru", "ivanovo.ru", "izhevsk.ru", "jar.ru", "joshkar-ola.ru", + "kalmykia.ru", "kaluga.ru", "kamchatka.ru", "karelia.ru", "kazan.ru", "kchr.ru", + "kemerovo.ru", "khabarovsk.ru", "khakassia.ru", "khv.ru", "kirov.ru", + "koenig.ru", "komi.ru", "kostroma.ru", "kranoyarsk.ru", "kuban.ru", "kurgan.ru", + "kursk.ru", "lipetsk.ru", "magadan.ru", "mari.ru", "mari-el.ru", "marine.ru", + "mil.ru", "mordovia.ru", "mosreg.ru", "msk.ru", "murmansk.ru", "nalchik.ru", + "net.ru", "nnov.ru", "nov.ru", "novosibirsk.ru", "nsk.ru", "omsk.ru", + "orenburg.ru", "org.ru", "oryol.ru", "penza.ru", "perm.ru", "pp.ru", "pskov.ru", + "ptz.ru", "rnd.ru", "ryazan.ru", "sakhalin.ru", "samara.ru", "saratov.ru", + "simbirsk.ru", "smolensk.ru", "spb.ru", "stavropol.ru", "stv.ru", "surgut.ru", + "tambov.ru", "tatarstan.ru", "tom.ru", "tomsk.ru", "tsaritsyn.ru", "tsk.ru", + "tula.ru", "tuva.ru", "tver.ru", "tyumen.ru", "udm.ru", "udmurtia.ru", + "ulan-ude.ru", "vladikavkaz.ru", "vladimir.ru", "vladivostok.ru", "volgograd.ru", + "vologda.ru", "voronezh.ru", "vrn.ru", "vyatka.ru", "yakutia.ru", "yamal.ru", + "yekaterinburg.ru", "yuzhno-sakhalinsk.ru", + // .rw + "ac.rw", "co.rw", "com.rw", "edu.rw", "gouv.rw", "gov.rw", "int.rw", "mil.rw", "net.rw", + // .sa + "com.sa", "edu.sa", "gov.sa", "med.sa", "net.sa", "org.sa", "pub.sa", "sch.sa", + // .sb + "com.sb", "edu.sb", "gov.sb", "net.sb", "org.sb", + // .sc + "com.sc", "edu.sc", "gov.sc", "net.sc", "org.sc", + // .sd + "com.sd", "edu.sd", "gov.sd", "info.sd", "med.sd", "net.sd", "org.sd", "tv.sd", + // .se + "a.se", "ac.se", "b.se", "bd.se", "c.se", "d.se", "e.se", "f.se", "g.se", + "h.se", "i.se", "k.se", "l.se", "m.se", "n.se", "o.se", "org.se", "p.se", + "parti.se", "pp.se", "press.se", "r.se", "s.se", "t.se", "tm.se", "u.se", + "w.se", "x.se", "y.se", "z.se", + // .sg + "com.sg", "edu.sg", "gov.sg", "idn.sg", "net.sg", "org.sg", "per.sg", + // .sh + "co.sh", "com.sh", "edu.sh", "gov.sh", "net.sh", "nom.sh", "org.sh", + // .sl + "com.sl", "edu.sl", "gov.sl", "net.sl", "org.sl", + // .sn + "art.sn", "com.sn", "edu.sn", "gouv.sn", "org.sn", "perso.sn", "univ.sn", + // .st + "co.st", "com.st", "consulado.st", "edu.st", "embaixada.st", "gov.st", "mil.st", + "net.st", "org.st", "principe.st", "saotome.st", "store.st", + // .sv + "com.sv", "edu.sv", "gob.sv", "org.sv", "red.sv", + // .sy + "com.sy", "edu.sy", "gov.sy", "mil.sy", "net.sy", "news.sy", "org.sy", + // .sz + "ac.sz", "co.sz", "org.sz", + // .th + "ac.th", "co.th", "go.th", "in.th", "mi.th", "net.th", "or.th", + // .tj + "ac.tj", "biz.tj", "co.tj", "com.tj", "edu.tj", "go.tj", "gov.tj", "info.tj", + "int.tj", "mil.tj", "name.tj", "net.tj", "nic.tj", "org.tj", "test.tj", "web.tj", + // .tn + "agrinet.tn", "com.tn", "defense.tn", "edunet.tn", "ens.tn", "fin.tn", "gov.tn", + "ind.tn", "info.tn", "intl.tn", "mincom.tn", "nat.tn", "net.tn", "org.tn", + "perso.tn", "rnrt.tn", "rns.tn", "rnu.tn", "tourism.tn", + // .tr + "av.tr", "bbs.tr", "bel.tr", "biz.tr", "com.tr", "dr.tr", "edu.tr", "gen.tr", + "gov.tr", "info.tr", "k12.tr", "name.tr", "net.tr", "org.tr", "pol.tr", + "tel.tr", "tsk.tr", "tv.tr", "web.tr", + // .tt + "aero.tt", "biz.tt", "cat.tt", "co.tt", "com.tt", "coop.tt", "edu.tt", "gov.tt", + "info.tt", "int.tt", "jobs.tt", "mil.tt", "mobi.tt", "museum.tt", "name.tt", + "net.tt", "org.tt", "pro.tt", "tel.tt", "travel.tt", + // .tw + "club.tw", "com.tw", "ebiz.tw", "edu.tw", "game.tw", "gov.tw", "idv.tw", + "mil.tw", "net.tw", "org.tw", + // .tz + "ac.tz", "co.tz", "go.tz", "ne.tz", "or.tz", + // .ua + "biz.ua", "cherkassy.ua", "chernigov.ua", "chernovtsy.ua", "ck.ua", "cn.ua", + "co.ua", "com.ua", "crimea.ua", "cv.ua", "dn.ua", "dnepropetrovsk.ua", + "donetsk.ua", "dp.ua", "edu.ua", "gov.ua", "if.ua", "in.ua", + "ivano-frankivsk.ua", "kh.ua", "kharkov.ua", "kherson.ua", "khmelnitskiy.ua", + "kiev.ua", "kirovograd.ua", "km.ua", "kr.ua", "ks.ua", "kv.ua", "lg.ua", + "lugansk.ua", "lutsk.ua", "lviv.ua", "me.ua", "mk.ua", "net.ua", + "nikolaev.ua", "od.ua", "odessa.ua", "org.ua", "pl.ua", "poltava.ua", "pp.ua", + "rovno.ua", "rv.ua", "sebastopol.ua", "sumy.ua", "te.ua", "ternopil.ua", + "uzhgorod.ua", "vinnica.ua", "vn.ua", "zaporizhzhe.ua", "zhitomir.ua", + "zp.ua", "zt.ua", + // .ug + "ac.ug", "co.ug", "go.ug", "ne.ug", "or.ug", "org.ug", "sc.ug", + // .uk + "ac.uk", "bl.uk", "british-library.uk", "co.uk", "cym.uk", "gov.uk", "govt.uk", + "icnet.uk", "jet.uk", "lea.uk", "ltd.uk", "me.uk", "mil.uk", "mod.uk", + "national-library-scotland.uk", "nel.uk", "net.uk", "nhs.uk", "nic.uk", + "nls.uk", "org.uk", "orgn.uk", "parliament.uk", "plc.uk", "police.uk", + "sch.uk", "scot.uk", "soc.uk", + // .us + "4fd.us", "dni.us", "fed.us", "isa.us", "kids.us", "nsn.us", + // .uy + "com.uy", "edu.uy", "gub.uy", "mil.uy", "net.uy", "org.uy", + // .ve + "co.ve", "com.ve", "edu.ve", "gob.ve", "info.ve", "mil.ve", "net.ve", "org.ve", "web.ve", + // .vi + "co.vi", "com.vi", "k12.vi", "net.vi", "org.vi", + // .vn + "ac.vn", "biz.vn", "com.vn", "edu.vn", "gov.vn", "health.vn", "info.vn", + "int.vn", "name.vn", "net.vn", "org.vn", "pro.vn", + // .ye + "co.ye", "com.ye", "gov.ye", "ltd.ye", "me.ye", "net.ye", "org.ye", "plc.ye", + // .yu + "ac.yu", "co.yu", "edu.yu", "gov.yu", "org.yu", + // .za + "ac.za", "agric.za", "alt.za", "bourse.za", "city.za", "co.za", "cybernet.za", + "db.za", "edu.za", "gov.za", "grondar.za", "iaccess.za", "imt.za", "inca.za", + "landesign.za", "law.za", "mil.za", "net.za", "ngo.za", "nis.za", "nom.za", + "olivetti.za", "org.za", "pix.za", "school.za", "tm.za", "web.za", + // .zm + "ac.zm", "co.zm", "com.zm", "edu.zm", "gov.zm", "net.zm", "org.zm", "sch.zm", +]); + +export function getRootDomain(domain: string): string { + const parts = domain.split("."); + if (parts.length <= 2) return domain; + const lastTwo = parts.slice(-2).join("."); + if (MULTI_PART_TLDS.has(lastTwo)) { + return parts.length >= 3 ? parts.slice(-3).join(".") : domain; + } + return parts.slice(-2).join("."); +} diff --git a/locales/ar/common.json b/locales/ar/common.json index cb0c291f7..16f68c921 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "تدّعي هذه الرسالة أنها من {domain}، لكنها أُرسلت من {host} ولم تجتز فحوصات المرسل الخاصة بـ {domain}. قد تكون مزوّرة.", "caution": "توخَّ الحذر مع الروابط والمرفقات وطلبات كلمات المرور أو بيانات الدفع." }, + "brand_logo": { + "verified_label": "شعار موثّق", + "verified": "هذا الشعار موثّق على أنه يخص {organization}.", + "verified_by": "تحققت {issuer} من أن هذا الشعار يخص {organization}." + }, "back_to_list": "العودة إلى القائمة", "view_contact": "عرض جهة الاتصال", "message_details": "تفاصيل الرسالة", diff --git a/locales/ca/common.json b/locales/ca/common.json index 0b1c8d3a6..df68c6863 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Aquest missatge diu que és de {domain}, però s'ha enviat des de {host} i no ha superat les comprovacions de remitent de {domain}. Podria estar falsificat.", "caution": "Aneu amb compte amb els enllaços, els fitxers adjunts i les sol·licituds de contrasenyes o dades de pagament." }, + "brand_logo": { + "verified_label": "Logotip verificat", + "verified": "Aquest logotip està verificat com a propietat de {organization}.", + "verified_by": "{issuer} ha verificat que aquest logotip pertany a {organization}." + }, "back_to_list": "Torna a la llista", "view_contact": "Mostra el contacte", "message_details": "Detalls del missatge", diff --git a/locales/cs/common.json b/locales/cs/common.json index 743f5fb4c..12e216360 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Tato zpráva tvrdí, že pochází z domény {domain}, ale byla odeslána z domény {host} a neprošla kontrolami odesílatele domény {domain}. Může být podvržená.", "caution": "Buďte opatrní u odkazů, příloh a žádostí o hesla nebo platební údaje." }, + "brand_logo": { + "verified_label": "Ověřené logo", + "verified": "Toto logo je ověřeno jako logo společnosti {organization}.", + "verified_by": "{issuer} ověřil, že toto logo patří společnosti {organization}." + }, "back_to_list": "Zpět na seznam", "view_contact": "Zobrazit kontakt", "message_details": "Podrobnosti zprávy", diff --git a/locales/da/common.json b/locales/da/common.json index b08254f01..8b62f80f6 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Denne besked udgiver sig for at være fra {domain}, men den blev sendt fra {host} og bestod ikke afsenderkontrollen for {domain}. Den kan være forfalsket.", "caution": "Vær forsigtig med links, vedhæftede filer og anmodninger om adgangskoder eller betalingsoplysninger." }, + "brand_logo": { + "verified_label": "Verificeret logo", + "verified": "Dette logo er verificeret som tilhørende {organization}.", + "verified_by": "{issuer} har verificeret, at dette logo tilhører {organization}." + }, "back_to_list": "Tilbage til liste", "view_contact": "Vis kontakt", "message_details": "Beskeddetaljer", diff --git a/locales/de/common.json b/locales/de/common.json index 980ba1e7b..63694491a 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Diese Nachricht gibt vor, von {domain} zu kommen, wurde aber von {host} verschickt und hat die Absenderprüfung von {domain} nicht bestanden. Sie ist möglicherweise gefälscht.", "caution": "Seien Sie vorsichtig mit Links, Anhängen und Fragen nach Passwörtern oder Zahlungsdaten." }, + "brand_logo": { + "verified_label": "Verifiziertes Logo", + "verified": "Dieses Logo ist als Logo von {organization} verifiziert.", + "verified_by": "{issuer} hat bestätigt, dass dieses Logo zu {organization} gehört." + }, "back_to_list": "Zurück zur Liste", "view_contact": "Kontakt anzeigen", "message_details": "Nachrichtendetails", diff --git a/locales/en/common.json b/locales/en/common.json index df7a98ef0..339fb5304 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "This message claims to be from {domain}, but it was sent from {host} and failed {domain}'s sender checks. It may be forged.", "caution": "Be careful with links, attachments and requests for passwords or payment details." }, + "brand_logo": { + "verified_label": "Verified logo", + "verified": "This logo is verified as belonging to {organization}.", + "verified_by": "{issuer} verified that this logo belongs to {organization}." + }, "back_to_list": "Back to list", "view_contact": "View contact", "message_details": "Message Details", diff --git a/locales/es/common.json b/locales/es/common.json index 32a1b37da..5b4c61ada 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Este mensaje dice proceder de {domain}, pero se envió desde {host} y no superó las comprobaciones de remitente de {domain}. Podría estar falsificado.", "caution": "Tenga cuidado con los enlaces, los archivos adjuntos y las solicitudes de contraseñas o datos de pago." }, + "brand_logo": { + "verified_label": "Logotipo verificado", + "verified": "Este logotipo está verificado como propiedad de {organization}.", + "verified_by": "{issuer} verificó que este logotipo pertenece a {organization}." + }, "back_to_list": "Volver a la lista", "view_contact": "Ver contacto", "message_details": "Detalles del Mensaje", diff --git a/locales/et/common.json b/locales/et/common.json index 427dfc56a..12a2f10f2 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "See kiri väidab, et on saadetud domeenilt {domain}, kuid see saadeti hostilt {host} ega läbinud domeeni {domain} saatjakontrolle. See võib olla võltsitud.", "caution": "Ole ettevaatlik linkide, manuste ning paroolide või makseandmete küsimistega." }, + "brand_logo": { + "verified_label": "Kinnitatud logo", + "verified": "See logo on kinnitatud kui {organization} logo.", + "verified_by": "{issuer} kinnitas, et see logo kuulub ettevõttele {organization}." + }, "back_to_list": "Tagasi loetellu", "view_contact": "Vaata kontakti", "message_details": "Kirja üksikasjad", diff --git a/locales/fa/common.json b/locales/fa/common.json index bbe9afa8e..05b5e0229 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "این پیام ادعا می‌کند از {domain} است، اما از {host} ارسال شده و در بررسی‌های فرستنده برای {domain} رد شده است. ممکن است جعلی باشد.", "caution": "مراقب پیوندها، پیوست‌ها و درخواست‌های رمز عبور یا اطلاعات پرداخت باشید." }, + "brand_logo": { + "verified_label": "لوگوی تأییدشده", + "verified": "این لوگو به‌عنوان متعلق به {organization} تأیید شده است.", + "verified_by": "{issuer} تأیید کرده است که این لوگو متعلق به {organization} است." + }, "back_to_list": "بازگشت به لیست", "view_contact": "مشاهده مخاطب", "message_details": "جزئیات پیام", diff --git a/locales/fr/common.json b/locales/fr/common.json index e672743a7..06c3c27a5 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Ce message prétend venir de {domain}, mais il a été envoyé depuis {host} et a échoué aux vérifications d'expéditeur de {domain}. Il est peut-être falsifié.", "caution": "Méfiez-vous des liens, des pièces jointes et des demandes de mots de passe ou d'informations de paiement." }, + "brand_logo": { + "verified_label": "Logo vérifié", + "verified": "Ce logo est vérifié comme appartenant à {organization}.", + "verified_by": "{issuer} a vérifié que ce logo appartient à {organization}." + }, "back_to_list": "Retour à la liste", "view_contact": "Voir le contact", "message_details": "Détails du message", diff --git a/locales/he/common.json b/locales/he/common.json index fb2b3dcde..0ac090b4d 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -446,6 +446,11 @@ "failed_sent_from": "ההודעה הזו טוענת שהיא מהדומיין {domain}, אבל היא נשלחה מהדומיין {host} ונכשלה בבדיקות השולח של {domain}. ייתכן שהיא מזויפת.", "caution": "היזהר מקישורים, מקבצים מצורפים ומבקשות לסיסמאות או לפרטי תשלום." }, + "brand_logo": { + "verified_label": "לוגו מאומת", + "verified": "הלוגו הזה מאומת כשייך ל-{organization}.", + "verified_by": "{issuer} אימת שהלוגו הזה שייך ל-{organization}." + }, "back_to_list": "חזרה לרשימה", "view_contact": "הצג איש קשר", "message_details": "פרטי הודעה", diff --git a/locales/hu/common.json b/locales/hu/common.json index 299c043db..11c00331b 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Ez az üzenet azt állítja, hogy a(z) {domain} tartományból jött, de a(z) {host} tartományból küldték, és nem ment át a(z) {domain} tartomány feladóellenőrzésén. Lehet, hogy hamisított.", "caution": "Légy óvatos a linkekkel, a mellékletekkel, és ha jelszót vagy fizetési adatokat kérnek tőled." }, + "brand_logo": { + "verified_label": "Ellenőrzött logó", + "verified": "Ez a logó ellenőrzötten a(z) {organization} tulajdona.", + "verified_by": "A(z) {issuer} ellenőrizte, hogy ez a logó a(z) {organization} tulajdona." + }, "back_to_list": "Vissza a listához", "view_contact": "Névjegy megtekintése", "message_details": "Üzenet részletei", diff --git a/locales/it/common.json b/locales/it/common.json index 396d86c3f..63b01167f 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Questo messaggio dichiara di provenire da {domain}, ma è stato inviato da {host} e non ha superato i controlli sul mittente di {domain}. Potrebbe essere falsificato.", "caution": "Fai attenzione a link, allegati e richieste di password o dati di pagamento." }, + "brand_logo": { + "verified_label": "Logo verificato", + "verified": "Questo logo è verificato come appartenente a {organization}.", + "verified_by": "{issuer} ha verificato che questo logo appartiene a {organization}." + }, "back_to_list": "Torna all'elenco", "view_contact": "Visualizza contatto", "message_details": "Dettagli del messaggio", diff --git a/locales/ja/common.json b/locales/ja/common.json index ed53c4e6d..c6fd4b814 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "このメッセージは{domain}からのものとされていますが、実際には{host}から送信されており、{domain}の送信者チェックにも失敗しました。なりすましの可能性があります。", "caution": "リンクや添付ファイル、パスワードや支払い情報の要求には注意してください。" }, + "brand_logo": { + "verified_label": "認証済みロゴ", + "verified": "このロゴは {organization} のものとして認証されています。", + "verified_by": "{issuer} がこのロゴが {organization} のものであることを認証しました。" + }, "back_to_list": "リストに戻る", "view_contact": "連絡先を表示", "message_details": "メッセージの詳細", diff --git a/locales/ko/common.json b/locales/ko/common.json index 4456df28c..7cf2bbbc7 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "이 메시지는 {domain}에서 보냈다고 하지만 실제로는 {host}에서 발송되었고 {domain}의 보낸 사람 확인을 통과하지 못했어요. 위조된 메시지일 수 있어요.", "caution": "링크, 첨부 파일, 비밀번호나 결제 정보 요청에 주의하세요." }, + "brand_logo": { + "verified_label": "인증된 로고", + "verified": "이 로고는 {organization}의 로고로 인증되었습니다.", + "verified_by": "{issuer}에서 이 로고가 {organization}의 로고임을 인증했습니다." + }, "back_to_list": "목록으로", "view_contact": "연락처 보기", "message_details": "메시지 상세 정보", diff --git a/locales/lv/common.json b/locales/lv/common.json index 50a7efa7b..0db3f22a5 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Šis ziņojums apgalvo, ka ir no domēna {domain}, taču tas nosūtīts no domēna {host} un neizturēja domēna {domain} sūtītāja pārbaudes. Tas var būt viltots.", "caution": "Esiet piesardzīgi ar saitēm, pielikumiem un paroļu vai maksājumu datu pieprasījumiem." }, + "brand_logo": { + "verified_label": "Verificēts logotips", + "verified": "Šis logotips ir verificēts kā {organization} logotips.", + "verified_by": "{issuer} ir verificējis, ka šis logotips pieder {organization}." + }, "back_to_list": "Atpakaļ uz sarakstu", "view_contact": "Skatīt kontaktu", "message_details": "Informācija par ziņojumu", diff --git a/locales/mn/common.json b/locales/mn/common.json index a37c37b25..313dba597 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Энэ зурвас {domain} домэйнээс ирсэн гэж мэдэгдэж байгаа ч {host} домэйнээс илгээгдсэн бөгөөд {domain} домэйны илгээгчийн шалгалтыг давж чадаагүй. Хуурамч байж магадгүй.", "caution": "Холбоос, хавсралт болон нууц үг, төлбөрийн мэдээлэл асуусан хүсэлтэд болгоомжтой хандаарай." }, + "brand_logo": { + "verified_label": "Баталгаажсан лого", + "verified": "Энэ лого {organization}-д хамаарах нь баталгаажсан.", + "verified_by": "{issuer} энэ лого {organization}-д хамаарахыг баталгаажуулсан." + }, "back_to_list": "Жагсаалт руу буцах", "view_contact": "Харилцагчийг харах", "message_details": "Зурвасны дэлгэрэнгүй мэдээлэл", diff --git a/locales/nb/common.json b/locales/nb/common.json index 74b1d625c..0217528bd 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Denne meldingen utgir seg for å være fra {domain}, men den ble sendt fra {host} og besto ikke avsenderkontrollene til {domain}. Den kan være forfalsket.", "caution": "Vær forsiktig med lenker, vedlegg og forespørsler om passord eller betalingsopplysninger." }, + "brand_logo": { + "verified_label": "Bekreftet logo", + "verified": "Denne logoen er bekreftet å tilhøre {organization}.", + "verified_by": "{issuer} har bekreftet at denne logoen tilhører {organization}." + }, "back_to_list": "Tilbake til listen", "view_contact": "Se kontakt", "message_details": "Meldingsdetaljer", diff --git a/locales/nl/common.json b/locales/nl/common.json index 1007e62bf..fd1e0a2d9 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Dit bericht beweert van {domain} te komen, maar is verzonden vanaf {host} en is niet door de afzendercontroles van {domain} gekomen. Het kan vervalst zijn.", "caution": "Wees voorzichtig met links, bijlagen en verzoeken om wachtwoorden of betaalgegevens." }, + "brand_logo": { + "verified_label": "Geverifieerd logo", + "verified": "Dit logo is geverifieerd als logo van {organization}.", + "verified_by": "{issuer} heeft geverifieerd dat dit logo van {organization} is." + }, "back_to_list": "Terug naar lijst", "view_contact": "Contact bekijken", "message_details": "Berichtdetails", diff --git a/locales/pl/common.json b/locales/pl/common.json index 4036b29cd..39424a911 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Ta wiadomość twierdzi, że pochodzi z domeny {domain}, ale została wysłana z domeny {host} i nie przeszła weryfikacji nadawcy domeny {domain}. Może być sfałszowana.", "caution": "Uważaj na linki, załączniki i prośby o hasła lub dane płatnicze." }, + "brand_logo": { + "verified_label": "Zweryfikowane logo", + "verified": "To logo zostało zweryfikowane jako należące do {organization}.", + "verified_by": "{issuer} potwierdził, że to logo należy do {organization}." + }, "back_to_list": "Powrót do listy", "view_contact": "Pokaż kontakt", "message_details": "Szczegóły wiadomości", diff --git a/locales/pt/common.json b/locales/pt/common.json index f9ae49b67..3c4c24106 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Esta mensagem afirma ser de {domain}, mas foi enviada de {host} e não passou nas verificações de remetente de {domain}. Pode ter sido falsificada.", "caution": "Tenha cuidado com links, anexos e pedidos de senhas ou dados de pagamento." }, + "brand_logo": { + "verified_label": "Logotipo verificado", + "verified": "Este logotipo foi verificado como pertencente a {organization}.", + "verified_by": "{issuer} verificou que este logotipo pertence a {organization}." + }, "back_to_list": "Voltar para a lista", "view_contact": "Ver contato", "message_details": "Detalhes da Mensagem", diff --git a/locales/ro/common.json b/locales/ro/common.json index 1b9f6be13..83222e1ac 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Acest mesaj pretinde că provine de la {domain}, dar a fost trimis de pe {host} și nu a trecut de verificările de expeditor ale domeniului {domain}. Este posibil să fie falsificat.", "caution": "Fiți atenți la linkuri, atașamente și solicitări de parole sau date de plată." }, + "brand_logo": { + "verified_label": "Logo verificat", + "verified": "Acest logo este verificat ca aparținând {organization}.", + "verified_by": "{issuer} a verificat că acest logo aparține {organization}." + }, "back_to_list": "Înapoi la listă", "view_contact": "Vizualizare contact", "message_details": "Detalii mesaj", diff --git a/locales/ru/common.json b/locales/ru/common.json index 4beb6f65b..30b6f233f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Это сообщение якобы отправлено с домена {domain}, но на самом деле оно отправлено с домена {host} и не прошло проверку отправителя домена {domain}. Возможно, оно поддельное.", "caution": "Будьте осторожны со ссылками, вложениями и просьбами указать пароль или платёжные данные." }, + "brand_logo": { + "verified_label": "Подтверждённый логотип", + "verified": "Этот логотип подтверждён как принадлежащий {organization}.", + "verified_by": "{issuer} подтвердил, что этот логотип принадлежит {organization}." + }, "back_to_list": "Вернуться к списку", "view_contact": "Просмотреть контакт", "message_details": "Детали сообщения", diff --git a/locales/sk/common.json b/locales/sk/common.json index be68b68c1..d9144010c 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Táto správa tvrdí, že pochádza z domény {domain}, ale bola odoslaná z domény {host} a neprešla kontrolami odosielateľa domény {domain}. Môže byť podvrhnutá.", "caution": "Buďte opatrní pri odkazoch, prílohách a žiadostiach o heslá alebo platobné údaje." }, + "brand_logo": { + "verified_label": "Overené logo", + "verified": "Toto logo je overené ako logo spoločnosti {organization}.", + "verified_by": "{issuer} overil, že toto logo patrí spoločnosti {organization}." + }, "back_to_list": "Späť na zoznam", "view_contact": "Zobraziť kontakt", "message_details": "Podrobnosti správy", diff --git a/locales/th/common.json b/locales/th/common.json index 04bc0c8c3..66e44a8f8 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -1432,6 +1432,11 @@ "failed_sent_from": "ข้อความนี้อ้างว่าส่งมาจาก {domain} แต่ส่งมาจาก {host} และไม่ผ่านการตรวจสอบความปลอดภัย อาจเป็นข้อความปลอมแปลง", "caution": "โปรดระมัดระวังการคลิกลิงก์หรือเปิดไฟล์แนบในข้อความนี้" }, + "brand_logo": { + "verified_label": "โลโก้ที่ยืนยันแล้ว", + "verified": "โลโก้นี้ได้รับการยืนยันว่าเป็นของ {organization}", + "verified_by": "{issuer} ยืนยันแล้วว่าโลโก้นี้เป็นของ {organization}" + }, "back_to_list": "กลับไปยังรายการอีเมล", "view_contact": "ดูข้อมูลผู้ติดต่อ", "message_details": "รายละเอียดข้อความ", diff --git a/locales/tr/common.json b/locales/tr/common.json index 665a1e40c..c53a09b0e 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Bu ileti {domain} etki alanından geldiğini iddia ediyor, ancak {host} etki alanından gönderildi ve {domain} etki alanının gönderen denetimlerinden geçemedi. Sahte olabilir.", "caution": "Bağlantılara, eklere ve parola ya da ödeme bilgisi isteklerine karşı dikkatli olun." }, + "brand_logo": { + "verified_label": "Doğrulanmış logo", + "verified": "Bu logonun {organization} kuruluşuna ait olduğu doğrulandı.", + "verified_by": "{issuer}, bu logonun {organization} kuruluşuna ait olduğunu doğruladı." + }, "back_to_list": "Listeye dön", "view_contact": "Kişiyi görüntüle", "message_details": "İleti Ayrıntıları", diff --git a/locales/uk/common.json b/locales/uk/common.json index 48dd3c434..440dc7fc7 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Це повідомлення нібито надіслано з домену {domain}, але насправді його надіслано з домену {host}, і воно не пройшло перевірку відправника домену {domain}. Можливо, воно підроблене.", "caution": "Будьте обережні з посиланнями, вкладеннями та запитами паролів чи платіжних даних." }, + "brand_logo": { + "verified_label": "Підтверджений логотип", + "verified": "Цей логотип підтверджено як такий, що належить {organization}.", + "verified_by": "{issuer} підтвердив, що цей логотип належить {organization}." + }, "back_to_list": "Повернутися до списку", "view_contact": "Переглянути контакт", "message_details": "Деталі повідомлення", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index e1a63932e..79cdca437 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "這封郵件聲稱來自 {domain},但它是從 {host} 寄出的,而且未通過 {domain} 的寄件者驗證,可能是偽造的。", "caution": "請小心其中的連結、附件,以及要求提供密碼或付款資訊的請求。" }, + "brand_logo": { + "verified_label": "已驗證的標誌", + "verified": "此標誌已驗證屬於 {organization}。", + "verified_by": "{issuer} 已驗證此標誌屬於 {organization}。" + }, "back_to_list": "回到清單", "view_contact": "檢視聯絡人", "message_details": "郵件詳細資料", diff --git a/locales/zh/common.json b/locales/zh/common.json index 238f74379..42e4e518d 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "此邮件声称来自 {domain},但它是从 {host} 发出的,并且未通过 {domain} 的发件人验证。它可能是伪造的。", "caution": "请谨慎对待其中的链接、附件,以及索要密码或付款信息的请求。" }, + "brand_logo": { + "verified_label": "已验证的标志", + "verified": "此标志已验证属于 {organization}。", + "verified_by": "{issuer} 已验证此标志属于 {organization}。" + }, "back_to_list": "返回列表", "view_contact": "查看联系人", "message_details": "邮件详情", diff --git a/stores/favicon-refresh-store.ts b/stores/favicon-refresh-store.ts index f6087774b..d366c6a33 100644 --- a/stores/favicon-refresh-store.ts +++ b/stores/favicon-refresh-store.ts @@ -4,6 +4,8 @@ import { persist } from 'zustand/middleware'; // Domains whose favicon failed to load in this tab. Avatars skip them so a // missing icon isn't requested again on every render. export const failedFaviconDomains = new Set(); +// The same for BIMI logos (/api/bimi), by From domain. +export const failedBrandDomains = new Set(); interface FaviconRefreshState { /** When the user last asked for fresh sender icons (ms), 0 if never. */ @@ -21,6 +23,7 @@ export const useFaviconRefreshStore = create()( refreshedAt: 0, refresh: () => { failedFaviconDomains.clear(); + failedBrandDomains.clear(); set({ refreshedAt: Date.now() }); }, }),