From 83ddd13726c526ede731b236716f1eb8027a4b77 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:44:35 +0100 Subject: [PATCH] feat: show the sender's BIMI logo from the webmail when the message passes DMARC --- src/components/SenderAvatar.tsx | 45 +++++++++- src/lib/__tests__/avatar-bimi.test.ts | 115 ++++++++++++++++++++++++++ src/lib/avatar-bimi.ts | 96 +++++++++++++++++++++ src/lib/avatar-utils.ts | 9 +- 4 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 src/lib/__tests__/avatar-bimi.test.ts create mode 100644 src/lib/avatar-bimi.ts diff --git a/src/components/SenderAvatar.tsx b/src/components/SenderAvatar.tsx index 22396e38..618dada8 100644 --- a/src/components/SenderAvatar.tsx +++ b/src/components/SenderAvatar.tsx @@ -12,10 +12,12 @@ import { hasFaviconFailed, markFaviconFailed, } from '../lib/avatar-utils'; +import { bimiLogoUrl, isNoLogoImage, markBrandLogoFailed, showBrandLogo } from '../lib/avatar-bimi'; import type { ContactPhotoRef } from '../lib/contact-utils'; import { useContactPhotoSource } from '../lib/use-contact-photo'; import { useSettingsStore } from '../stores/settings-store'; import { useContactsStore } from '../stores/contacts-store'; +import { useAccountStore } from '../stores/account-store'; import { useColors } from '../theme/colors'; import { ShieldAlert } from './icons'; @@ -43,12 +45,19 @@ interface SenderAvatarProps { * or photo (webmail #1439). */ senderWarning?: string | null; + /** + * The From domain of a message that passed DMARC for it (see + * brandIndicatorDomain in lib/avatar-bimi): its BIMI logo, from the + * account's webmail, comes before the favicon, under the same switch. + */ + brandDomain?: string | null; } // Mirrors the webmail `Avatar` priority for the subset available on native: -// sender warning → address-book photo → company favicon (if sender domain is +// sender warning → address-book photo → BIMI logo (from the account's +// webmail, `senderFavicons` on) → company favicon (if sender domain is // non-personal and `senderFavicons` is on) → initials over HSL-hashed color. -export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo, senderWarning }: SenderAvatarProps) { +export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo, senderWarning, brandDomain }: SenderAvatarProps) { const c = useColors(); const warned = !!senderWarning; const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned; @@ -63,9 +72,13 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma const { source: photoSource, onLoad: onPhotoLoad, onError: onPhotoError } = useContactPhotoSource(disableImages || warned ? null : contactPhoto); const [imgError, setImgError] = React.useState(false); + // BIMI logos come from the webmail the active account signed in through. + const webmailUrl = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.webmailUrl ?? null); + const [failedBrand, setFailedBrand] = React.useState(null); React.useEffect(() => { setImgError(false); + setFailedBrand(null); }, [email]); const primaryName = (name ?? '').split(',')[0].trim(); @@ -74,9 +87,13 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma const faviconDomain = getFaviconDomain(email ?? undefined); const domainFailed = faviconDomain ? hasFaviconFailed(faviconDomain) : false; + const brandUrl = brandDomain ? bimiLogoUrl(webmailUrl, brandDomain) : null; + const showBrand = !!brandUrl && brandUrl !== failedBrand + && showBrandLogo({ senderFavicons, email, brandDomain, webmailUrl }); const content = avatarContent({ senderWarning, hasPhoto: !disableImages && !!photoSource, + showBrand, showFavicon: senderFavicons && !!faviconDomain && !imgError && !domainFailed, }); @@ -84,6 +101,12 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma setImgError(true); if (faviconDomain) markFaviconFailed(faviconDomain); }, [faviconDomain]); + // No logo (or it didn't load): the favicon or initials take over. + const handleBrandFailed = React.useCallback(() => { + if (!brandUrl) return; + markBrandLogoFailed(brandUrl); + setFailedBrand(brandUrl); + }, [brandUrl]); const containerStyle = [ styles.container, @@ -93,7 +116,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma borderRadius: size / 2, backgroundColor: content === 'warning' ? c.errorBg - : content === 'photo' ? 'transparent' : content === 'favicon' ? '#ffffff' : bgColor, + : content === 'photo' ? 'transparent' : content === 'favicon' || content === 'brand' ? '#ffffff' : bgColor, }, style, ]; @@ -127,6 +150,22 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma ); } + if (content === 'brand' && brandUrl) { + return ( + + { if (isNoLogoImage(e.nativeEvent?.source)) handleBrandFailed(); }} + onError={handleBrandFailed} + testID="sender-brand-avatar" + /> + + ); + } + if (content === 'favicon' && faviconDomain) { return ( diff --git a/src/lib/__tests__/avatar-bimi.test.ts b/src/lib/__tests__/avatar-bimi.test.ts new file mode 100644 index 00000000..405bb0f9 --- /dev/null +++ b/src/lib/__tests__/avatar-bimi.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + bimiLogoUrl, + brandIndicatorDomain, + isNoLogoImage, + markBrandLogoFailed, + resetBrandLogosForTests, + showBrandLogo, +} from '../avatar-bimi'; +import { avatarContent } from '../avatar-utils'; +import { LIST_AUTH_RESULTS_PROPERTY } from '../email-headers'; +import type { Email } from '../../api/types'; + +// BIMI logos as the webmail shows them (components/ui/avatar.tsx, +// lib/email-headers.ts brandIndicatorDomain, app/api/bimi). + +const PASS = 'mx.example.org; spf=pass smtp.mailfrom=bank.example; dkim=pass header.d=bank.example; dmarc=pass header.from=bank.example'; +const FAIL = 'mx.example.org; spf=fail smtp.mailfrom=evil.example; dkim=none; dmarc=fail header.from=bank.example'; +const OTHER_DOMAIN = 'mx.example.org; spf=pass smtp.mailfrom=other.example; dkim=pass header.d=other.example; dmarc=pass header.from=other.example'; +const NO_DMARC = 'mx.example.org; spf=pass smtp.mailfrom=bank.example; dkim=pass header.d=bank.example'; +const SPOOFED_PASS = 'mx.example.org; spf=fail smtp.mailfrom=bank.example; dkim=none; dmarc=pass header.from=bank.example'; + +function row(auth?: string[], from = 'billing@bank.example'): Email { + return { + id: 'm1', threadId: 't1', mailboxIds: {}, keywords: {}, size: 1, receivedAt: '2026-10-31T10:00:00Z', + hasAttachment: false, from: [{ name: 'Bank', email: from }], + ...(auth ? { [LIST_AUTH_RESULTS_PROPERTY]: auth } : {}), + } as Email; +} + +beforeEach(() => resetBrandLogosForTests()); + +describe('brandIndicatorDomain', () => { + it('names the From domain of a message that passed DMARC for it, from the list property or the headers', () => { + expect(brandIndicatorDomain(row([PASS]))).toBe('bank.example'); + const full = { ...row(), headers: [{ name: 'Authentication-Results', value: ` ${PASS}` }] }; + expect(brandIndicatorDomain(full)).toBe('bank.example'); + expect(brandIndicatorDomain(row([PASS], 'Billing@BANK.example'))).toBe('bank.example'); + }); + + it('names none for a failed, missing or foreign DMARC result, or a spoofed message', () => { + expect(brandIndicatorDomain(row([FAIL]))).toBeUndefined(); + expect(brandIndicatorDomain(row([NO_DMARC]))).toBeUndefined(); + expect(brandIndicatorDomain(row([OTHER_DOMAIN]))).toBeUndefined(); + expect(brandIndicatorDomain(row([SPOOFED_PASS]))).toBeUndefined(); + expect(brandIndicatorDomain(row())).toBeUndefined(); + expect(brandIndicatorDomain(undefined)).toBeUndefined(); + }); +}); + +describe('bimiLogoUrl', () => { + it('asks the account webmail for the domain', () => { + expect(bimiLogoUrl('https://mail.example.org/', 'bank.example')).toBe('https://mail.example.org/api/bimi?domain=bank.example'); + expect(bimiLogoUrl('https://mail.example.org/webmail', 'a&b.example')).toBe('https://mail.example.org/webmail/api/bimi?domain=a%26b.example'); + }); + + it('has none without a webmail', () => { + expect(bimiLogoUrl(null, 'bank.example')).toBeNull(); + expect(bimiLogoUrl('', 'bank.example')).toBeNull(); + expect(bimiLogoUrl('javascript:alert(1)', 'bank.example')).toBeNull(); + }); +}); + +describe('showBrandLogo', () => { + const base = { senderFavicons: true, email: 'billing@bank.example', brandDomain: 'bank.example', webmailUrl: 'https://mail.example.org' }; + + it('shows the logo for the address whose message passed', () => { + expect(showBrandLogo(base)).toBe(true); + }); + + it('follows the sender favicon switch, which is off by default', () => { + expect(showBrandLogo({ ...base, senderFavicons: false })).toBe(false); + }); + + it('shows none for another address than the one the check was for', () => { + expect(showBrandLogo({ ...base, email: 'someone@elsewhere.example' })).toBe(false); + expect(showBrandLogo({ ...base, email: 'news@mail.bank.example' })).toBe(false); + }); + + it('shows none without a webmail or a passing domain', () => { + expect(showBrandLogo({ ...base, webmailUrl: undefined })).toBe(false); + expect(showBrandLogo({ ...base, brandDomain: undefined })).toBe(false); + }); + + it('stops asking for a logo that failed this session', () => { + markBrandLogoFailed('https://mail.example.org/api/bimi?domain=bank.example'); + expect(showBrandLogo(base)).toBe(false); + }); + + it('reads the 1x1 answer as no logo', () => { + expect(isNoLogoImage({ width: 1, height: 1 })).toBe(true); + expect(isNoLogoImage({ width: 128, height: 128 })).toBe(false); + expect(isNoLogoImage(undefined)).toBe(false); + }); +}); + +describe('avatar order with a BIMI logo', () => { + it('keeps the warning shield and the contact photo ahead of the logo, and the logo ahead of the favicon', () => { + expect(avatarContent({ senderWarning: 'Sender check failed', hasPhoto: true, showBrand: true, showFavicon: true })).toBe('warning'); + expect(avatarContent({ hasPhoto: true, showBrand: true, showFavicon: true })).toBe('photo'); + expect(avatarContent({ hasPhoto: false, showBrand: true, showFavicon: true })).toBe('brand'); + expect(avatarContent({ hasPhoto: false, showBrand: false, showFavicon: true })).toBe('favicon'); + }); + + it('draws the logo in SenderAvatar under the favicon switch, never for a warned sender', () => { + const source = fs.readFileSync(path.resolve(__dirname, '..', '..', 'components', 'SenderAvatar.tsx'), 'utf8'); + // senderFavicons there is the setting, off for disabled images and a warned sender. + expect(source).toContain("const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned;"); + expect(source).toContain('showBrandLogo({ senderFavicons, email, brandDomain, webmailUrl })'); + expect(source).toContain('const webmailUrl = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.webmailUrl ?? null);'); + expect(source).toMatch(/if \(content === 'brand' && brandUrl\) \{[\s\S]*?source=\{\{ uri: brandUrl \}\}[\s\S]*?onError=\{handleBrandFailed\}/); + }); +}); diff --git a/src/lib/avatar-bimi.ts b/src/lib/avatar-bimi.ts new file mode 100644 index 00000000..996032f8 --- /dev/null +++ b/src/lib/avatar-bimi.ts @@ -0,0 +1,96 @@ +// BIMI sender logos (webmail components/ui/avatar.tsx, lib/email-headers.ts +// brandIndicatorDomain, app/api/bimi/route.ts). The logo comes from the +// webmail the account signed in through: GET /api/bimi?domain= answers it as a PNG, or a 1x1 transparent PNG when the domain has +// none. The webmail caches its answers (6 h for a logo, 1 h for none); a +// domain without a logo is also remembered here for the session. + +import type { Email } from '../api/types'; +import { + LIST_AUTH_RESULTS_PROPERTY, + headerValues, + isAuthenticationSpoofed, + parseAuthenticationResults, + type AuthenticationResults, +} from './email-headers'; + +function domainOf(address: string): string | undefined { + const domain = address.slice(address.lastIndexOf('@') + 1).trim().toLowerCase().replace(/\.$/, ''); + return /^[^\s<>@]+\.[^\s<>@]+$/.test(domain) ? domain : undefined; +} + +/** The Authentication-Results of a message as the list or the reader holds it. */ +function authenticationResultsOf(email: Pick): AuthenticationResults | undefined { + let values: string[]; + if (email.headers) { + values = headerValues(email.headers, 'Authentication-Results'); + } else { + const raw = (email as { [LIST_AUTH_RESULTS_PROPERTY]?: unknown })[LIST_AUTH_RESULTS_PROPERTY]; + values = Array.isArray(raw) ? raw.filter((value): value is string => typeof value === 'string') : []; + } + return values.length > 0 ? parseAuthenticationResults(values) : undefined; +} + +/** + * The From domain of a message that passed DMARC for that domain, whose + * BIMI logo may stand for the sender (webmail brandIndicatorDomain). The + * logo says who the domain is, not that a message came from it, so a message + * without a DMARC pass for its own From domain gets none. Undefined for no + * message (rows that show the recipient). + */ +export function brandIndicatorDomain(email: Pick | undefined): string | undefined { + if (!email) return undefined; + const fromEmail = email.from?.[0]?.email ?? undefined; + const auth = authenticationResultsOf(email); + 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; +} + +/** The logo URL on the account's webmail; null without a usable webmail address. */ +export function bimiLogoUrl(webmailUrl: string | null | undefined, domain: string): string | null { + const base = webmailUrl?.trim().replace(/\/+$/, ''); + if (!base || !/^https?:\/\/[^/]/i.test(base)) return null; + return `${base}/api/bimi?domain=${encodeURIComponent(domain)}`; +} + +/** + * Whether the avatar of `email` may show the BIMI logo of `brandDomain` + * (webmail Avatar showBrand): only under the sender favicon switch, only for + * the address shown (a thread row may show another participant than the + * message whose DMARC pass named the domain), and only from a webmail. + */ +export function showBrandLogo(options: { + senderFavicons: boolean; + email: string | null | undefined; + brandDomain: string | null | undefined; + webmailUrl: string | null | undefined; +}): boolean { + const { senderFavicons, email, brandDomain, webmailUrl } = options; + if (!senderFavicons || !brandDomain || !email) return false; + if (domainOf(email) !== brandDomain) return false; + const url = bimiLogoUrl(webmailUrl, brandDomain); + return url !== null && !failedBrandLogos.has(url); +} + +// Logos that didn't load or answered "no logo", for the session. +const failedBrandLogos = new Set(); + +export function markBrandLogoFailed(url: string): void { + failedBrandLogos.add(url); +} + +export function hasBrandLogoFailed(url: string): boolean { + return failedBrandLogos.has(url); +} + +/** The webmail's "no logo" answer is a 1x1 transparent PNG. */ +export function isNoLogoImage(size: { width?: number; height?: number } | null | undefined): boolean { + return !!size && typeof size.width === 'number' && size.width <= 1; +} + +export function resetBrandLogosForTests(): void { + failedBrandLogos.clear(); +} diff --git a/src/lib/avatar-utils.ts b/src/lib/avatar-utils.ts index c8670f7c..912d1040 100644 --- a/src/lib/avatar-utils.ts +++ b/src/lib/avatar-utils.ts @@ -238,21 +238,24 @@ export function getContactPhotoForEmail( // --- What the avatar shows -------------------------------------------------- -export type AvatarContent = 'warning' | 'photo' | 'favicon' | 'initials'; +export type AvatarContent = 'warning' | 'photo' | 'brand' | 'favicon' | 'initials'; /** * What a sender avatar shows, in the webmail `Avatar`'s order. A failed or * unverified sender check comes first: a warning shield instead of any photo, - * favicon or initials, so a forged From doesn't get the real sender's logo or - * contact photo (webmail 605a3983b, #1439). + * logo, favicon or initials, so a forged From doesn't get the real sender's + * logo or contact photo (webmail 605a3983b, #1439). Then the contact photo, + * the domain's BIMI logo (see avatar-bimi.ts), the favicon. */ export function avatarContent(options: { senderWarning?: string | null; hasPhoto: boolean; + showBrand?: boolean; showFavicon: boolean; }): AvatarContent { if (options.senderWarning) return 'warning'; if (options.hasPhoto) return 'photo'; + if (options.showBrand) return 'brand'; if (options.showFavicon) return 'favicon'; return 'initials'; }