@@ -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(/