mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
feat: show the sender's BIMI logo from the webmail when the message passes DMARC
This commit is contained in:
@@ -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}>
|
||||
|
||||
@@ -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\}/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user