From cc21317b5a894f98ee0435c5b0ba84171da71539 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Thu, 29 Oct 2026 03:09:04 +0100
Subject: [PATCH] feat: warn about unverified senders in the reader and stop
offering Always trust for them
---
src/components/EmailBodyView.tsx | 13 +++++--
src/components/email/MessageHeader.tsx | 45 +++++++++++++++++++++++++
src/lib/__tests__/email-headers.test.ts | 30 +++++++++++++++++
src/lib/email-headers.ts | 34 +++++++++++++++++++
4 files changed, 120 insertions(+), 2 deletions(-)
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;