From 0b0fc021fb476d06cb3316bd1f7e5ecd41fc853e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:37:46 +0100 Subject: [PATCH] fix: show a warning shield instead of the sender photo when the sender check fails (bulwarkmail/webmail#1439) --- src/api/__tests__/live-stalwart-mail.test.ts | 30 +++++++++ src/api/email.ts | 16 ++++- src/api/unified-inbox.ts | 3 + src/components/SenderAvatar.tsx | 52 ++++++++++++---- .../__tests__/sender-warning-avatar.test.ts | 61 +++++++++++++++++++ src/components/email/MessageHeader.tsx | 7 ++- src/components/email/ThreadMessageCard.tsx | 4 +- src/components/email/use-sender-warning.ts | 18 ++++++ src/lib/avatar-utils.ts | 21 +++++++ src/lib/email-headers.ts | 22 +++++++ src/screens/EmailListScreen.tsx | 4 ++ src/screens/UnifiedInboxScreen.tsx | 4 ++ 12 files changed, 226 insertions(+), 16 deletions(-) create mode 100644 src/components/__tests__/sender-warning-avatar.test.ts create mode 100644 src/components/email/use-sender-warning.ts diff --git a/src/api/__tests__/live-stalwart-mail.test.ts b/src/api/__tests__/live-stalwart-mail.test.ts index 9cfe4a25..ffb1a563 100644 --- a/src/api/__tests__/live-stalwart-mail.test.ts +++ b/src/api/__tests__/live-stalwart-mail.test.ts @@ -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 ', + `To: ${USER}`, + `Subject: ${subject}`, + `Date: ${new Date().toUTCString()}`, + `Message-ID: `, + '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); + }); }); diff --git a/src/api/email.ts b/src/api/email.ts index 45ca4657..7ad3b9d2 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -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', diff --git a/src/api/unified-inbox.ts b/src/api/unified-inbox.ts index 7bc53957..254a57d0 100644 --- a/src/api/unified-inbox.ts +++ b/src/api/unified-inbox.ts @@ -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; diff --git a/src/components/SenderAvatar.tsx b/src/components/SenderAvatar.tsx index 321915c1..15e5c758 100644 --- a/src/components/SenderAvatar.tsx +++ b/src/components/SenderAvatar.tsx @@ -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 ( + + + + ); + } + + if (content === 'photo' && photoSource) { return ( ({ + 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 \?/); + }); +}); diff --git a/src/components/email/MessageHeader.tsx b/src/components/email/MessageHeader.tsx index 4fdcbcca..ad236b1b 100644 --- a/src/components/email/MessageHeader.tsx +++ b/src/components/email/MessageHeader.tsx @@ -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')} > - + onAddressPress(from) : undefined}> diff --git a/src/components/email/ThreadMessageCard.tsx b/src/components/email/ThreadMessageCard.tsx index a70b0e28..6fd10d7e 100644 --- a/src/components/email/ThreadMessageCard.tsx +++ b/src/components/email/ThreadMessageCard.tsx @@ -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 ( [styles.collapsed, pressed && styles.pressed]}> - + diff --git a/src/components/email/use-sender-warning.ts b/src/components/email/use-sender-warning.ts new file mode 100644 index 00000000..7ef27120 --- /dev/null +++ b/src/components/email/use-sender-warning.ts @@ -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 | 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; +} diff --git a/src/lib/avatar-utils.ts b/src/lib/avatar-utils.ts index 0e87db7e..584d82fd 100644 --- a/src/lib/avatar-utils.ts +++ b/src/lib/avatar-utils.ts @@ -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'; +} diff --git a/src/lib/email-headers.ts b/src/lib/email-headers.ts index 539265ea..767e5007 100644 --- a/src/lib/email-headers.ts +++ b/src/lib/email-headers.ts @@ -244,6 +244,28 @@ export function senderVerificationOf(email: Pick): 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): 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; diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index a9dc294f..b9308448 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -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 ( )} diff --git a/src/screens/UnifiedInboxScreen.tsx b/src/screens/UnifiedInboxScreen.tsx index d2c7097d..94470533 100644 --- a/src/screens/UnifiedInboxScreen.tsx +++ b/src/screens/UnifiedInboxScreen.tsx @@ -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 ( {acc && accountIds.length > 1 && (