fix: show a warning shield instead of the sender photo when the sender check fails (bulwarkmail/webmail#1439)

This commit is contained in:
Linus Rath
2026-10-31 03:37:46 +01:00
parent 49d4372918
commit 0b0fc021fb
12 changed files with 226 additions and 16 deletions
@@ -37,7 +37,9 @@ import {
searchEmails,
sendEmail,
MAILBOX_PROPERTIES,
queryEmailPage,
} from '../email';
import { senderWarningOf } from '../../lib/email-headers';
import { uploadBytes } from '../blob';
import { getIdentities, updateIdentity } from '../identity';
import { isJMAPSetError } from '../jmap-result';
@@ -418,4 +420,32 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => {
}
}, 60_000);
});
describe('mail list (reader pass)', () => {
it('lists the Authentication-Results headers so a forged From gets the warning shield (bulwarkmail/webmail#1439)', async () => {
const subject = `forged ${mailTag}`;
const raw = [
'Authentication-Results: mx.example.org; spf=fail smtp.mailfrom=evil.example; dkim=none; dmarc=fail header.from=bank.example',
'From: Bank <ceo@bank.example>',
`To: ${USER}`,
`Subject: ${subject}`,
`Date: ${new Date().toUTCString()}`,
`Message-ID: <forged-${RUN}@live.test>`,
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=utf-8',
'',
'hello',
'',
].join('\r\n');
const { blobId } = await uploadBytes(new TextEncoder().encode(raw), 'message/rfc822');
const id = await importEmailBlob(blobId, scratch, {});
const page = await queryEmailPage(scratch, { limit: 10 });
const row = page.list.find((e) => e.id === id);
expect(row).toBeDefined();
expect(senderWarningOf(row!)?.status).toBe('failed');
// The full fetch reads the same verdict from `headers`.
const [full] = await getFullEmails([id]);
expect(senderWarningOf(full)?.status).toBe('failed');
}, 60_000);
});
});
+13 -3
View File
@@ -19,17 +19,27 @@ import { hasTruncatedDisplayedBody } from '../lib/email-body';
import { withInlineBodyParts } from '../lib/email-inline-parts';
import { stalwartStateWentBack } from './stalwart-state';
import { isTransientNetworkError } from '../lib/network-error';
import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers';
export const EMAIL_LIST_PROPERTIES = [
const EMAIL_SUMMARY_PROPERTIES = [
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment',
];
export const EMAIL_LIST_PROPERTIES = [
...EMAIL_SUMMARY_PROPERTIES,
// The sender check, so a list row shows a warning shield instead of the
// favicon or photo of a forged From (webmail #1439).
LIST_AUTH_RESULTS_PROPERTY,
];
// The viewer derives reply threading (In-Reply-To/References), Reply-To
// handling, SPF/DKIM/DMARC chips, List-Unsubscribe and read-receipt requests
// from these; without `messageId`/`headers` none of that can work.
// from these; without `messageId`/`headers` none of that can work. The full
// `headers` carry Authentication-Results, so the list's header property is
// left out.
export const EMAIL_FULL_PROPERTIES = [
...EMAIL_LIST_PROPERTIES,
...EMAIL_SUMMARY_PROPERTIES,
'bodyStructure', 'textBody', 'htmlBody', 'bodyValues',
'attachments', 'blobId', 'bcc', 'replyTo', 'sentAt',
'messageId', 'inReplyTo', 'references', 'headers',
+3
View File
@@ -8,6 +8,7 @@ import { keywordPointer, mailboxPointer } from './patch-pointer';
import { secureFetch } from '../lib/client-cert';
import { freshStoredCredentials } from '../lib/stored-token-refresh';
import { textConditions } from '../lib/search-utils';
import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers';
// Aggregated views across accounts ("All inboxes", "All Sent", All mail /
// Unread / Starred). Because the JMAP client is a single-account singleton
@@ -21,6 +22,8 @@ import { textConditions } from '../lib/search-utils';
const EMAIL_LIST_PROPERTIES = [
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment',
// The sender check, for the warning shield on a forged From (#1439).
LIST_AUTH_RESULTS_PROPERTY,
];
const SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
+41 -11
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { View, Text, Image, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
import { useShallow } from 'zustand/react/shallow';
import {
avatarContent,
generateEmailAvatarColor,
getContactPhotoForEmail,
getEmailInitials,
@@ -14,6 +15,8 @@ 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 { useColors } from '../theme/colors';
import { ShieldAlert } from './icons';
interface SenderAvatarProps {
name?: string | null;
@@ -32,23 +35,32 @@ interface SenderAvatarProps {
* the contact has no photo, so no other card's photo is borrowed.
*/
photo?: ContactPhotoRef | null;
/**
* The message's sender check failed or is unverified (see
* useSenderWarning): a warning shield with this label instead of any photo,
* favicon or initials, so a forged From doesn't get the real sender's logo
* or photo (webmail #1439).
*/
senderWarning?: string | null;
}
// Mirrors the webmail `Avatar` priority for the subset available on native:
// address-book photo → 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 }: SenderAvatarProps) {
const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages;
// sender warning → address-book photo → 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) {
const c = useColors();
const warned = !!senderWarning;
const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned;
// Compared shallowly, so only avatars whose photo changed re-render when the
// contacts change; the index behind it is built once per contacts array.
const indexedPhoto = useContactsStore(
useShallow((s) => (photo === undefined ? getContactPhotoForEmail(s.contacts, email) : undefined)),
useShallow((s) => (photo === undefined && !warned ? getContactPhotoForEmail(s.contacts, email) : undefined)),
);
const contactPhoto = photo === undefined ? indexedPhoto : photo;
// A blob photo (Stalwart 1.0) has no source while it waits for its
// download or after it failed; the favicon or initials show meanwhile.
const { source: photoSource, onLoad: onPhotoLoad, onError: onPhotoError } =
useContactPhotoSource(disableImages ? null : contactPhoto);
useContactPhotoSource(disableImages || warned ? null : contactPhoto);
const [imgError, setImgError] = React.useState(false);
React.useEffect(() => {
@@ -61,8 +73,11 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
const faviconDomain = getFaviconDomain(email ?? undefined);
const domainFailed = faviconDomain ? hasFaviconFailed(faviconDomain) : false;
const showPhoto = !disableImages && !!photoSource;
const showFavicon = !showPhoto && senderFavicons && !!faviconDomain && !imgError && !domainFailed;
const content = avatarContent({
senderWarning,
hasPhoto: !disableImages && !!photoSource,
showFavicon: senderFavicons && !!faviconDomain && !imgError && !domainFailed,
});
const handleError = React.useCallback(() => {
setImgError(true);
@@ -75,14 +90,29 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: showPhoto ? 'transparent' : showFavicon ? '#ffffff' : bgColor,
backgroundColor: content === 'warning'
? c.errorBg
: content === 'photo' ? 'transparent' : content === 'favicon' ? '#ffffff' : bgColor,
},
style,
];
const fontSize = Math.round(size * 0.4);
if (showPhoto && photoSource) {
if (content === 'warning') {
return (
<View
style={containerStyle}
accessible
accessibilityLabel={`${primaryName || email || ''} (${senderWarning})`}
testID="sender-warning-avatar"
>
<ShieldAlert size={Math.round(size * 0.5)} color={c.error} />
</View>
);
}
if (content === 'photo' && photoSource) {
return (
<View style={containerStyle}>
<Image
@@ -96,7 +126,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
);
}
if (showFavicon && faviconDomain) {
if (content === 'favicon' && faviconDomain) {
return (
<View style={containerStyle}>
<Image
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
vi.mock('../../api/jmap-client', () => ({
jmapClient: { accountId: 'acc-1', request: vi.fn(), getMaxObjectsInGet: vi.fn(() => 50) },
}));
import { avatarContent } from '../../lib/avatar-utils';
import { LIST_AUTH_RESULTS_PROPERTY, senderWarningOf } from '../../lib/email-headers';
import { EMAIL_FULL_PROPERTIES, EMAIL_LIST_PROPERTIES } from '../../api/email';
import type { Email } from '../../api/types';
// A forged From kept the real sender's favicon or contact photo: the reader
// worked out that the sender check failed, but the avatar beside it still
// showed the claimed sender's logo, and list rows had no auth data at all
// (webmail 605a3983b, #1439). Now the avatar shows a warning shield.
const FORGED = 'mx.example.org; spf=fail smtp.mailfrom=evil.example; dkim=none; dmarc=fail header.from=bank.example';
const GOOD = 'mx.example.org; spf=pass smtp.mailfrom=bank.example; dkim=pass header.d=bank.example; dmarc=pass header.from=bank.example';
function row(auth: string[] | undefined): Email {
return {
id: 'm1', threadId: 't1', mailboxIds: {}, keywords: {}, size: 1, receivedAt: '2026-10-31T10:00:00Z',
hasAttachment: false, from: [{ name: 'Bank', email: 'ceo@bank.example' }],
...(auth ? { [LIST_AUTH_RESULTS_PROPERTY]: auth } : {}),
} as Email;
}
describe('sender warning in avatars (bulwarkmail/webmail#1439)', () => {
it('asks for the Authentication-Results headers in the list fetch, not in the full one', () => {
expect(EMAIL_LIST_PROPERTIES).toContain('header:Authentication-Results:all');
// The full fetch has `headers`, which carry the same header.
expect(EMAIL_FULL_PROPERTIES).not.toContain('header:Authentication-Results:all');
expect(EMAIL_FULL_PROPERTIES).toContain('headers');
});
it('judges a list row from the listed header and a full message from its headers', () => {
expect(senderWarningOf(row([FORGED]))?.status).toBe('failed');
expect(senderWarningOf(row([GOOD]))).toBeNull();
expect(senderWarningOf(row(undefined))).toBeNull();
expect(senderWarningOf(row([]))).toBeNull();
const full = { ...row(undefined), headers: [{ name: 'Authentication-Results', value: ` ${FORGED}` }] };
expect(senderWarningOf(full)?.status).toBe('failed');
});
it('shows the shield before a contact photo or favicon', () => {
expect(avatarContent({ senderWarning: 'Sender check failed', hasPhoto: true, showFavicon: true })).toBe('warning');
expect(avatarContent({ senderWarning: undefined, hasPhoto: true, showFavicon: true })).toBe('photo');
expect(avatarContent({ senderWarning: null, hasPhoto: false, showFavicon: true })).toBe('favicon');
expect(avatarContent({ hasPhoto: false, showFavicon: false })).toBe('initials');
});
it('passes the warning to the avatar in the reader, collapsed conversation cards and list rows', () => {
const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', p), 'utf8');
expect(src('components/email/MessageHeader.tsx')).toMatch(/senderWarning=\{verificationText\?\.label\}/);
expect(src('components/email/ThreadMessageCard.tsx')).toMatch(/senderWarning=\{senderWarning\}/);
expect(src('screens/EmailListScreen.tsx')).toMatch(/senderWarning=\{senderWarning\}/);
expect(src('screens/UnifiedInboxScreen.tsx')).toMatch(/senderWarning=\{verification \?/);
});
});
+6 -1
View File
@@ -172,7 +172,12 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
accessibilityRole="button"
accessibilityLabel={from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')}
>
<SenderAvatar name={from?.name} email={from?.email} size={compact ? componentSizes.avatarSm : componentSizes.avatarMd} />
<SenderAvatar
name={from?.name}
email={from?.email}
size={compact ? componentSizes.avatarSm : componentSizes.avatarMd}
senderWarning={verificationText?.label}
/>
</Pressable>
<View style={styles.info}>
<Pressable onPress={from ? () => onAddressPress(from) : undefined}>
+3 -1
View File
@@ -10,6 +10,7 @@ import SenderAvatar from '../SenderAvatar';
import { MessageContent, type MessageContentProps } from './MessageContent';
import { emailDisplayDate, formatHeaderDate, formatHeaderTime } from '../../lib/email-date';
import { singleLine } from '../../lib/single-line';
import { useSenderWarning } from './use-sender-warning';
interface Props extends MessageContentProps {
expanded: boolean;
@@ -43,11 +44,12 @@ export function ThreadMessageCard({
const unread = !email.keywords?.$seen;
const starred = !!email.keywords?.$flagged;
const date = emailDisplayDate(email);
const senderWarning = useSenderWarning(expanded ? undefined : email);
if (!expanded) {
return (
<Pressable onPress={onToggleExpanded} style={({ pressed }) => [styles.collapsed, pressed && styles.pressed]}>
<SenderAvatar name={from?.name} email={from?.email} size={componentSizes.avatarSm} />
<SenderAvatar name={from?.name} email={from?.email} size={componentSizes.avatarSm} senderWarning={senderWarning} />
<View style={styles.collapsedInfo}>
<View style={styles.collapsedTop}>
<Text style={[styles.collapsedName, unread && styles.unread]} numberOfLines={1}>
@@ -0,0 +1,18 @@
import React from 'react';
import type { Email } from '../../api/types';
import { describeSenderVerification, senderWarningOf } from '../../lib/email-headers';
import { useLocaleStore } from '../../stores/locale-store';
/**
* The sender-check label of a message whose From the receiving server's
* checks don't back, the same verdict as the notice in the reader. Avatars
* show it instead of the claimed sender's favicon or photo (webmail
* use-sender-warning.ts, #1439). Undefined when the check passed, there are
* no results to judge by, or no message is given (Sent and Drafts rows show
* the recipient, whose avatar the check says nothing about).
*/
export function useSenderWarning(email: Pick<Email, 'headers' | 'from'> | undefined): string | undefined {
const t = useLocaleStore((s) => s.t);
const verification = React.useMemo(() => (email ? senderWarningOf(email) : null), [email]);
return verification ? describeSenderVerification(verification, t).label : undefined;
}
+21
View File
@@ -196,3 +196,24 @@ export function getContactPhotoForEmail(
const key = email?.trim().toLowerCase();
return key ? getContactPhotoIndex(contacts).get(key) : undefined;
}
// --- What the avatar shows --------------------------------------------------
export type AvatarContent = 'warning' | 'photo' | '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).
*/
export function avatarContent(options: {
senderWarning?: string | null;
hasPhoto: boolean;
showFavicon: boolean;
}): AvatarContent {
if (options.senderWarning) return 'warning';
if (options.hasPhoto) return 'photo';
if (options.showFavicon) return 'favicon';
return 'initials';
}
+22
View File
@@ -244,6 +244,28 @@ export function senderVerificationOf(email: Pick<Email, 'headers' | 'from'>): Se
return getSenderVerification(parseAuthenticationResults(values), email.from?.[0]?.email);
}
/**
* The list fetch asks for the Authentication-Results headers by this
* property, so list rows and collapsed conversation cards can judge the
* sender too (webmail 605a3983b, #1439). Stalwart answers header properties
* from the stored headers, not the message blob.
*/
export const LIST_AUTH_RESULTS_PROPERTY = 'header:Authentication-Results:all';
/**
* The sender check for a message as the list or the reader holds it: from
* the full headers when they are there, else from the list property. Null
* when the checks back the From or there is nothing to judge by.
*/
export function senderWarningOf(email: Pick<Email, 'headers' | 'from'>): SenderVerification | null {
if (email.headers) return senderVerificationOf(email);
const raw = (email as { [LIST_AUTH_RESULTS_PROPERTY]?: unknown })[LIST_AUTH_RESULTS_PROPERTY];
if (!Array.isArray(raw)) return null;
const values = raw.filter((value): value is string => typeof value === 'string');
if (values.length === 0) return null;
return getSenderVerification(parseAuthenticationResults(values), email.from?.[0]?.email);
}
interface ResInfo {
method: string;
result: string;
+4
View File
@@ -24,6 +24,7 @@ import {
ListAttachmentChips, ListAttachmentOpener, useListRowAttachments,
} from '../components/email/ListAttachmentChips';
import { VerificationCodeChip, useListVerificationCode } from '../components/email/VerificationCodeChip';
import { useSenderWarning } from '../components/email/use-sender-warning';
import type { LoadListAttachments } from '../lib/list-attachments';
import { useNetworkStore } from '../stores/network-store';
import {
@@ -176,6 +177,8 @@ const EmailRow = React.memo(function EmailRow({
const handlePress = React.useCallback(() => onPress(key), [onPress, key]);
const handleLongPress = React.useCallback(() => onLongPress(key), [onLongPress, key]);
const verificationCode = useListVerificationCode(item);
// Sent and Drafts rows show the recipient: the check says nothing about them.
const senderWarning = useSenderWarning(showRecipient ? undefined : item);
return (
<Pressable
@@ -215,6 +218,7 @@ const EmailRow = React.memo(function EmailRow({
email={senderEmail}
size={componentSizes.avatarMd}
disableImages={disableAvatarImages}
senderWarning={senderWarning}
/>
)}
+4
View File
@@ -26,6 +26,7 @@ import { singleLine } from '../lib/single-line';
import { prefetchMessage, rememberRows } from '../lib/email-detail-cache';
import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm';
import { onStateChangeType } from '../lib/state-change-bus';
import { describeSenderVerification, senderWarningOf } from '../lib/email-headers';
import { createUnifiedReloadTracker } from '../lib/unified-reload';
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
@@ -313,6 +314,8 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
const unread = !item.keywords?.$seen;
const starred = !!item.keywords?.$flagged;
const isSelected = selected.has(rowKey(item));
// Sent and Drafts rows show the recipient: the check says nothing about them.
const verification = showRecipient ? null : senderWarningOf(item);
return (
<SwipeableRow
leftAction={selectionMode ? 'none' : swipeLeftAction}
@@ -344,6 +347,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
email={showRecipient ? item.to?.[0]?.email : item.from?.[0]?.email}
size={componentSizes.avatarMd}
disableImages={role === 'junk' && !showAvatarsInJunk}
senderWarning={verification ? describeSenderVerification(verification, t).label : undefined}
/>
{acc && accountIds.length > 1 && (
<View style={[styles.accountDot, { backgroundColor: acc.avatarColor }]} />