mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: warn about unverified senders in the reader and stop offering Always trust for them
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user