feat: show the sender's BIMI logo from the webmail when the message passes DMARC

This commit is contained in:
Linus Rath
2026-10-31 15:44:35 +01:00
parent f27e2cebb4
commit 83ddd13726
4 changed files with 259 additions and 6 deletions
+42 -3
View File
@@ -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<string | null>(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 (
<View style={containerStyle}>
<Image
source={{ uri: brandUrl }}
style={{ width: size, height: size, borderRadius: size / 2 }}
resizeMode="cover"
// The webmail answers a domain without a logo with a 1x1 PNG.
onLoad={(e) => { if (isNoLogoImage(e.nativeEvent?.source)) handleBrandFailed(); }}
onError={handleBrandFailed}
testID="sender-brand-avatar"
/>
</View>
);
}
if (content === 'favicon' && faviconDomain) {
return (
<View style={containerStyle}>
+115
View File
@@ -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\}/);
});
});
+96
View File
@@ -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 <webmail>/api/bimi?domain=<From
// 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<Email, 'headers' | 'from'>): 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<Email, 'headers' | 'from'> | 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<string>();
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();
}
+6 -3
View File
@@ -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';
}