diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx index 86c47d6d..be2b4b8b 100644 --- a/src/components/EmailBodyView.tsx +++ b/src/components/EmailBodyView.tsx @@ -13,6 +13,7 @@ import { estimateBodyHeight, lastBodyHeight, rememberBodyHeight } from '../lib/b import { parseMailtoUrl } from '../lib/unsubscribe'; import { fetchInlineImageDataUri } from '../lib/email-export'; import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders'; +import { senderVerificationOf } from '../lib/email-headers'; import { useHasContacts } from '../lib/capabilities'; import { useSettingsStore } from '../stores/settings-store'; import { useContactsStore } from '../stores/contacts-store'; @@ -563,7 +564,15 @@ export default function EmailBodyView({ }, [email, bodyOverride]); const rawHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]); const text = picked.text; - const trusted = isSenderContentTrusted(senderEmail, { + // A From address the server's checks don't back may be forged: neither + // honour an earlier "Always trust" for it nor offer one, or a forgery of a + // trusted sender would load remote content (and trusting it would let the + // next forgery do the same). + const unverifiedSender = React.useMemo( + () => senderVerificationOf(email) !== null, + [email.headers, email.from], + ); + const trusted = !unverifiedSender && isSenderContentTrusted(senderEmail, { isLocallyTrusted: isSenderTrusted, syncEnabled: syncTrustedSenders, trustedBookEmails: trustedSenderEmails, @@ -768,7 +777,7 @@ export default function EmailBodyView({ {t('email_viewer.load_external_content', 'Load images')} )} - {senderEmail ? ( + {senderEmail && !unverifiedSender ? ( {t('email_viewer.trust_sender', 'Always trust this sender')} diff --git a/src/components/email/MessageHeader.tsx b/src/components/email/MessageHeader.tsx index bd599b3c..6bd6ed1a 100644 --- a/src/components/email/MessageHeader.tsx +++ b/src/components/email/MessageHeader.tsx @@ -13,6 +13,7 @@ import { useKeywordsStore } from '../../stores/keywords-store'; import { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date'; import { deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity, + getSenderVerification, describeSenderVerification, type AuthenticationResults, type EmailHeaderInfo, } from '../../lib/email-headers'; import { formatSize } from '../../lib/attachment-display'; @@ -106,6 +107,15 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA const starred = !!email.keywords?.$flagged; const displayDate = emailDisplayDate(email); const spoofed = isAuthenticationSpoofed(info.auth); + // The server's checks don't back the visible From domain: say so next to + // the name and above the body, not only in the collapsed details. + const verification = React.useMemo( + () => getSenderVerification(info.auth, from?.email), + [info.auth, from?.email], + ); + const verificationText = verification ? describeSenderVerification(verification, t) : null; + const verificationColor = verification?.status === 'failed' ? c.error : c.warning; + const verificationBg = verification?.status === 'failed' ? c.errorBg : c.warningBg; // "via ": the message was sent by one of the user's identities or // received at one of them (incl. +tag); hidden when the From can't be @@ -159,6 +169,15 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA {from.email} ) : null} + {verificationText && ( + + + {verificationText.label} + + )} {!compact && ( <> @@ -234,6 +253,19 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA )} + {!compact && verificationText && ( + + + + {verificationText.label} + {verificationText.message} + + {t('email_viewer.sender_check.caution', 'Be careful with links, attachments and requests for passwords or payment details.')} + + + + )} + {!compact && ( setShowDetails((v) => !v)} style={styles.detailsToggle} hitSlop={6}> {showDetails ? : } @@ -345,6 +377,19 @@ function makeStyles(c: ThemePalette) { backgroundColor: c.surfaceHover, }, badgeDot: { width: 8, height: 8, borderRadius: 4 }, + verificationBadge: { alignSelf: 'flex-start', marginTop: 4 }, + verificationNotice: { + flexDirection: 'row', + gap: spacing.sm, + padding: spacing.md, + borderWidth: 1, + borderRadius: radius.md, + backgroundColor: c.surface, + }, + verificationBody: { flex: 1, minWidth: 0, gap: 2 }, + verificationLabel: { ...typography.small, fontWeight: '600' }, + verificationMessage: { ...typography.caption, color: c.text }, + verificationCaution: { ...typography.caption, color: c.textSecondary }, badgeText: { ...typography.small, color: c.textSecondary }, detailsToggle: { flexDirection: 'row', alignItems: 'center', gap: 4, alignSelf: 'flex-start' }, detailsToggleText: { ...typography.caption, color: c.primary, fontWeight: '600' }, diff --git a/src/lib/__tests__/email-headers.test.ts b/src/lib/__tests__/email-headers.test.ts index 97b4d970..884325b0 100644 --- a/src/lib/__tests__/email-headers.test.ts +++ b/src/lib/__tests__/email-headers.test.ts @@ -3,6 +3,7 @@ import { parseAuthenticationResults, parseSpamScore, parseSpamLLM, extractListHeaders, isAuthenticationSpoofed, headersToRecord, deriveHeaderInfo, deliveryDeltaMs, formatDelta, findReceivingIdentity, getSenderVerification, hasAlignedAuthenticationPass, + describeSenderVerification, senderVerificationOf, } from '../email-headers'; describe('parseAuthenticationResults', () => { @@ -174,3 +175,32 @@ describe('findReceivingIdentity', () => { expect(findReceivingIdentity([], { to: [] })).toBeUndefined(); }); }); + +describe('describeSenderVerification / senderVerificationOf', () => { + const t = (_key: string, fallback?: string, params?: Record) => + (fallback ?? '').replace(/\{(\w+)\}/g, (_m, k: string) => String(params?.[k] ?? '')); + + it('names the claimed domain and the sending host', () => { + const unverified = describeSenderVerification({ status: 'unverified', domain: 'bank.example', sentFrom: 'evil.example' }, t); + expect(unverified.label).toBe('Unverified sender'); + expect(unverified.message).toContain('bank.example'); + expect(unverified.message).toContain('evil.example'); + const failed = describeSenderVerification({ status: 'failed', domain: 'bank.example' }, t); + expect(failed.label).toBe('Sender check failed'); + expect(failed.message).toContain('may be forged'); + }); + + it('reads the check from the raw headers of a message', () => { + const forged = { + from: [{ email: 'ceo@bank.example' }], + headers: [{ name: 'Authentication-Results', value: 'mx; spf=pass smtp.mailfrom=evil.example; dkim=none; dmarc=none' }], + }; + expect(senderVerificationOf(forged)?.status).toBe('unverified'); + const genuine = { + from: [{ email: 'ceo@bank.example' }], + headers: [{ name: 'Authentication-Results', value: 'mx; spf=pass smtp.mailfrom=bank.example; dkim=pass header.d=bank.example; dmarc=pass' }], + }; + expect(senderVerificationOf(genuine)).toBeNull(); + expect(senderVerificationOf({ from: [{ email: 'a@b.example' }], headers: [] })).toBeNull(); + }); +}); diff --git a/src/lib/email-headers.ts b/src/lib/email-headers.ts index 15be0866..1c661829 100644 --- a/src/lib/email-headers.ts +++ b/src/lib/email-headers.ts @@ -210,6 +210,40 @@ export function getSenderVerification( return { status: 'unverified', domain, sentFrom }; } +type Translate = (key: string, fallback?: string, params?: Record) => string; + +/** The badge label and the sentence the reader shows for a sender check (webmail email-viewer.tsx). */ +export function describeSenderVerification( + verification: SenderVerification, + t: Translate, +): { label: string; message: string } { + const { status, domain, sentFrom } = verification; + if (status === 'failed') { + return { + label: t('email_viewer.sender_check.failed_label', 'Sender check failed'), + message: sentFrom + ? t('email_viewer.sender_check.failed_sent_from', "This message claims to be from {domain}, but it was sent from {host} and failed {domain}'s sender checks. It may be forged.", { domain, host: sentFrom }) + : t('email_viewer.sender_check.failed', "This message claims to be from {domain}, but it failed that domain's sender checks. It may be forged.", { domain }), + }; + } + return { + label: t('email_viewer.sender_check.unverified_label', 'Unverified sender'), + message: sentFrom + ? t('email_viewer.sender_check.unverified_sent_from', 'This message claims to be from {domain}, but it was sent from {host} and has no valid signature from {domain}.', { domain, host: sentFrom }) + : t('email_viewer.sender_check.unverified', "This message claims to be from {domain}, but nothing confirms that. It has no valid signature from {domain} and didn't come from a server {domain} authorizes.", { domain }), + }; +} + +/** + * The sender check for a message straight from its raw headers, for views + * that don't hold the parsed header info (the body view's trust gate). + */ +export function senderVerificationOf(email: Pick): SenderVerification | null { + const values = headerValues(email.headers, 'Authentication-Results'); + if (values.length === 0) return null; + return getSenderVerification(parseAuthenticationResults(values), email.from?.[0]?.email); +} + interface ResInfo { method: string; result: string;