feat: warn when a message's sender can't be verified

This commit is contained in:
Linus Rath
2026-10-07 19:04:15 +02:00
parent ccf6bf7706
commit 8889346371
6 changed files with 208 additions and 9 deletions
+56 -3
View File
@@ -22,7 +22,7 @@ import { TagPicker } from "./tag-picker";
import { useMeasuredTagDisplay } from "@/hooks/use-tag-display";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { getEmailTagIds } from "@/lib/thread-utils";
import { getSecurityStatus, extractListHeaders } from "@/lib/email-headers";
import { getSecurityStatus, extractListHeaders, getSenderVerification } from "@/lib/email-headers";
import { emailToReadView } from "@/lib/plugin-projection";
import { generateEmailSource } from "@/lib/email-source";
import {
@@ -3020,6 +3020,19 @@ export function EmailViewer({
}
const sender = email.from?.[0];
const senderVerification = getSenderVerification(email.authenticationResults, sender?.email);
const senderVerificationLabel = senderVerification?.status === 'failed'
? t('sender_check.failed_label')
: t('sender_check.unverified_label');
const senderVerificationMessage = !senderVerification
? ''
: senderVerification.status === 'failed'
? senderVerification.sentFrom
? t('sender_check.failed_sent_from', { domain: senderVerification.domain, host: senderVerification.sentFrom })
: t('sender_check.failed', { domain: senderVerification.domain })
: senderVerification.sentFrom
? t('sender_check.unverified_sent_from', { domain: senderVerification.domain, host: senderVerification.sentFrom })
: t('sender_check.unverified', { domain: senderVerification.domain });
const isStarred = email.keywords?.$flagged;
const isUnread = !email.keywords?.$seen;
const isImportant = email.keywords?.["$important"];
@@ -4511,6 +4524,20 @@ export function EmailViewer({
onViewContact={handleViewContactSidebar}
className="text-sm text-start"
/>
{senderVerification && (
<span
className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md border text-xs whitespace-nowrap cursor-help",
senderVerification.status === 'failed'
? "bg-red-500/[0.07] border-red-500/30 text-red-700 dark:text-red-400"
: "bg-amber-500/[0.07] border-amber-500/30 text-amber-700 dark:text-amber-400",
)}
title={senderVerificationMessage}
>
<AlertTriangle className="w-3 h-3 flex-shrink-0" />
{senderVerificationLabel}
</span>
)}
</div>
</Row>
{replyToDifferent && (
@@ -4816,12 +4843,36 @@ export function EmailViewer({
)}
{/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */}
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
{(senderVerification ||
(hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
hasCalendarInvitation ||
(readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && (
<div className="border-b border-border bg-muted/30 isolate">
<div className="px-6 py-1.5">
<div className="flex flex-col gap-3 isolate">
{/* Sender the server's checks don't back */}
{senderVerification && (
<div className="flex items-start gap-3 py-1">
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
senderVerification.status === 'failed' ? "bg-destructive/15 text-destructive" : "bg-warning/15 text-warning",
)}>
<ShieldAlert className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{senderVerificationLabel}
</div>
<div className="text-sm font-medium text-foreground break-words">
{senderVerificationMessage}
</div>
<div className="text-sm text-muted-foreground break-words">
{t('sender_check.caution')}
</div>
</div>
</div>
)}
{/* External Content Controls */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
<div className="flex items-start gap-3 py-1">
@@ -4847,7 +4898,9 @@ export function EmailViewer({
{t('load_external_content')}
</button>
)}
{email.from?.[0]?.email && (
{/* Trusting a forged address would load remote content
for the next forgery too. */}
{email.from?.[0]?.email && !senderVerification && (
<button
onClick={() => {
const senderEmail = email.from?.[0]?.email;
+59
View File
@@ -8,6 +8,7 @@ import {
parseSpamLLM,
extractListHeaders,
isAuthenticationSpoofed,
getSenderVerification,
} from '../email-headers';
describe('parseAuthenticationResults', () => {
@@ -393,3 +394,61 @@ describe('parseAuthenticationResults - sender-controlled text', () => {
expect(result.dmarc?.result).toBe('fail');
});
});
describe('getSenderVerification', () => {
// Shape of a real phish: forged bank From, sent by a web host's PHP
// mailer, no signature. mail-auth reports dmarc=none because neither SPF
// nor DKIM passed, although the From domain publishes a DMARC record.
const phish = parseAuthenticationResults(
'mx.example.org;\r\n\tspf=none (mx.example.org: no SPF records found for www-data@web1.hoster.example) smtp.mailfrom=www-data@web1.hoster.example;\r\n\tiprev=permerror policy.iprev=203.0.113.7;\r\n\tdmarc=none header.from=bank.example policy.dmarc=none',
);
it('flags a message that passes neither SPF nor DKIM', () => {
expect(isAuthenticationSpoofed(phish)).toBe(false);
expect(getSenderVerification(phish, 'support@bank.example')).toEqual({
status: 'unverified',
domain: 'bank.example',
sentFrom: 'web1.hoster.example',
});
});
it('leaves out the envelope host when it is the From domain', () => {
const auth = parseAuthenticationResults('mx; spf=softfail smtp.mailfrom=billing@Bank.Example; dmarc=none header.from=bank.example');
expect(getSenderVerification(auth, 'billing@bank.example')).toEqual({ status: 'unverified', domain: 'bank.example' });
});
it('reports a DMARC fail as failed', () => {
const auth = parseAuthenticationResults('mx; spf=pass smtp.mailfrom=bounce@evil.example; dmarc=fail header.from=bank.example');
expect(getSenderVerification(auth, 'support@bank.example')).toEqual({
status: 'failed',
domain: 'bank.example',
sentFrom: 'evil.example',
});
});
it('accepts any passing SPF, DKIM or DMARC result', () => {
const from = 'news@shop.example';
expect(getSenderVerification(parseAuthenticationResults('mx; spf=pass smtp.mailfrom=bounces.esp.example'), from)).toBeNull();
expect(getSenderVerification(parseAuthenticationResults('mx; spf=none smtp.mailfrom=x.example; dkim=pass header.d=esp.example'), from)).toBeNull();
expect(getSenderVerification(parseAuthenticationResults('mx; dmarc=pass header.from=shop.example'), from)).toBeNull();
});
it('counts a passing signature that is not the first one', () => {
const auth = parseAuthenticationResults('mx; dkim=fail header.d=shop.example; dkim=pass header.d=esp.example; spf=none smtp.mailfrom=x.example');
expect(auth.dkim?.result).toBe('fail');
expect(auth.dkim?.all).toHaveLength(2);
expect(getSenderVerification(auth, 'news@shop.example')).toBeNull();
});
it('does not take a HELO pass for a MAIL FROM pass', () => {
const auth = parseAuthenticationResults('mx; spf=pass smtp.helo=web1.hoster.example; spf=none smtp.mailfrom=www-data@web1.hoster.example');
expect(getSenderVerification(auth, 'support@bank.example')?.status).toBe('unverified');
});
it('says nothing without results or a From address', () => {
expect(getSenderVerification(undefined, 'a@b.example')).toBeNull();
expect(getSenderVerification({}, 'a@b.example')).toBeNull();
expect(getSenderVerification({ iprev: { result: 'fail' } }, 'a@b.example')).toBeNull();
expect(getSenderVerification(phish, undefined)).toBeNull();
});
});
+66 -6
View File
@@ -33,10 +33,62 @@ export function isAuthenticationSpoofed(auth?: AuthenticationResults): boolean {
if (auth.dmarc?.result === 'fail') return true;
// Otherwise a hard SPF fail with no valid DKIM signature means the sender
// isn't authorized for the envelope domain.
if (auth.spf?.result === 'fail' && auth.dkim?.result !== 'pass') return true;
if (auth.spf?.result === 'fail' && !hasDkimPass(auth)) return true;
return false;
}
function hasDkimPass(auth: AuthenticationResults): boolean {
return auth.dkim?.result === 'pass' || !!auth.dkim?.all?.some((entry) => entry.result === 'pass');
}
function domainOf(address: string): string | undefined {
const domain = address.slice(address.lastIndexOf('@') + 1).trim().toLowerCase().replace(/\.$/, '');
return /^[^\s<>@]+\.[^\s<>@]+$/.test(domain) ? domain : undefined;
}
export interface SenderVerification {
/**
* `failed`: the message fails the From domain's checks (see
* isAuthenticationSpoofed). `unverified`: neither SPF nor DKIM passes, so
* nothing ties the message to any domain, let alone the one in From.
*/
status: 'failed' | 'unverified';
/** Domain of the visible From address. */
domain: string;
/** Envelope (MAIL FROM) host, when it differs from `domain`. */
sentFrom?: string;
}
/**
* Whether the receiving server's checks back the visible From domain.
* Returns null when they do, or when there are no results to judge by.
*
* DMARC alone can't answer this: mail-auth (Stalwart) only checks alignment
* once SPF or DKIM passes, so a message that passes neither reports
* `dmarc=none` even when the From domain publishes a policy. That is the
* plainest kind of forgery, so it gets its own verdict here.
*/
export function getSenderVerification(
auth: AuthenticationResults | undefined,
fromEmail: string | undefined,
): SenderVerification | null {
if (!auth || !fromEmail || (!auth.spf && !auth.dkim && !auth.dmarc)) return null;
const domain = domainOf(fromEmail);
if (!domain) return null;
// DMARC only counts the MAIL FROM identity; a HELO pass proves nothing
// about who wrote the message.
const mailFrom = auth.spf?.all?.find((entry) => entry.identity === 'mailfrom');
const spfPass = auth.spf?.all ? mailFrom?.result === 'pass' : auth.spf?.result === 'pass';
const envelope = mailFrom?.domain ?? auth.spf?.domain;
const envelopeDomain = envelope ? domainOf(envelope) : undefined;
const sentFrom = envelopeDomain && envelopeDomain !== domain ? envelopeDomain : undefined;
if (isAuthenticationSpoofed(auth)) return { status: 'failed', domain, sentFrom };
if (auth.dmarc?.result === 'pass' || spfPass || hasDkimPass(auth)) return null;
return { status: 'unverified', domain, sentFrom };
}
interface ResInfo {
method: string;
result: string;
@@ -182,12 +234,20 @@ export function parseAuthenticationResults(headers: string | readonly string[]):
};
}
const dkim = own.find((info) => info.method === 'dkim');
if (dkim) {
// A message can carry several signatures (the author's domain and the
// sending service's). The first one stays the headline; keep them all so
// a pass further down still counts.
const dkimResults = own
.filter((info) => info.method === 'dkim')
.map((info) => ({
result: info.result as DkimResult,
domain: info.props['header.d'],
selector: info.props['header.s'],
}));
if (dkimResults.length > 0) {
results.dkim = {
result: dkim.result as DkimResult,
domain: dkim.props['header.d'],
selector: dkim.props['header.s'],
...dkimResults[0],
...(dkimResults.length > 1 ? { all: dkimResults } : {}),
};
}
+9
View File
@@ -163,6 +163,15 @@ export interface AuthenticationResults {
result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
domain?: string;
selector?: string;
/**
* Every DKIM result when the message carried more than one signature;
* `result` above is the first.
*/
all?: Array<{
result: 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
domain?: string;
selector?: string;
}>;
};
dmarc?: {
result: 'pass' | 'fail' | 'none';
+9
View File
@@ -345,6 +345,15 @@
"external_content_warning": "Bilder und externe Inhalte wurden blockiert",
"load_external_content": "Bilder laden",
"trust_sender": "Diesem Absender immer vertrauen",
"sender_check": {
"unverified_label": "Absender nicht bestätigt",
"failed_label": "Absenderprüfung fehlgeschlagen",
"unverified": "Diese Nachricht gibt vor, von {domain} zu kommen, aber nichts bestätigt das. Sie trägt keine gültige Signatur von {domain} und kam nicht von einem Server, den {domain} zulässt.",
"unverified_sent_from": "Diese Nachricht gibt vor, von {domain} zu kommen, wurde aber von {host} verschickt und trägt keine gültige Signatur von {domain}.",
"failed": "Diese Nachricht gibt vor, von {domain} zu kommen, hat aber die Absenderprüfung dieser Domain nicht bestanden. Sie ist möglicherweise gefälscht.",
"failed_sent_from": "Diese Nachricht gibt vor, von {domain} zu kommen, wurde aber von {host} verschickt und hat die Absenderprüfung von {domain} nicht bestanden. Sie ist möglicherweise gefälscht.",
"caution": "Seien Sie vorsichtig mit Links, Anhängen und Fragen nach Passwörtern oder Zahlungsdaten."
},
"back_to_list": "Zurück zur Liste",
"view_contact": "Kontakt anzeigen",
"message_details": "Nachrichtendetails",
+9
View File
@@ -345,6 +345,15 @@
"external_content_warning": "Images and external content have been blocked",
"load_external_content": "Load images",
"trust_sender": "Always trust this sender",
"sender_check": {
"unverified_label": "Unverified sender",
"failed_label": "Sender check failed",
"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.",
"unverified_sent_from": "This message claims to be from {domain}, but it was sent from {host} and has no valid signature from {domain}.",
"failed": "This message claims to be from {domain}, but it failed that domain's sender checks. It may be forged.",
"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.",
"caution": "Be careful with links, attachments and requests for passwords or payment details."
},
"back_to_list": "Back to list",
"view_contact": "View contact",
"message_details": "Message Details",