feat: warn about unverified senders in the reader and stop offering Always trust for them

This commit is contained in:
Linus Rath
2026-10-29 03:09:04 +01:00
parent 855d304b4f
commit cc21317b5a
4 changed files with 120 additions and 2 deletions
+11 -2
View File
@@ -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({
<Text style={styles.bannerButtonText}>{t('email_viewer.load_external_content', 'Load images')}</Text>
</Pressable>
)}
{senderEmail ? (
{senderEmail && !unverifiedSender ? (
<Pressable style={styles.bannerButton} onPress={onTrustSender} hitSlop={8}>
<ShieldCheck size={14} color={c.textSecondary} />
<Text style={styles.bannerButtonText}>{t('email_viewer.trust_sender', 'Always trust this sender')}</Text>
+45
View File
@@ -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 <identity>": 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
<Text style={styles.email} numberOfLines={1}>{from.email}</Text>
) : null}
</Pressable>
{verificationText && (
<View
style={[styles.badge, styles.verificationBadge, { backgroundColor: verificationBg }]}
accessibilityLabel={verificationText.message}
>
<ShieldAlert size={11} color={verificationColor} />
<Text style={[styles.badgeText, { color: verificationColor }]}>{verificationText.label}</Text>
</View>
)}
{!compact && (
<>
<AddressList label={t('email_viewer.to', 'To').toLowerCase()} addresses={email.to} onPress={onAddressPress} styles={styles} />
@@ -234,6 +253,19 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
</View>
)}
{!compact && verificationText && (
<View style={[styles.verificationNotice, { borderColor: verificationColor }]} accessibilityRole="alert">
<ShieldAlert size={18} color={verificationColor} />
<View style={styles.verificationBody}>
<Text style={[styles.verificationLabel, { color: verificationColor }]}>{verificationText.label}</Text>
<Text style={styles.verificationMessage}>{verificationText.message}</Text>
<Text style={styles.verificationCaution}>
{t('email_viewer.sender_check.caution', 'Be careful with links, attachments and requests for passwords or payment details.')}
</Text>
</View>
</View>
)}
{!compact && (
<Pressable onPress={() => setShowDetails((v) => !v)} style={styles.detailsToggle} hitSlop={6}>
{showDetails ? <ChevronUp size={14} color={c.primary} /> : <ChevronDown size={14} color={c.primary} />}
@@ -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' },
+30
View File
@@ -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<string, string | number>) =>
(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();
});
});
+34
View File
@@ -210,6 +210,40 @@ export function getSenderVerification(
return { status: 'unverified', domain, sentFrom };
}
type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => 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<Email, 'headers' | 'from'>): 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;