fix: show a warning shield instead of the sender photo when the sender check fails (bulwarkmail/webmail#1439)

This commit is contained in:
Linus Rath
2026-10-31 03:37:46 +01:00
parent 49d4372918
commit 0b0fc021fb
12 changed files with 226 additions and 16 deletions
@@ -37,7 +37,9 @@ import {
searchEmails,
sendEmail,
MAILBOX_PROPERTIES,
queryEmailPage,
} from '../email';
import { senderWarningOf } from '../../lib/email-headers';
import { uploadBytes } from '../blob';
import { getIdentities, updateIdentity } from '../identity';
import { isJMAPSetError } from '../jmap-result';
@@ -418,4 +420,32 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => {
}
}, 60_000);
});
describe('mail list (reader pass)', () => {
it('lists the Authentication-Results headers so a forged From gets the warning shield (bulwarkmail/webmail#1439)', async () => {
const subject = `forged ${mailTag}`;
const raw = [
'Authentication-Results: mx.example.org; spf=fail smtp.mailfrom=evil.example; dkim=none; dmarc=fail header.from=bank.example',
'From: Bank <ceo@bank.example>',
`To: ${USER}`,
`Subject: ${subject}`,
`Date: ${new Date().toUTCString()}`,
`Message-ID: <forged-${RUN}@live.test>`,
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=utf-8',
'',
'hello',
'',
].join('\r\n');
const { blobId } = await uploadBytes(new TextEncoder().encode(raw), 'message/rfc822');
const id = await importEmailBlob(blobId, scratch, {});
const page = await queryEmailPage(scratch, { limit: 10 });
const row = page.list.find((e) => e.id === id);
expect(row).toBeDefined();
expect(senderWarningOf(row!)?.status).toBe('failed');
// The full fetch reads the same verdict from `headers`.
const [full] = await getFullEmails([id]);
expect(senderWarningOf(full)?.status).toBe('failed');
}, 60_000);
});
});
+13 -3
View File
@@ -19,17 +19,27 @@ import { hasTruncatedDisplayedBody } from '../lib/email-body';
import { withInlineBodyParts } from '../lib/email-inline-parts';
import { stalwartStateWentBack } from './stalwart-state';
import { isTransientNetworkError } from '../lib/network-error';
import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers';
export const EMAIL_LIST_PROPERTIES = [
const EMAIL_SUMMARY_PROPERTIES = [
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment',
];
export const EMAIL_LIST_PROPERTIES = [
...EMAIL_SUMMARY_PROPERTIES,
// The sender check, so a list row shows a warning shield instead of the
// favicon or photo of a forged From (webmail #1439).
LIST_AUTH_RESULTS_PROPERTY,
];
// The viewer derives reply threading (In-Reply-To/References), Reply-To
// handling, SPF/DKIM/DMARC chips, List-Unsubscribe and read-receipt requests
// from these; without `messageId`/`headers` none of that can work.
// from these; without `messageId`/`headers` none of that can work. The full
// `headers` carry Authentication-Results, so the list's header property is
// left out.
export const EMAIL_FULL_PROPERTIES = [
...EMAIL_LIST_PROPERTIES,
...EMAIL_SUMMARY_PROPERTIES,
'bodyStructure', 'textBody', 'htmlBody', 'bodyValues',
'attachments', 'blobId', 'bcc', 'replyTo', 'sentAt',
'messageId', 'inReplyTo', 'references', 'headers',
+3
View File
@@ -8,6 +8,7 @@ import { keywordPointer, mailboxPointer } from './patch-pointer';
import { secureFetch } from '../lib/client-cert';
import { freshStoredCredentials } from '../lib/stored-token-refresh';
import { textConditions } from '../lib/search-utils';
import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers';
// Aggregated views across accounts ("All inboxes", "All Sent", All mail /
// Unread / Starred). Because the JMAP client is a single-account singleton
@@ -21,6 +22,8 @@ import { textConditions } from '../lib/search-utils';
const EMAIL_LIST_PROPERTIES = [
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment',
// The sender check, for the warning shield on a forged From (#1439).
LIST_AUTH_RESULTS_PROPERTY,
];
const SESSION_CACHE_TTL_MS = 10 * 60 * 1000;