mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +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 { parseMailtoUrl } from '../lib/unsubscribe';
|
||||||
import { fetchInlineImageDataUri } from '../lib/email-export';
|
import { fetchInlineImageDataUri } from '../lib/email-export';
|
||||||
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders';
|
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders';
|
||||||
|
import { senderVerificationOf } from '../lib/email-headers';
|
||||||
import { useHasContacts } from '../lib/capabilities';
|
import { useHasContacts } from '../lib/capabilities';
|
||||||
import { useSettingsStore } from '../stores/settings-store';
|
import { useSettingsStore } from '../stores/settings-store';
|
||||||
import { useContactsStore } from '../stores/contacts-store';
|
import { useContactsStore } from '../stores/contacts-store';
|
||||||
@@ -563,7 +564,15 @@ export default function EmailBodyView({
|
|||||||
}, [email, bodyOverride]);
|
}, [email, bodyOverride]);
|
||||||
const rawHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]);
|
const rawHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]);
|
||||||
const text = picked.text;
|
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,
|
isLocallyTrusted: isSenderTrusted,
|
||||||
syncEnabled: syncTrustedSenders,
|
syncEnabled: syncTrustedSenders,
|
||||||
trustedBookEmails: trustedSenderEmails,
|
trustedBookEmails: trustedSenderEmails,
|
||||||
@@ -768,7 +777,7 @@ export default function EmailBodyView({
|
|||||||
<Text style={styles.bannerButtonText}>{t('email_viewer.load_external_content', 'Load images')}</Text>
|
<Text style={styles.bannerButtonText}>{t('email_viewer.load_external_content', 'Load images')}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
{senderEmail ? (
|
{senderEmail && !unverifiedSender ? (
|
||||||
<Pressable style={styles.bannerButton} onPress={onTrustSender} hitSlop={8}>
|
<Pressable style={styles.bannerButton} onPress={onTrustSender} hitSlop={8}>
|
||||||
<ShieldCheck size={14} color={c.textSecondary} />
|
<ShieldCheck size={14} color={c.textSecondary} />
|
||||||
<Text style={styles.bannerButtonText}>{t('email_viewer.trust_sender', 'Always trust this sender')}</Text>
|
<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 { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date';
|
||||||
import {
|
import {
|
||||||
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity,
|
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity,
|
||||||
|
getSenderVerification, describeSenderVerification,
|
||||||
type AuthenticationResults, type EmailHeaderInfo,
|
type AuthenticationResults, type EmailHeaderInfo,
|
||||||
} from '../../lib/email-headers';
|
} from '../../lib/email-headers';
|
||||||
import { formatSize } from '../../lib/attachment-display';
|
import { formatSize } from '../../lib/attachment-display';
|
||||||
@@ -106,6 +107,15 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
|||||||
const starred = !!email.keywords?.$flagged;
|
const starred = !!email.keywords?.$flagged;
|
||||||
const displayDate = emailDisplayDate(email);
|
const displayDate = emailDisplayDate(email);
|
||||||
const spoofed = isAuthenticationSpoofed(info.auth);
|
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
|
// "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
|
// 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>
|
<Text style={styles.email} numberOfLines={1}>{from.email}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</Pressable>
|
</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 && (
|
{!compact && (
|
||||||
<>
|
<>
|
||||||
<AddressList label={t('email_viewer.to', 'To').toLowerCase()} addresses={email.to} onPress={onAddressPress} styles={styles} />
|
<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>
|
</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 && (
|
{!compact && (
|
||||||
<Pressable onPress={() => setShowDetails((v) => !v)} style={styles.detailsToggle} hitSlop={6}>
|
<Pressable onPress={() => setShowDetails((v) => !v)} style={styles.detailsToggle} hitSlop={6}>
|
||||||
{showDetails ? <ChevronUp size={14} color={c.primary} /> : <ChevronDown size={14} color={c.primary} />}
|
{showDetails ? <ChevronUp size={14} color={c.primary} /> : <ChevronDown size={14} color={c.primary} />}
|
||||||
@@ -345,6 +377,19 @@ function makeStyles(c: ThemePalette) {
|
|||||||
backgroundColor: c.surfaceHover,
|
backgroundColor: c.surfaceHover,
|
||||||
},
|
},
|
||||||
badgeDot: { width: 8, height: 8, borderRadius: 4 },
|
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 },
|
badgeText: { ...typography.small, color: c.textSecondary },
|
||||||
detailsToggle: { flexDirection: 'row', alignItems: 'center', gap: 4, alignSelf: 'flex-start' },
|
detailsToggle: { flexDirection: 'row', alignItems: 'center', gap: 4, alignSelf: 'flex-start' },
|
||||||
detailsToggleText: { ...typography.caption, color: c.primary, fontWeight: '600' },
|
detailsToggleText: { ...typography.caption, color: c.primary, fontWeight: '600' },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
parseAuthenticationResults, parseSpamScore, parseSpamLLM, extractListHeaders,
|
parseAuthenticationResults, parseSpamScore, parseSpamLLM, extractListHeaders,
|
||||||
isAuthenticationSpoofed, headersToRecord, deriveHeaderInfo, deliveryDeltaMs, formatDelta,
|
isAuthenticationSpoofed, headersToRecord, deriveHeaderInfo, deliveryDeltaMs, formatDelta,
|
||||||
findReceivingIdentity, getSenderVerification, hasAlignedAuthenticationPass,
|
findReceivingIdentity, getSenderVerification, hasAlignedAuthenticationPass,
|
||||||
|
describeSenderVerification, senderVerificationOf,
|
||||||
} from '../email-headers';
|
} from '../email-headers';
|
||||||
|
|
||||||
describe('parseAuthenticationResults', () => {
|
describe('parseAuthenticationResults', () => {
|
||||||
@@ -174,3 +175,32 @@ describe('findReceivingIdentity', () => {
|
|||||||
expect(findReceivingIdentity([], { to: [] })).toBeUndefined();
|
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 };
|
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 {
|
interface ResInfo {
|
||||||
method: string;
|
method: string;
|
||||||
result: string;
|
result: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user