From 49d43729182f30447b9508401b0fe1ec89fe3dad Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:30:38 +0100 Subject: [PATCH 01/37] fix: count a dkim pass the receiver reports by header.i only (bulwarkmail/webmail#1420) --- src/lib/__tests__/email-headers.test.ts | 10 ++++++++++ src/lib/email-headers.ts | 10 +++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/lib/__tests__/email-headers.test.ts b/src/lib/__tests__/email-headers.test.ts index 6ff67182..286769e6 100644 --- a/src/lib/__tests__/email-headers.test.ts +++ b/src/lib/__tests__/email-headers.test.ts @@ -85,6 +85,16 @@ describe('getSenderVerification', () => { expect(getSenderVerification(undefined, 'a@bank.example')).toBeNull(); }); + it('takes the signing domain from header.i when the receiver writes no header.d (bulwarkmail/webmail#1420)', () => { + const from = 'noreply@example.org'; + expect(getSenderVerification(parseAuthenticationResults('mx.example.net; dkim=pass header.i=@example.org'), from)).toBeNull(); + expect(getSenderVerification(parseAuthenticationResults('mx; dkim=pass header.i=bounce@mail.example.org'), from)).toBeNull(); + expect(getSenderVerification(parseAuthenticationResults('mx; dkim=pass header.i=@other.example'), from)) + .toEqual({ status: 'unverified', domain: 'example.org' }); + // header.d still wins when both are there. + expect(parseAuthenticationResults('mx; dkim=pass header.d=a.example header.i=@b.example').dkim?.domain).toBe('a.example'); + }); + it('does not count a HELO pass', () => { const auth = parseAuthenticationResults('mx; spf=pass smtp.helo=bank.example; spf=none smtp.mailfrom=bank.example'); expect(getSenderVerification(auth, 'a@bank.example')?.status).toBe('unverified'); diff --git a/src/lib/email-headers.ts b/src/lib/email-headers.ts index bb3856ad..539265ea 100644 --- a/src/lib/email-headers.ts +++ b/src/lib/email-headers.ts @@ -323,6 +323,12 @@ function parseResinfos(header: string): ResInfo[] { .filter((info): info is ResInfo => info !== null); } +/** The domain of a DKIM identity (`header.i`): `@domain` or `user@domain`. */ +function identityDomain(identity: string | undefined): string | undefined { + const at = identity?.lastIndexOf('@') ?? -1; + return identity && at >= 0 && at < identity.length - 1 ? identity.slice(at + 1) : undefined; +} + const DMARC_SEVERITY: Record = { fail: 3, permerror: 2, @@ -394,7 +400,9 @@ export function parseAuthenticationResults(headers: string | readonly string[]): .filter((info) => info.method === 'dkim') .map((info) => ({ result: info.result as DkimResult, - domain: info.props['header.d'], + // Some receivers name the signature only by its identity (`header.i`, + // `@domain` or `user@domain`, RFC 8601 2.7.1); its domain is the signer's. + domain: info.props['header.d'] ?? identityDomain(info.props['header.i']), selector: info.props['header.s'], })); if (dkimResults.length > 0) { From 0b0fc021fb476d06cb3316bd1f7e5ecd41fc853e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:37:46 +0100 Subject: [PATCH 02/37] fix: show a warning shield instead of the sender photo when the sender check fails (bulwarkmail/webmail#1439) --- src/api/__tests__/live-stalwart-mail.test.ts | 30 +++++++++ src/api/email.ts | 16 ++++- src/api/unified-inbox.ts | 3 + src/components/SenderAvatar.tsx | 52 ++++++++++++---- .../__tests__/sender-warning-avatar.test.ts | 61 +++++++++++++++++++ src/components/email/MessageHeader.tsx | 7 ++- src/components/email/ThreadMessageCard.tsx | 4 +- src/components/email/use-sender-warning.ts | 18 ++++++ src/lib/avatar-utils.ts | 21 +++++++ src/lib/email-headers.ts | 22 +++++++ src/screens/EmailListScreen.tsx | 4 ++ src/screens/UnifiedInboxScreen.tsx | 4 ++ 12 files changed, 226 insertions(+), 16 deletions(-) create mode 100644 src/components/__tests__/sender-warning-avatar.test.ts create mode 100644 src/components/email/use-sender-warning.ts diff --git a/src/api/__tests__/live-stalwart-mail.test.ts b/src/api/__tests__/live-stalwart-mail.test.ts index 9cfe4a25..ffb1a563 100644 --- a/src/api/__tests__/live-stalwart-mail.test.ts +++ b/src/api/__tests__/live-stalwart-mail.test.ts @@ -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 ', + `To: ${USER}`, + `Subject: ${subject}`, + `Date: ${new Date().toUTCString()}`, + `Message-ID: `, + '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); + }); }); diff --git a/src/api/email.ts b/src/api/email.ts index 45ca4657..7ad3b9d2 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -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', diff --git a/src/api/unified-inbox.ts b/src/api/unified-inbox.ts index 7bc53957..254a57d0 100644 --- a/src/api/unified-inbox.ts +++ b/src/api/unified-inbox.ts @@ -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; diff --git a/src/components/SenderAvatar.tsx b/src/components/SenderAvatar.tsx index 321915c1..15e5c758 100644 --- a/src/components/SenderAvatar.tsx +++ b/src/components/SenderAvatar.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { View, Text, Image, StyleSheet, type StyleProp, type ViewStyle } from 'react-native'; import { useShallow } from 'zustand/react/shallow'; import { + avatarContent, generateEmailAvatarColor, getContactPhotoForEmail, getEmailInitials, @@ -14,6 +15,8 @@ import type { ContactPhotoRef } from '../lib/contact-utils'; import { useContactPhotoSource } from '../lib/use-contact-photo'; import { useSettingsStore } from '../stores/settings-store'; import { useContactsStore } from '../stores/contacts-store'; +import { useColors } from '../theme/colors'; +import { ShieldAlert } from './icons'; interface SenderAvatarProps { name?: string | null; @@ -32,23 +35,32 @@ interface SenderAvatarProps { * the contact has no photo, so no other card's photo is borrowed. */ photo?: ContactPhotoRef | null; + /** + * The message's sender check failed or is unverified (see + * useSenderWarning): a warning shield with this label instead of any photo, + * favicon or initials, so a forged From doesn't get the real sender's logo + * or photo (webmail #1439). + */ + senderWarning?: string | null; } // Mirrors the webmail `Avatar` priority for the subset available on native: -// address-book photo → company favicon (if sender domain is non-personal and -// `senderFavicons` is on) → initials over HSL-hashed color. -export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo }: SenderAvatarProps) { - const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages; +// sender warning → address-book photo → company favicon (if sender domain is +// non-personal and `senderFavicons` is on) → initials over HSL-hashed color. +export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo, senderWarning }: SenderAvatarProps) { + const c = useColors(); + const warned = !!senderWarning; + const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned; // Compared shallowly, so only avatars whose photo changed re-render when the // contacts change; the index behind it is built once per contacts array. const indexedPhoto = useContactsStore( - useShallow((s) => (photo === undefined ? getContactPhotoForEmail(s.contacts, email) : undefined)), + useShallow((s) => (photo === undefined && !warned ? getContactPhotoForEmail(s.contacts, email) : undefined)), ); const contactPhoto = photo === undefined ? indexedPhoto : photo; // A blob photo (Stalwart 1.0) has no source while it waits for its // download or after it failed; the favicon or initials show meanwhile. const { source: photoSource, onLoad: onPhotoLoad, onError: onPhotoError } = - useContactPhotoSource(disableImages ? null : contactPhoto); + useContactPhotoSource(disableImages || warned ? null : contactPhoto); const [imgError, setImgError] = React.useState(false); React.useEffect(() => { @@ -61,8 +73,11 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma const faviconDomain = getFaviconDomain(email ?? undefined); const domainFailed = faviconDomain ? hasFaviconFailed(faviconDomain) : false; - const showPhoto = !disableImages && !!photoSource; - const showFavicon = !showPhoto && senderFavicons && !!faviconDomain && !imgError && !domainFailed; + const content = avatarContent({ + senderWarning, + hasPhoto: !disableImages && !!photoSource, + showFavicon: senderFavicons && !!faviconDomain && !imgError && !domainFailed, + }); const handleError = React.useCallback(() => { setImgError(true); @@ -75,14 +90,29 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma width: size, height: size, borderRadius: size / 2, - backgroundColor: showPhoto ? 'transparent' : showFavicon ? '#ffffff' : bgColor, + backgroundColor: content === 'warning' + ? c.errorBg + : content === 'photo' ? 'transparent' : content === 'favicon' ? '#ffffff' : bgColor, }, style, ]; const fontSize = Math.round(size * 0.4); - if (showPhoto && photoSource) { + if (content === 'warning') { + return ( + + + + ); + } + + if (content === 'photo' && photoSource) { return ( ({ + jmapClient: { accountId: 'acc-1', request: vi.fn(), getMaxObjectsInGet: vi.fn(() => 50) }, +})); + +import { avatarContent } from '../../lib/avatar-utils'; +import { LIST_AUTH_RESULTS_PROPERTY, senderWarningOf } from '../../lib/email-headers'; +import { EMAIL_FULL_PROPERTIES, EMAIL_LIST_PROPERTIES } from '../../api/email'; +import type { Email } from '../../api/types'; + +// A forged From kept the real sender's favicon or contact photo: the reader +// worked out that the sender check failed, but the avatar beside it still +// showed the claimed sender's logo, and list rows had no auth data at all +// (webmail 605a3983b, #1439). Now the avatar shows a warning shield. + +const FORGED = 'mx.example.org; spf=fail smtp.mailfrom=evil.example; dkim=none; dmarc=fail header.from=bank.example'; +const GOOD = 'mx.example.org; spf=pass smtp.mailfrom=bank.example; dkim=pass header.d=bank.example; dmarc=pass header.from=bank.example'; + +function row(auth: string[] | undefined): Email { + return { + id: 'm1', threadId: 't1', mailboxIds: {}, keywords: {}, size: 1, receivedAt: '2026-10-31T10:00:00Z', + hasAttachment: false, from: [{ name: 'Bank', email: 'ceo@bank.example' }], + ...(auth ? { [LIST_AUTH_RESULTS_PROPERTY]: auth } : {}), + } as Email; +} + +describe('sender warning in avatars (bulwarkmail/webmail#1439)', () => { + it('asks for the Authentication-Results headers in the list fetch, not in the full one', () => { + expect(EMAIL_LIST_PROPERTIES).toContain('header:Authentication-Results:all'); + // The full fetch has `headers`, which carry the same header. + expect(EMAIL_FULL_PROPERTIES).not.toContain('header:Authentication-Results:all'); + expect(EMAIL_FULL_PROPERTIES).toContain('headers'); + }); + + it('judges a list row from the listed header and a full message from its headers', () => { + expect(senderWarningOf(row([FORGED]))?.status).toBe('failed'); + expect(senderWarningOf(row([GOOD]))).toBeNull(); + expect(senderWarningOf(row(undefined))).toBeNull(); + expect(senderWarningOf(row([]))).toBeNull(); + const full = { ...row(undefined), headers: [{ name: 'Authentication-Results', value: ` ${FORGED}` }] }; + expect(senderWarningOf(full)?.status).toBe('failed'); + }); + + it('shows the shield before a contact photo or favicon', () => { + expect(avatarContent({ senderWarning: 'Sender check failed', hasPhoto: true, showFavicon: true })).toBe('warning'); + expect(avatarContent({ senderWarning: undefined, hasPhoto: true, showFavicon: true })).toBe('photo'); + expect(avatarContent({ senderWarning: null, hasPhoto: false, showFavicon: true })).toBe('favicon'); + expect(avatarContent({ hasPhoto: false, showFavicon: false })).toBe('initials'); + }); + + it('passes the warning to the avatar in the reader, collapsed conversation cards and list rows', () => { + const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', p), 'utf8'); + expect(src('components/email/MessageHeader.tsx')).toMatch(/senderWarning=\{verificationText\?\.label\}/); + expect(src('components/email/ThreadMessageCard.tsx')).toMatch(/senderWarning=\{senderWarning\}/); + expect(src('screens/EmailListScreen.tsx')).toMatch(/senderWarning=\{senderWarning\}/); + expect(src('screens/UnifiedInboxScreen.tsx')).toMatch(/senderWarning=\{verification \?/); + }); +}); diff --git a/src/components/email/MessageHeader.tsx b/src/components/email/MessageHeader.tsx index 4fdcbcca..ad236b1b 100644 --- a/src/components/email/MessageHeader.tsx +++ b/src/components/email/MessageHeader.tsx @@ -172,7 +172,12 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA accessibilityRole="button" accessibilityLabel={from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')} > - + onAddressPress(from) : undefined}> diff --git a/src/components/email/ThreadMessageCard.tsx b/src/components/email/ThreadMessageCard.tsx index a70b0e28..6fd10d7e 100644 --- a/src/components/email/ThreadMessageCard.tsx +++ b/src/components/email/ThreadMessageCard.tsx @@ -10,6 +10,7 @@ import SenderAvatar from '../SenderAvatar'; import { MessageContent, type MessageContentProps } from './MessageContent'; import { emailDisplayDate, formatHeaderDate, formatHeaderTime } from '../../lib/email-date'; import { singleLine } from '../../lib/single-line'; +import { useSenderWarning } from './use-sender-warning'; interface Props extends MessageContentProps { expanded: boolean; @@ -43,11 +44,12 @@ export function ThreadMessageCard({ const unread = !email.keywords?.$seen; const starred = !!email.keywords?.$flagged; const date = emailDisplayDate(email); + const senderWarning = useSenderWarning(expanded ? undefined : email); if (!expanded) { return ( [styles.collapsed, pressed && styles.pressed]}> - + diff --git a/src/components/email/use-sender-warning.ts b/src/components/email/use-sender-warning.ts new file mode 100644 index 00000000..7ef27120 --- /dev/null +++ b/src/components/email/use-sender-warning.ts @@ -0,0 +1,18 @@ +import React from 'react'; +import type { Email } from '../../api/types'; +import { describeSenderVerification, senderWarningOf } from '../../lib/email-headers'; +import { useLocaleStore } from '../../stores/locale-store'; + +/** + * The sender-check label of a message whose From the receiving server's + * checks don't back, the same verdict as the notice in the reader. Avatars + * show it instead of the claimed sender's favicon or photo (webmail + * use-sender-warning.ts, #1439). Undefined when the check passed, there are + * no results to judge by, or no message is given (Sent and Drafts rows show + * the recipient, whose avatar the check says nothing about). + */ +export function useSenderWarning(email: Pick | undefined): string | undefined { + const t = useLocaleStore((s) => s.t); + const verification = React.useMemo(() => (email ? senderWarningOf(email) : null), [email]); + return verification ? describeSenderVerification(verification, t).label : undefined; +} diff --git a/src/lib/avatar-utils.ts b/src/lib/avatar-utils.ts index 0e87db7e..584d82fd 100644 --- a/src/lib/avatar-utils.ts +++ b/src/lib/avatar-utils.ts @@ -196,3 +196,24 @@ export function getContactPhotoForEmail( const key = email?.trim().toLowerCase(); return key ? getContactPhotoIndex(contacts).get(key) : undefined; } + +// --- What the avatar shows -------------------------------------------------- + +export type AvatarContent = 'warning' | 'photo' | 'favicon' | 'initials'; + +/** + * What a sender avatar shows, in the webmail `Avatar`'s order. A failed or + * unverified sender check comes first: a warning shield instead of any photo, + * favicon or initials, so a forged From doesn't get the real sender's logo or + * contact photo (webmail 605a3983b, #1439). + */ +export function avatarContent(options: { + senderWarning?: string | null; + hasPhoto: boolean; + showFavicon: boolean; +}): AvatarContent { + if (options.senderWarning) return 'warning'; + if (options.hasPhoto) return 'photo'; + if (options.showFavicon) return 'favicon'; + return 'initials'; +} diff --git a/src/lib/email-headers.ts b/src/lib/email-headers.ts index 539265ea..767e5007 100644 --- a/src/lib/email-headers.ts +++ b/src/lib/email-headers.ts @@ -244,6 +244,28 @@ export function senderVerificationOf(email: Pick): Se return getSenderVerification(parseAuthenticationResults(values), email.from?.[0]?.email); } +/** + * The list fetch asks for the Authentication-Results headers by this + * property, so list rows and collapsed conversation cards can judge the + * sender too (webmail 605a3983b, #1439). Stalwart answers header properties + * from the stored headers, not the message blob. + */ +export const LIST_AUTH_RESULTS_PROPERTY = 'header:Authentication-Results:all'; + +/** + * The sender check for a message as the list or the reader holds it: from + * the full headers when they are there, else from the list property. Null + * when the checks back the From or there is nothing to judge by. + */ +export function senderWarningOf(email: Pick): SenderVerification | null { + if (email.headers) return senderVerificationOf(email); + const raw = (email as { [LIST_AUTH_RESULTS_PROPERTY]?: unknown })[LIST_AUTH_RESULTS_PROPERTY]; + if (!Array.isArray(raw)) return null; + const values = raw.filter((value): value is string => typeof value === 'string'); + if (values.length === 0) return null; + return getSenderVerification(parseAuthenticationResults(values), email.from?.[0]?.email); +} + interface ResInfo { method: string; result: string; diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index a9dc294f..b9308448 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -24,6 +24,7 @@ import { ListAttachmentChips, ListAttachmentOpener, useListRowAttachments, } from '../components/email/ListAttachmentChips'; import { VerificationCodeChip, useListVerificationCode } from '../components/email/VerificationCodeChip'; +import { useSenderWarning } from '../components/email/use-sender-warning'; import type { LoadListAttachments } from '../lib/list-attachments'; import { useNetworkStore } from '../stores/network-store'; import { @@ -176,6 +177,8 @@ const EmailRow = React.memo(function EmailRow({ const handlePress = React.useCallback(() => onPress(key), [onPress, key]); const handleLongPress = React.useCallback(() => onLongPress(key), [onLongPress, key]); const verificationCode = useListVerificationCode(item); + // Sent and Drafts rows show the recipient: the check says nothing about them. + const senderWarning = useSenderWarning(showRecipient ? undefined : item); return ( )} diff --git a/src/screens/UnifiedInboxScreen.tsx b/src/screens/UnifiedInboxScreen.tsx index d2c7097d..94470533 100644 --- a/src/screens/UnifiedInboxScreen.tsx +++ b/src/screens/UnifiedInboxScreen.tsx @@ -26,6 +26,7 @@ import { singleLine } from '../lib/single-line'; import { prefetchMessage, rememberRows } from '../lib/email-detail-cache'; import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm'; import { onStateChangeType } from '../lib/state-change-bus'; +import { describeSenderVerification, senderWarningOf } from '../lib/email-headers'; import { createUnifiedReloadTracker } from '../lib/unified-reload'; import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; @@ -313,6 +314,8 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) { const unread = !item.keywords?.$seen; const starred = !!item.keywords?.$flagged; const isSelected = selected.has(rowKey(item)); + // Sent and Drafts rows show the recipient: the check says nothing about them. + const verification = showRecipient ? null : senderWarningOf(item); return ( {acc && accountIds.length > 1 && ( From 8e10e73e29ce0a45de32863ca0f6111b7de8fc0b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:38:44 +0100 Subject: [PATCH 03/37] fix: keep sender favicons off by default so sender domains don't go to duckduckgo --- .../__tests__/settings-store-reader.test.ts | 20 +++++++++++++++++++ src/stores/settings-store.ts | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 src/stores/__tests__/settings-store-reader.test.ts diff --git a/src/stores/__tests__/settings-store-reader.test.ts b/src/stores/__tests__/settings-store-reader.test.ts new file mode 100644 index 00000000..9ff990fe --- /dev/null +++ b/src/stores/__tests__/settings-store-reader.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useSettingsStore } from '../settings-store'; + +// Defaults the reader pass changed. Kept apart from settings-store.test.ts so +// the agents working on settings in parallel don't edit the same test file. + +describe('settings defaults (reader pass)', () => { + beforeEach(async () => { + await AsyncStorage.clear(); + useSettingsStore.getState().resetToDefaults(); + }); + + it('keeps sender favicons off until the user turns them on', () => { + // The app fetches them from icons.duckduckgo.com itself, which tells a + // third party every sender domain and the device's IP. The webmail + // proxies them through its own server, so its default doesn't carry over. + expect(useSettingsStore.getState().senderFavicons).toBe(false); + }); +}); diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index bb0ffb33..674a2fa3 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -319,7 +319,7 @@ const DEFAULT_PERSISTED: PersistedSettings = { externalContentPolicy: 'ask', trustedSenders: [], trustedSendersAddressBook: null, - senderFavicons: true, + senderFavicons: false, hideInlineImageAttachments: true, groupContactsByLetter: true, From 3243714db9170df8cc8fb0cb5b4fb8046f4dba1f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:40:34 +0100 Subject: [PATCH 04/37] fix: stop a mail full of blockquotes from freezing the reader --- src/lib/__tests__/quote-collapse-walk.test.ts | 143 ++++++++++++++++++ src/lib/quote-collapse.ts | 30 ++-- 2 files changed, 157 insertions(+), 16 deletions(-) create mode 100644 src/lib/__tests__/quote-collapse-walk.test.ts diff --git a/src/lib/__tests__/quote-collapse-walk.test.ts b/src/lib/__tests__/quote-collapse-walk.test.ts new file mode 100644 index 00000000..7adb7142 --- /dev/null +++ b/src/lib/__tests__/quote-collapse-walk.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from 'vitest'; +import { buildQuoteCollapseScript, QUOTE_COLLAPSED_ATTR, QUOTE_TOGGLE_ATTR } from '../quote-collapse'; + +// The reader's quote-collapse script looked for an attributed
+// by walking every text node from the start of the body once per top-level +// blockquote: quadratic, so a mail full of blockquotes froze the reader. The +// webmail does one walk (lib/quote-collapse.ts). These tests run the injected +// script against a small fake DOM that counts the tree-walker steps. + +const SHOW_ELEMENT = 1; +const SHOW_TEXT = 4; + +let steps = 0; + +class FNode { + parentNode: FEl | null = null; + get parentElement(): FEl | null { return this.parentNode; } +} + +class FText extends FNode { + nodeType = 3; + constructor(public nodeValue: string) { super(); } +} + +class FEl extends FNode { + nodeType = 1; + tagName: string; + childNodes: FNode[] = []; + attrs = new Map(); + style = { display: '', cssText: '' }; + title = ''; + textContent = ''; + constructor(tag: string) { super(); this.tagName = tag.toUpperCase(); } + append(...kids: Array): this { + for (const kid of kids) { + const node = typeof kid === 'string' ? new FText(kid) : kid; + node.parentNode = this; + this.childNodes.push(node); + } + return this; + } + insertBefore(node: FNode, ref: FNode): void { + node.parentNode = this; + this.childNodes.splice(this.childNodes.indexOf(ref), 0, node); + } + get nextElementSibling(): FEl | null { + const sibs = this.parentNode?.childNodes ?? []; + for (let i = sibs.indexOf(this) + 1; i < sibs.length; i++) if (sibs[i] instanceof FEl) return sibs[i] as FEl; + return null; + } + setAttribute(k: string, v: string) { this.attrs.set(k, v); } + getAttribute(k: string) { return this.attrs.get(k) ?? null; } + hasAttribute(k: string) { return this.attrs.has(k); } + removeAttribute(k: string) { this.attrs.delete(k); } + addEventListener() { /* not clicked here */ } + matches() { return false; } + // The fixtures hold no element the container or separator selectors match. + querySelector() { return null; } + querySelectorAll(tag: string): FEl[] { + return preorder(this).slice(1).filter((n): n is FEl => n instanceof FEl && n.tagName === tag.toUpperCase()); + } + closest(tag: string): FEl | null { + for (let el: FEl | null = this; el; el = el.parentNode) if (el.tagName === tag.toUpperCase()) return el; + return null; + } + compareDocumentPosition(other: FNode): number { + for (let p = other.parentNode; p; p = p.parentNode) if (p === this) return 16 | 4; // contained by, following + const order = preorder(rootOf(this)); + return order.indexOf(other) < order.indexOf(this) ? 2 : 4; + } +} + +function rootOf(node: FNode): FNode { + let n = node; + while (n.parentNode) n = n.parentNode; + return n; +} + +function preorder(root: FNode): FNode[] { + const out: FNode[] = []; + const visit = (n: FNode) => { + out.push(n); + if (n instanceof FEl) n.childNodes.forEach(visit); + }; + visit(root); + return out; +} + +function el(tag: string, ...kids: Array): FEl { + return new FEl(tag).append(...kids); +} + +function run(body: FEl): void { + const document = { + body, + createElement: (tag: string) => new FEl(tag), + createTreeWalker: (root: FEl, what: number) => { + const nodes = preorder(root).slice(1).filter((n) => + (n instanceof FText && what & SHOW_TEXT) || (n instanceof FEl && what & SHOW_ELEMENT)); + let i = 0; + return { nextNode: () => { steps++; return nodes[i++] ?? null; } }; + }, + }; + const Node = { + TEXT_NODE: 3, DOCUMENT_POSITION_PRECEDING: 2, DOCUMENT_POSITION_FOLLOWING: 4, DOCUMENT_POSITION_CONTAINED_BY: 16, + }; + const NodeFilter = { SHOW_ELEMENT, SHOW_TEXT }; + const script = buildQuoteCollapseScript({ show: 'Show', hide: 'Hide' }); + new Function('document', 'Node', 'NodeFilter', 'window', script)(document, Node, NodeFilter, {}); +} + +describe('quote collapse in HTML mail', () => { + it('finds the attributed blockquote in one walk, not one walk per blockquote', () => { + const n = 300; + const body = el('body'); + for (let i = 0; i < n; i++) body.append(el('p', `line ${i}`), el('blockquote', `quote ${i}`)); + steps = 0; + run(body); + // About 4n nodes in the body; walking from the start for each of the n + // blockquotes took about n² steps. + expect(steps).toBeLessThan(20 * n); + }); + + it('collapses the outermost blockquote after an attribution line', () => { + const inner = el('blockquote', 'older'); + const outer = el('blockquote', el('p', 'old'), inner); + const first = el('blockquote', 'not attributed'); + const body = el('body', el('p', 'a'), first, el('p', 'Thanks!'), el('p', 'On Monday, Bob wrote:'), outer); + run(body); + expect(outer.style.display).toBe('none'); + expect(outer.hasAttribute(QUOTE_COLLAPSED_ATTR)).toBe(true); + expect(first.style.display).toBe(''); + expect(inner.hasAttribute(QUOTE_COLLAPSED_ATTR)).toBe(false); + const button = body.childNodes[body.childNodes.indexOf(outer) - 1] as FEl; + expect(button.hasAttribute(QUOTE_TOGGLE_ATTR)).toBe(true); + }); + + it('leaves a blockquote alone when no attribution line precedes it', () => { + const quote = el('blockquote', 'quoted'); + run(el('body', el('p', 'Hello there'), quote)); + expect(quote.style.display).toBe(''); + }); +}); diff --git a/src/lib/quote-collapse.ts b/src/lib/quote-collapse.ts index c18ca953..7343b6dc 100644 --- a/src/lib/quote-collapse.ts +++ b/src/lib/quote-collapse.ts @@ -92,24 +92,22 @@ export function buildQuoteCollapseScript(labels: QuoteCollapseLabels): string { } return false; } - function precedingTextEndsWithColon(el) { - var walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT); + // One walk in document order, remembering the last non-blank text: a + // blockquote is reached before its own text, so that is the text preceding + // it. Walking from the start again per blockquote took quadratic time and + // froze the reader on a mail full of blockquotes (webmail quote-collapse.ts). + function findAttributedBlockquote() { + var walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT); var last = null; for (var node = walker.nextNode(); node; node = walker.nextNode()) { - var rel = el.compareDocumentPosition(node); - if (rel & Node.DOCUMENT_POSITION_CONTAINED_BY) continue; - if (!(rel & Node.DOCUMENT_POSITION_PRECEDING)) break; - var text = (node.nodeValue || '').trim(); - if (text !== '') last = text; - } - return last !== null && /:$/.test(last); - } - function findAttributedBlockquote() { - var bqs = body.querySelectorAll('blockquote'); - for (var i = 0; i < bqs.length; i++) { - var bq = bqs[i]; - if (bq.parentElement && bq.parentElement.closest('blockquote')) continue; - if (precedingTextEndsWithColon(bq)) return bq; + if (node.nodeType === Node.TEXT_NODE) { + var text = (node.nodeValue || '').trim(); + if (text !== '') last = text; + continue; + } + if (node.tagName !== 'BLOCKQUOTE') continue; + if (node.parentElement && node.parentElement.closest('blockquote')) continue; + if (last !== null && /:$/.test(last)) return node; } return null; } From a930d17786ad20b9d7f299ecdca2b9aba3850af4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:43:18 +0100 Subject: [PATCH 05/37] fix: drop direction-override characters from attachment names so a sender can't fake the file type --- src/lib/__tests__/bidi.test.ts | 25 +++++++++++++++++++++++++ src/lib/__tests__/email-export.test.ts | 6 ++++++ src/lib/attachment-display.ts | 6 +++++- src/lib/bidi.ts | 12 ++++++++++++ src/lib/email-export.ts | 3 ++- 5 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 src/lib/__tests__/bidi.test.ts create mode 100644 src/lib/bidi.ts diff --git a/src/lib/__tests__/bidi.test.ts b/src/lib/__tests__/bidi.test.ts new file mode 100644 index 00000000..2e8bc9f9 --- /dev/null +++ b/src/lib/__tests__/bidi.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { stripBidiControls } from '../bidi'; +import { getAttachmentDisplayName } from '../attachment-display'; + +// A sender-chosen attachment name kept its direction-override characters, +// so `reportfdp.exe` showed as `reportexe.pdf`: an executable +// dressed up as a PDF (webmail lib/bidi.ts). + +const ch = (code: number) => String.fromCharCode(code); +const RLO = ch(0x202e); + +describe('direction-override characters in attachment names', () => { + it('strips the embeddings, overrides, isolates and LRM/RLM marks', () => { + const controls = [0x200e, 0x200f, 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, 0x2066, 0x2067, 0x2068, 0x2069].map(ch).join(''); + expect(stripBidiControls(`a${controls}b`)).toBe('ab'); + // Hebrew and Arabic letters stay. + expect(stripBidiControls('שלום.pdf')).toBe('שלום.pdf'); + }); + + it('shows the real extension in the chip name', () => { + expect(getAttachmentDisplayName(`report${RLO}fdp.exe`, 'application/octet-stream')).toBe('reportfdp.exe'); + // A name of nothing but controls falls back to the type label. + expect(getAttachmentDisplayName(RLO, 'application/pdf')).toBe('Document.pdf'); + }); +}); diff --git a/src/lib/__tests__/email-export.test.ts b/src/lib/__tests__/email-export.test.ts index fd720a24..68b76c1b 100644 --- a/src/lib/__tests__/email-export.test.ts +++ b/src/lib/__tests__/email-export.test.ts @@ -246,6 +246,12 @@ describe('cachePreviewFile (in-app preview)', () => { expect(file.name).toBe('a_b_.txt'); }); + it('drops direction-override characters, so the real extension is the one the file gets shown with', async () => { + const rlo = String.fromCharCode(0x202e); + const file = await cachePreviewFile('blob-b', `report${rlo}fdp.exe`, 'application/octet-stream'); + expect(file.name).toBe('reportfdp.exe'); + }); + it('removes its folder and rethrows when the download fails', async () => { vi.mocked(File.downloadFileAsync).mockRejectedValueOnce(new Error('Download failed: 404')); diff --git a/src/lib/attachment-display.ts b/src/lib/attachment-display.ts index c701228e..6031c94f 100644 --- a/src/lib/attachment-display.ts +++ b/src/lib/attachment-display.ts @@ -7,6 +7,7 @@ import type { Attachment, Email } from '../api/types'; import { findCalendarAttachment } from './calendar-invitation'; import { pickEmailBody, selectRenderableHtml } from './email-body'; import { extractCidRefs } from './email-html'; +import { stripBidiControls } from './bidi'; const MIME_TYPE_LABELS: Record = { 'application/pdf': 'Document.pdf', @@ -31,7 +32,10 @@ const MIME_TYPE_LABELS: Record = { }; export function getAttachmentDisplayName(name: string | null | undefined, mimeType?: string): string { - if (name) return name; + // A direction override in a sender-chosen name can fake the file type + // (`reportfdp.exe` shows as `reportexe.pdf`). + const shown = name ? stripBidiControls(name) : ''; + if (shown) return shown; if (mimeType) { const base = mimeType.split(';')[0].trim().toLowerCase(); const label = MIME_TYPE_LABELS[base]; diff --git a/src/lib/bidi.ts b/src/lib/bidi.ts new file mode 100644 index 00000000..93bc0cd8 --- /dev/null +++ b/src/lib/bidi.ts @@ -0,0 +1,12 @@ +/** + * Unicode directional formatting characters: the explicit embeddings, + * overrides and isolates (U+202A-202E, U+2066-2069) and the LRM/RLM marks. + * In a sender-controlled name they reorder what the reader sees, so + * `reportfdp.exe` shows as `reportexe.pdf` (webmail lib/bidi.ts). + */ +const BIDI_CONTROLS = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g; + +/** `text` without directional formatting characters, for display. */ +export function stripBidiControls(text: string): string { + return text.replace(BIDI_CONTROLS, ''); +} diff --git a/src/lib/email-export.ts b/src/lib/email-export.ts index c9b20160..41ebcb3c 100644 --- a/src/lib/email-export.ts +++ b/src/lib/email-export.ts @@ -14,6 +14,7 @@ import { } from './download-filename'; import { getClientCertAlias, secureFetch } from './client-cert'; import { sniffImageMime } from './email-html'; +import { stripBidiControls } from './bidi'; import { handoffMimeType } from './file-preview'; const RFC822 = 'message/rfc822'; @@ -55,7 +56,7 @@ export function attachmentSaveName(email: Email, name: string | undefined, type: function safeAttachmentName(name: string | undefined, type: string | undefined): string { const fallbackExt = type?.split('/')[1]?.replace(/[^a-z0-9]/gi, '').slice(0, 8) || 'bin'; - const cleaned = (name ?? '').replace(/[\\/:*?"<>|]/g, '_').trim(); + const cleaned = stripBidiControls(name ?? '').replace(/[\\/:*?"<>|]/g, '_').trim(); if (cleaned) return cleaned.slice(0, 120); return `attachment.${fallbackExt}`; } From 7dc457780dce0c1a2b9031d914f8f3565d9a69b7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:48:14 +0100 Subject: [PATCH 06/37] feat: read mail as plain text when plain text only is on, or per message from the menu --- src/components/EmailBodyView.tsx | 50 +++- .../__tests__/plain-text-reading.test.ts | 30 +++ src/components/email/MessageContent.tsx | 10 +- .../__tests__/html-to-readable-text.test.ts | 56 ++++ src/lib/html-to-readable-text.ts | 246 ++++++++++++++++++ src/lib/verification-code.ts | 2 +- src/screens/EmailThreadScreen.tsx | 41 ++- 7 files changed, 427 insertions(+), 8 deletions(-) create mode 100644 src/components/__tests__/plain-text-reading.test.ts create mode 100644 src/lib/__tests__/html-to-readable-text.test.ts create mode 100644 src/lib/html-to-readable-text.ts diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx index be2b4b8b..688a61b8 100644 --- a/src/components/EmailBodyView.tsx +++ b/src/components/EmailBodyView.tsx @@ -14,6 +14,7 @@ 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 { plainTextReading } from '../lib/html-to-readable-text'; import { useHasContacts } from '../lib/capabilities'; import { useSettingsStore } from '../stores/settings-store'; import { useContactsStore } from '../stores/contacts-store'; @@ -54,6 +55,13 @@ interface EmailBodyViewProps { * page below the header) rather than a guessed height. */ fill?: boolean; + /** + * Per-message "Show as plain text" from the More sheet; null or undefined + * follows the "Plain Text Only" setting. + */ + plainView?: boolean | null; + /** "Show formatted" in the plain-text note: turn the per-message view off. */ + onShowFormatted?: () => void; } // Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The @@ -521,6 +529,7 @@ const PINCH_ZOOM = ` export default function EmailBodyView({ email, senderEmail, jmapAccountId, onSwipe, onZoomChange, themeOverride, bodyOverride, onSettled, fill, + plainView: plainViewOverride, onShowFormatted, }: EmailBodyViewProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -535,6 +544,7 @@ export default function EmailBodyView({ const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode); const plainTextFont = useSettingsStore((s) => s.plainTextFont); const messageSpacing = useSettingsStore((s) => s.messageSpacing); + const plainTextMode = useSettingsStore((s) => s.plainTextMode); const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails); const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded); const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook); @@ -562,8 +572,19 @@ export default function EmailBodyView({ if (bodyOverride) return { html: bodyOverride.html ?? null, text: bodyOverride.text ?? null }; return pickEmailBody(email); }, [email, bodyOverride]); - const rawHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]); - const text = picked.text; + const formattedHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]); + // HTML mail as plain text: "Plain Text Only" for every message, or the More + // sheet's per-message switch. The message's own text part when it has one, + // else a readable conversion that keeps link targets and image alt text + // (webmail 1e929b0a9). A plain-text view loads nothing remote, so the + // external-content banner goes too. + const plainView = plainViewOverride ?? plainTextMode; + const plainText = React.useMemo( + () => (plainView ? plainTextReading({ html: formattedHtml, text: picked.text }) : null), + [plainView, formattedHtml, picked.text], + ); + const rawHtml = plainText !== null ? null : formattedHtml; + const text = plainText ?? picked.text; // 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 @@ -765,6 +786,18 @@ export default function EmailBodyView({ return ( + {plainText !== null && ( + + + {t('email_viewer.shown_as_plain_text', 'This message is shown as plain text.')} + + {onShowFormatted && ( + + {t('email_viewer.show_formatted', 'Show formatted')} + + )} + + )} {showBanner && ( @@ -895,6 +928,19 @@ function makeStyles(c: ThemePalette) { ...typography.caption, color: c.textSecondary, }, + plainNote: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + columnGap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingTop: spacing.sm, + }, + plainNoteLink: { + ...typography.caption, + color: c.primary, + textDecorationLine: 'underline', + }, webContainer: { width: '100%', backgroundColor: 'transparent', diff --git a/src/components/__tests__/plain-text-reading.test.ts b/src/components/__tests__/plain-text-reading.test.ts new file mode 100644 index 00000000..ac3266ce --- /dev/null +++ b/src/components/__tests__/plain-text-reading.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// "Plain Text Only" reads "Compose and read in plain text only", but only the +// composer honoured it: the reader always showed the HTML. The webmail reads +// HTML mail as plain text by default or per message (1e929b0a9). The +// conversion itself is tested in lib/__tests__/html-to-readable-text.test.ts; +// this checks the reader is wired to it. + +const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', p), 'utf8'); + +describe('plain-text reading', () => { + it('renders the plain-text reading when the setting or the per-message switch is on', () => { + const body = src('components/EmailBodyView.tsx'); + expect(body).toMatch(/useSettingsStore\(\(s\) => s\.plainTextMode\)/); + expect(body).toMatch(/const plainView = plainViewOverride \?\? plainTextMode;/); + expect(body).toMatch(/plainTextReading\(\{ html: formattedHtml, text: picked\.text \}\)/); + // The document is built from the text, not the HTML, while it is on. + expect(body).toMatch(/const rawHtml = plainText !== null \? null : formattedHtml;/); + expect(body).toMatch(/email_viewer\.shown_as_plain_text/); + }); + + it('offers "Show as plain text" per message in the More sheet and passes it to every card', () => { + const screen = src('screens/EmailThreadScreen.tsx'); + expect(screen).toMatch(/email_viewer\.show_as_plain_text/); + expect(screen).toMatch(/plainView=\{plainOverrides\[m\.id\] \?\? null\}/); + expect(src('components/email/MessageContent.tsx')).toMatch(/plainView=\{plainView\}/); + }); +}); diff --git a/src/components/email/MessageContent.tsx b/src/components/email/MessageContent.tsx index 4febfb19..e974306b 100644 --- a/src/components/email/MessageContent.tsx +++ b/src/components/email/MessageContent.tsx @@ -22,6 +22,10 @@ export interface MessageContentProps { /** On screen rather than pre-rendered by the pager (gates auto-sent read receipts). */ active: boolean; themeOverride?: 'light' | 'dark' | null; + /** Per-message "Show as plain text"; null follows the "Plain Text Only" setting. */ + plainView?: boolean | null; + /** Turn the per-message plain-text view off ("Show formatted"). */ + onShowFormatted?: (email: Email) => void; onSwipe?: (direction: 'prev' | 'next') => void; onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void; onToggleStar?: (email: Email) => void; @@ -52,8 +56,8 @@ export interface MessageContentProps { * cards. */ export function MessageContent({ - email, jmapAccountId, identities, currentMailboxRole, active, themeOverride, onSwipe, onZoomChange, - onToggleStar, onAddressPress, onEmailPatched, compact, deferBody, onBodySettled, fill, + email, jmapAccountId, identities, currentMailboxRole, active, themeOverride, plainView, onShowFormatted, + onSwipe, onZoomChange, onToggleStar, onAddressPress, onEmailPatched, compact, deferBody, onBodySettled, fill, }: MessageContentProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -124,6 +128,8 @@ export function MessageContent({ onSwipe={onSwipe} onZoomChange={onZoomChange} themeOverride={themeOverride} + plainView={plainView} + onShowFormatted={onShowFormatted ? () => onShowFormatted(email) : undefined} bodyOverride={unwrap.override} onSettled={onBodySettled} fill={fill} diff --git a/src/lib/__tests__/html-to-readable-text.test.ts b/src/lib/__tests__/html-to-readable-text.test.ts new file mode 100644 index 00000000..e23183ee --- /dev/null +++ b/src/lib/__tests__/html-to-readable-text.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { htmlToReadableText, plainTextReading } from '../html-to-readable-text'; + +// "Plain Text Only" promised plain-text reading, but the reader showed the +// HTML anyway. These are the webmail's cases for its converter +// (lib/__tests__/html-to-readable-text.test.ts) plus the HTML a DOM parser +// fixes up on its own. + +describe('htmlToReadableText', () => { + it('keeps paragraphs and line breaks', () => { + expect(htmlToReadableText('

Hello Ann,

first line
second line

')) + .toBe('Hello Ann,\n\nfirst line\nsecond line'); + }); + + it('keeps link targets and image alt text', () => { + expect(htmlToReadableText('

Read the report Logo

')) + .toBe('Read the report (https://ex.com/a) [Logo]'); + }); + + it('does not repeat a target that is the link text, and drops script and style', () => { + expect(htmlToReadableText('ex.com')) + .toBe('ex.com'); + expect(htmlToReadableText('Write to Ann')) + .toBe('Write to Ann (ann@ex.com)'); + }); + + it('turns layout tables into lines and lists into bullets', () => { + const html = '
NameAnn
CityBerlin
  • one
  • two
'; + expect(htmlToReadableText(html)).toBe('Name Ann\nCity Berlin\n\n• one\n• two'); + }); + + it('skips hidden preheaders and images without alt text', () => { + expect(htmlToReadableText('
preheader

Body

')).toBe('Body'); + }); + + it('reads a whole document: head, entities, comments and unclosed tags', () => { + const html = 'T' + + '

Fish & chips €5

Next deal' + + '

  • one
  • two
'; + expect(htmlToReadableText(html)).toBe('Fish & chips €5\n\nNext deal (https://ex.com/?a=1&b=2)\n\n• one\n• two'); + }); + + it('keeps preformatted text and marks quotes', () => { + expect(htmlToReadableText('
a  b\n  c
')).toBe('a b\n c'); + expect(htmlToReadableText('

On Monday Bob wrote:

hi
')).toBe('On Monday Bob wrote:\n\n> hi'); + }); +}); + +describe('plainTextReading', () => { + it("prefers the message's own text part, else converts the HTML, and leaves plain-text mail alone", () => { + expect(plainTextReading({ html: '

Hi there

', text: 'Hi there (text part)' })).toBe('Hi there (text part)'); + expect(plainTextReading({ html: '

Hi there

', text: null })).toBe('Hi there'); + expect(plainTextReading({ html: '

Hi

', text: ' ' })).toBe('Hi'); + expect(plainTextReading({ html: null, text: 'just text' })).toBeNull(); + }); +}); diff --git a/src/lib/html-to-readable-text.ts b/src/lib/html-to-readable-text.ts new file mode 100644 index 00000000..ec7a63fb --- /dev/null +++ b/src/lib/html-to-readable-text.ts @@ -0,0 +1,246 @@ +/** + * HTML mail as plain text, for "Plain Text Only" reading and the reader's + * per-message "Show as plain text" when a message has no text/plain part of + * its own. Port of the webmail's lib/html-to-readable-text.ts. + * + * Layout tables become lines, headings and paragraphs keep a blank line + * around them, list items get a bullet, images are replaced by their alt text + * and a link keeps its target in brackets after its text, so nothing the + * formatted mail offered is lost. + * + * The webmail walks a DOMParser tree; React Native has no DOM, so this walks + * the tags in order with a stack of open elements and closes the elements an + * HTML parser would close on its own (a paragraph before a block, a list item + * before the next one, table cells and rows). + */ + +import { decodeEntities } from './verification-code'; + +const BLOCK = new Set([ + 'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'CENTER', 'DD', 'DETAILS', 'DIV', 'DL', 'DT', 'FIELDSET', 'FIGCAPTION', + 'FIGURE', 'FOOTER', 'FORM', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HR', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE', + 'SECTION', 'SUMMARY', 'TABLE', 'TBODY', 'TFOOT', 'THEAD', 'TR', 'UL', +]); +/** Blocks that get a blank line before and after, not just a line break. */ +const PARAGRAPH = new Set(['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'PRE', 'UL', 'OL', 'TABLE', 'HR']); +const SKIP = new Set(['HEAD', 'STYLE', 'SCRIPT', 'TEMPLATE', 'NOSCRIPT', 'TITLE', 'META', 'LINK', 'SVG', 'OBJECT', 'IFRAME']); +/** Elements whose content is raw text up to their end tag. */ +const RAW_TEXT = new Set(['SCRIPT', 'STYLE', 'TITLE', 'TEXTAREA', 'XMP', 'NOSCRIPT', 'IFRAME', 'NOEMBED', 'NOFRAMES']); +const VOID = new Set(['AREA', 'BASE', 'BR', 'COL', 'EMBED', 'HR', 'IMG', 'INPUT', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']); + +/** Link targets worth writing out: web, mail and phone links. */ +function shownHref(href: string | null): string | null { + if (!href) return null; + const trimmed = href.trim(); + if (/^mailto:/i.test(trimmed)) return trimmed.slice(7).split('?')[0] || null; + if (/^(https?:|tel:)/i.test(trimmed)) return trimmed; + return null; +} + +function normalize(text: string): string { + return text.replace(/[ \t\f\v\r\n\xa0]+/g, ' '); +} + +/** Same address once with and once without the scheme or a trailing slash. */ +function sameTarget(text: string, href: string): boolean { + const strip = (s: string) => s.trim().toLowerCase().replace(/^(https?:\/\/|mailto:|tel:)/, '').replace(/\/$/, ''); + return strip(text) === strip(href); +} + +const TOKEN = /|$)|]*>?|<\?[^>]*>?|<(\/?)([a-zA-Z][a-zA-Z0-9:-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g; +const ATTR = /([^\s=/>"']+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g; + +function parseAttrs(raw: string): Map { + const attrs = new Map(); + ATTR.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = ATTR.exec(raw)) !== null) { + const name = m[1].toLowerCase(); + if (!attrs.has(name)) attrs.set(name, decodeEntities(m[2] ?? m[3] ?? m[4] ?? '')); + } + return attrs; +} + +interface Frame { + tag: string; + /** Inside a skipped or hidden element: nothing is written. */ + skip: boolean; + pre: boolean; + listDepth: number; + /** out.length when the element opened, for a link's text. */ + start: number; + href: string | null; +} + +/** Open elements an element closes when it starts, as an HTML parser does. */ +function closesOnOpen(open: string, tag: string): boolean { + if (open === 'P') return BLOCK.has(tag); + if (open === 'LI') return tag === 'LI'; + if (open === 'DT' || open === 'DD') return tag === 'DT' || tag === 'DD'; + if (open === 'TD' || open === 'TH') return tag === 'TD' || tag === 'TH' || tag === 'TR'; + if (open === 'TR') return tag === 'TR'; + return false; +} + +/** Elements that stop the search for an element to close on open. */ +const SCOPE = new Set(['UL', 'OL', 'TABLE', 'DL', 'BLOCKQUOTE', 'DIV', 'TD', 'TH']); + +export function htmlToReadableText(html: string): string { + if (!html) return ''; + const out: string[] = []; + // Pending line breaks, so runs of empty blocks collapse to one blank line. + let pendingBreaks = 0; + let atLineStart = true; + + const breakLine = (count: number) => { + if (out.length === 0) return; + pendingBreaks = Math.max(pendingBreaks, count); + }; + const write = (text: string) => { + if (!text) return; + if (pendingBreaks > 0) { + out.push('\n'.repeat(pendingBreaks)); + pendingBreaks = 0; + atLineStart = true; + } + if (atLineStart) text = text.replace(/^ +/, ''); + if (!text) return; + out.push(text); + atLineStart = text.endsWith('\n'); + }; + + const stack: Frame[] = []; + const top = (): Frame | undefined => stack[stack.length - 1]; + + const close = (frame: Frame) => { + if (frame.skip) return; + const { tag } = frame; + if (tag === 'A') { + const text = out.slice(frame.start).join('').trim(); + if (frame.href && !sameTarget(text, frame.href)) write(text ? ` (${frame.href})` : frame.href); + } + if (BLOCK.has(tag)) { + const gap = PARAGRAPH.has(tag) && !(tag === 'UL' || tag === 'OL') ? 2 : 1; + breakLine(tag === 'LI' ? 1 : gap); + } + }; + + const open = (tag: string, attrs: Map) => { + // Close what this element implicitly ends (a

before a block, the + // previous

  • , table cells and rows). + for (let i = stack.length - 1; i >= 0; i--) { + const frameTag = stack[i].tag; + if (closesOnOpen(frameTag, tag)) { + while (stack.length > i) close(stack.pop()!); + break; + } + if (SCOPE.has(frameTag)) break; + } + + const parent = top(); + const inherited = parent?.skip ?? false; + const hidden = attrs.has('hidden') || /display\s*:\s*none/i.test(attrs.get('style') ?? ''); + const skip = inherited || SKIP.has(tag) || hidden; + const pre = parent?.pre ?? false; + const listDepth = parent?.listDepth ?? 0; + + if (VOID.has(tag)) { + if (skip) return; + if (tag === 'BR') { + out.push('\n'); + pendingBreaks = 0; + atLineStart = true; + } else if (tag === 'IMG' || (tag === 'INPUT' && attrs.get('type') === 'image')) { + const alt = normalize(attrs.get('alt') ?? '').trim(); + if (alt) write(`[${alt}]`); + } else if (tag === 'HR') { + breakLine(2); + write('---'); + breakLine(2); + } + return; + } + + if (!skip) { + const isBlock = BLOCK.has(tag); + const gap = PARAGRAPH.has(tag) && !(tag === 'UL' || tag === 'OL') ? 2 : 1; + if (isBlock) breakLine(listDepth > 0 && (tag === 'UL' || tag === 'OL') ? 1 : gap); + if (tag === 'LI') write(`${' '.repeat(Math.max(0, listDepth - 1))}• `); + if (tag === 'TD' || tag === 'TH') { + if (!atLineStart && pendingBreaks === 0) write(' '); + } + if (tag === 'BLOCKQUOTE') write('> '); + } + stack.push({ + tag, + skip, + pre: pre || tag === 'PRE', + listDepth: tag === 'UL' || tag === 'OL' ? listDepth + 1 : listDepth, + start: out.length, + href: tag === 'A' ? shownHref(attrs.get('href') ?? null) : null, + }); + }; + + const end = (tag: string) => { + let i = stack.length - 1; + while (i >= 0 && stack[i].tag !== tag) i--; + if (i < 0) return; // a stray end tag + while (stack.length > i) close(stack.pop()!); + }; + + const text = (raw: string) => { + const frame = top(); + if (frame?.skip || !raw) return; + const value = decodeEntities(raw); + write(frame?.pre ? value : normalize(value)); + }; + + TOKEN.lastIndex = 0; + let lower: string | undefined; + let pos = 0; + let m: RegExpExecArray | null; + while ((m = TOKEN.exec(html)) !== null) { + if (m.index > pos) text(html.slice(pos, m.index)); + pos = TOKEN.lastIndex; + if (m[2] === undefined) continue; // comment, doctype, processing instruction + const tag = m[2].toUpperCase(); + if (m[1]) { + end(tag); + continue; + } + open(tag, parseAttrs(m[3])); + const selfClosing = /\/\s*$/.test(m[3]); + if (selfClosing && !VOID.has(tag)) end(tag); + if (RAW_TEXT.has(tag) && !selfClosing) { + // Raw text runs to the end tag: a "<" inside a script is not a tag. + lower ??= html.toLowerCase(); + const endAt = lower.indexOf(` 0) close(stack.pop()!); + + return out + .join('') + .split('\n') + .map((line) => line.replace(/[ \t]+$/g, '')) + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** + * The text the reader shows for a message read as plain text: its own + * text/plain part when it has one, else its HTML turned into text (webmail + * email-viewer.tsx, 1e929b0a9). Null when there is no HTML to replace: a + * plain-text message reads as it is. + */ +export function plainTextReading(body: { html: string | null; text: string | null }): string | null { + if (!body.html) return null; + if (body.text?.trim()) return body.text; + return htmlToReadableText(body.html); +} diff --git a/src/lib/verification-code.ts b/src/lib/verification-code.ts index c6a76ca7..0946a600 100644 --- a/src/lib/verification-code.ts +++ b/src/lib/verification-code.ts @@ -363,7 +363,7 @@ const LETTER_ENTITIES: Record = { upsih: 'ϒ', upsilon: 'υ', uuml: 'ü', xi: 'ξ', yacute: 'ý', yuml: 'ÿ', zeta: 'ζ', }; -function decodeEntities(text: string): string { +export function decodeEntities(text: string): string { return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (m, ref: string) => { if (ref[0] === '#') { const cp = ref[1] === 'x' || ref[1] === 'X' ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10); diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 1c4b466f..efb7a9ab 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -11,8 +11,9 @@ import { ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward, ChevronLeft, ChevronRight, Archive, Mail, MailOpen, FolderInput, ShieldAlert, ShieldCheck, X, Check, - Code, Download, Tag, Sun, Moon, FileInput, UserRoundPlus, Filter, + Code, Download, Tag, Sun, Moon, FileInput, UserRoundPlus, Filter, FileText, Type, } from '../components/icons'; +import { pickEmailBody } from '../lib/email-body'; import { QuickRuleSheet } from '../components/filters/QuickRuleSheet'; import { isSieveSupported } from '../api/sieve'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens'; @@ -177,6 +178,13 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr const [addressSheet, setAddressSheet] = React.useState(null); // Per-message override of the light/dark rendering (More sheet toggle). const [themeOverrides, setThemeOverrides] = React.useState>({}); + // Per-message "Show as plain text" (More sheet); unset follows "Plain Text Only". + const [plainOverrides, setPlainOverrides] = React.useState>({}); + const plainTextMode = useSettingsStore((s) => s.plainTextMode); + const showFormatted = React.useCallback( + (m: Email) => setPlainOverrides((prev) => ({ ...prev, [m.id]: false })), + [], + ); // Message details and conversations come from the shared cache // (lib/email-detail-cache): every rendered pane, the active one and its @@ -693,6 +701,10 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr const showMarkUnread = windowWidth >= 340; const showArchive = windowWidth >= 400 && canArchive; + // The active message read as plain text, and whether it has HTML to switch from. + const activePlainView = email ? plainOverrides[email.id] ?? plainTextMode : false; + const activeHasHtml = !!email && !!pickEmailBody(email).html; + // Current rendering mode of the active message, for the More sheet label. const activeRenderDark = email ? (themeOverrides[email.id] ? themeOverrides[email.id] === 'dark' : !emailAlwaysLightMode && resolvedTheme === 'dark') @@ -817,6 +829,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr currentMailboxRole={currentMailboxRole} identities={identities} themeOverrides={themeOverrides} + plainOverrides={plainOverrides} + onShowFormatted={showFormatted} ensureDetail={ensureDetail} ensureDetails={ensureDetails} ensureThread={ensureThread} @@ -880,6 +894,12 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr canExport={!!email?.blobId} canTag={keywordDefs.length > 0} renderDark={activeRenderDark} + plainView={activeHasHtml ? activePlainView : null} + onTogglePlainView={() => { + setMoreMenuOpen(false); + if (!email) return; + setPlainOverrides((prev) => ({ ...prev, [email.id]: !activePlainView })); + }} hasSender={!!email?.from?.[0]?.email} onArchive={() => { setMoreMenuOpen(false); onArchive(); }} onToggleUnread={() => { setMoreMenuOpen(false); onToggleUnread(); }} @@ -971,6 +991,8 @@ interface EmailPaneProps { currentMailboxRole: string | null; identities: Identity[]; themeOverrides: Record; + plainOverrides: Record; + onShowFormatted: (email: Email) => void; ensureDetail: (id: string) => Promise; ensureDetails: (ids: string[]) => void; ensureThread: (threadId: string) => void; @@ -993,7 +1015,8 @@ interface EmailPaneProps { // members' headers; bodies are only downloaded for the cards that are open. function EmailPane({ id, active, bodyEnabled, onBodySettled, threadIdHint, email, row, threadIds, memberOf, threadSizeHint, - threading, jmapAccountId, currentMailboxRole, identities, themeOverrides, ensureDetail, ensureDetails, + threading, jmapAccountId, currentMailboxRole, identities, themeOverrides, plainOverrides, onShowFormatted, + ensureDetail, ensureDetails, ensureThread, scheduleMarkRead, styles, bottomBarHeight, kbObstruction, onToggleStar, onAddressPress, onEmailPatched, onReply, onSwipe, onZoomChange, }: EmailPaneProps) { @@ -1219,6 +1242,8 @@ function EmailPane({ currentMailboxRole={currentMailboxRole} active={active} themeOverride={themeOverrides[m.id] ?? null} + plainView={plainOverrides[m.id] ?? null} + onShowFormatted={onShowFormatted} onSwipe={onSwipe} onZoomChange={onPaneZoom} onToggleStar={onToggleStar} @@ -1307,6 +1332,9 @@ interface MoreMenuSheetProps { canViewSource: boolean; canExport: boolean; renderDark: boolean; + /** The active message read as plain text; null when it has no HTML to switch from. */ + plainView: boolean | null; + onTogglePlainView: () => void; hasSender: boolean; onArchive: () => void; onToggleUnread: () => void; @@ -1324,7 +1352,7 @@ interface MoreMenuSheetProps { function MoreMenuSheet({ visible, onClose, unread, canArchive, canMarkUnread, canMove, canTag, - showSpam, isInJunk, canViewSource, canExport, renderDark, hasSender, + showSpam, isInJunk, canViewSource, canExport, renderDark, plainView, onTogglePlainView, hasSender, onArchive, onToggleUnread, onMove, onTag, onToggleSpam, onToggleTheme, onSenderActions, onForwardAsAttachment, onRules, onViewSource, onExport, }: MoreMenuSheetProps) { @@ -1430,6 +1458,13 @@ function MoreMenuSheet({ onPress={onToggleSpam} /> )} + {plainView !== null && ( + : } + label={plainView ? t('email_viewer.show_formatted', 'Show formatted') : t('email_viewer.show_as_plain_text', 'Show as plain text')} + onPress={onTogglePlainView} + /> + )} : } label={renderDark ? t('email_viewer.view_light_mode', 'View in light mode') : t('email_viewer.view_dark_mode', 'View in dark mode')} From 7b551fc3e0cc06e17bbc8e187adf1472f6877ad6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:51:04 +0100 Subject: [PATCH 07/37] feat: scale the mail body with the app text size and the system font size --- src/components/EmailBodyView.tsx | 20 +++++-- src/lib/__tests__/mail-reading.test.ts | 65 ++++++++++++++++++++++ src/lib/email-body-document.ts | 10 +++- src/lib/email-html.ts | 6 +- src/lib/mail-reading.ts | 76 ++++++++++++++++++++++++++ 5 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 src/lib/__tests__/mail-reading.test.ts create mode 100644 src/lib/mail-reading.ts diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx index 688a61b8..1082e9d9 100644 --- a/src/components/EmailBodyView.tsx +++ b/src/components/EmailBodyView.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, StyleSheet, Linking, Platform, Text, Pressable, useWindowDimensions } from 'react-native'; +import { View, StyleSheet, Linking, Platform, Text, Pressable, PixelRatio, useWindowDimensions } from 'react-native'; import { WebView, type WebViewMessageEvent } from 'react-native-webview'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -15,11 +15,13 @@ import { fetchInlineImageDataUri } from '../lib/email-export'; import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders'; import { senderVerificationOf } from '../lib/email-headers'; import { plainTextReading } from '../lib/html-to-readable-text'; +import { buildMailTextSizeScript, mailTextFactor } from '../lib/mail-reading'; import { useHasContacts } from '../lib/capabilities'; import { useSettingsStore } from '../stores/settings-store'; import { useContactsStore } from '../stores/contacts-store'; import { useLocaleStore } from '../stores/locale-store'; import { spacing, typography, type ThemePalette } from '../theme/tokens'; +import { useTypography } from '../theme/dynamic'; import { useColors, useResolvedTheme } from '../theme/colors'; import { perfEndAfterPaint, perfLap } from '../lib/perf-marks'; import type { RootStackParamList } from '../navigation/types'; @@ -545,6 +547,11 @@ export default function EmailBodyView({ const plainTextFont = useSettingsStore((s) => s.plainTextFont); const messageSpacing = useSettingsStore((s) => s.messageSpacing); const plainTextMode = useSettingsStore((s) => s.plainTextMode); + // Body text follows the app's text size and the system font scale (webmail + // c07ba1a37); it was fixed at 14 px. The app's factor is read off its + // scaled body style, so it follows whatever steps the text size setting has. + const dyn = useTypography(); + const textFactor = mailTextFactor(dyn.body.fontSize / typography.body.fontSize, PixelRatio.getFontScale()); const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails); const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded); const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook); @@ -692,9 +699,10 @@ export default function EmailBodyView({ messageSpacing, plainTextFont, quoteLabels, + textFactor, }), [ jmapAccountId, email.id, rawHtml, text, email.preview, emptyLabel, shouldBlock, cidMap, renderAsDark, - messageSpacing, plainTextFont, quoteLabels, + messageSpacing, plainTextFont, quoteLabels, textFactor, ]); const source = React.useMemo(() => ({ html: prepared.html }), [prepared.html]); @@ -707,11 +715,12 @@ export default function EmailBodyView({ // input the CSS was built from, so the DOM pass and the stylesheet agree. const injectedJs = React.useMemo( () => - (prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '') + (prepared.isHtml ? buildMailTextSizeScript(textFactor) : '') + + (prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '') + (prepared.applyInversion ? DARK_REINVERT_SCRIPT : '') + (prepared.isHtml ? IMG_FIXUP_SCRIPT : '') + HEIGHT_REPORTER + SWIPE_DETECTOR + PINCH_ZOOM, - [prepared.isHtml, prepared.applyInversion, quoteLabels], + [prepared.isHtml, prepared.applyInversion, quoteLabels, textFactor], ); // Performance harness: how much of opening a message is the WebView. @@ -854,6 +863,9 @@ export default function EmailBodyView({ // Native Android zoom would scale the viewport inside the fixed-size // container; pinch zoom is implemented in the page instead. setBuiltInZoomControls={false} + // The system font size is part of textFactor; Android's WebView + // would scale the text by it a second time. + textZoom={100} showsVerticalScrollIndicator={false} showsHorizontalScrollIndicator={false} setSupportMultipleWindows={false} diff --git a/src/lib/__tests__/mail-reading.test.ts b/src/lib/__tests__/mail-reading.test.ts new file mode 100644 index 00000000..c7b187ab --- /dev/null +++ b/src/lib/__tests__/mail-reading.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { buildMailTextSizeScript, mailBaseFontPx, mailTextFactor } from '../mail-reading'; +import { buildBodyDocument } from '../email-body-document'; + +// The mail body was fixed at 14 px whatever the app's text size or the +// system font size said (webmail c07ba1a37 scales it). + +const base = { + key: 'k', emptyLabel: '-', blockRemoteImages: false, cidMap: {}, isDark: false, + messageSpacing: 'normal' as const, plainTextFont: 'sans' as const, quoteLabels: { show: 's', hide: 'h' }, +}; + +describe('mail text size', () => { + it('multiplies the app text size by the system font scale, within bounds', () => { + expect(mailTextFactor(1, 1)).toBe(1); + expect(mailTextFactor(1.14, 1.3)).toBe(1.48); + expect(mailTextFactor(1, 0)).toBe(1); + expect(mailTextFactor(2, 3)).toBe(3); + expect(mailBaseFontPx(1.5)).toBe(21); + }); + + it('sizes a plain-text body by the factor', () => { + expect(buildBodyDocument({ ...base, rawHtml: null, text: 'hello' }).html).toMatch(/font-size: 14px;/); + expect(buildBodyDocument({ ...base, rawHtml: null, text: 'hello', textFactor: 1.5 }).html).toMatch(/font-size: 21px;/); + }); + + it('scales every element of an HTML body once, line heights in px included', () => { + expect(buildMailTextSizeScript(1)).toBe(''); + const writes: Array<[string, string, string]> = []; + const attrs = new Set(); + const el = (tag: string, fontSize: string, lineHeight: string, inSvg = false) => ({ + tagName: tag, + computed: { fontSize, lineHeight }, + closest: (sel: string) => (sel === 'svg' && inSvg ? {} : null), + style: { setProperty: (prop: string, value: string) => writes.push([tag, prop, value]) }, + }); + const kids = [el('P', '16px', '24px'), el('SPAN', '12px', 'normal'), el('IMG', '14px', 'normal'), el('TEXT', '10px', 'normal', true)]; + const body = { + ...el('BODY', '14px', '22.4px'), + hasAttribute: (a: string) => attrs.has(a), + setAttribute: (a: string) => attrs.add(a), + querySelectorAll: () => kids, + }; + const window = { getComputedStyle: (e: { computed: unknown }) => e.computed }; + const run = () => new Function('document', 'window', buildMailTextSizeScript(1.5))({ body }, window); + run(); + expect(writes).toEqual([ + ['BODY', 'font-size', '21px'], ['BODY', 'line-height', '33.6px'], + ['P', 'font-size', '24px'], ['P', 'line-height', '36px'], + ['SPAN', 'font-size', '18px'], + ]); + // The body view injects its scripts again after load: no second pass. + run(); + expect(writes).toHaveLength(5); + }); + + it('is wired into the body view, with Android text zoom held at 100', () => { + const view = fs.readFileSync(path.resolve(__dirname, '../../components/EmailBodyView.tsx'), 'utf8'); + expect(view).toMatch(/mailTextFactor\(dyn\.body\.fontSize \/ typography\.body\.fontSize, PixelRatio\.getFontScale\(\)\)/); + expect(view).toMatch(/buildMailTextSizeScript\(textFactor\)/); + expect(view).toMatch(/textZoom=\{100\}/); + }); +}); diff --git a/src/lib/email-body-document.ts b/src/lib/email-body-document.ts index 995ab98f..8413c671 100644 --- a/src/lib/email-body-document.ts +++ b/src/lib/email-body-document.ts @@ -9,6 +9,7 @@ import { type MessageSpacing, type PlainTextFont, } from './email-html'; import { collapsePlainTextQuotes, type QuoteCollapseLabels } from './quote-collapse'; +import { mailBaseFontPx } from './mail-reading'; export interface BodyDocumentInput { /** The message (account and id); the sources below are compared as well. */ @@ -25,6 +26,11 @@ export interface BodyDocumentInput { messageSpacing: MessageSpacing; plainTextFont: PlainTextFont; quoteLabels: QuoteCollapseLabels; + /** + * Factor for the text size (mailTextFactor): the base size of a plain-text + * body. An HTML body is scaled by an injected pass instead. Default 1. + */ + textFactor?: number; } export interface BodyDocument { @@ -55,7 +61,7 @@ export function buildBodyDocument(input: BodyDocumentInput): BodyDocument { } const safe = collapsePlainTextQuotes(plainTextToSafeHtml(text), input.quoteLabels); return { - html: wrapPlainTextEmail(safe, { isDark, font: input.plainTextFont }), + html: wrapPlainTextEmail(safe, { isDark, font: input.plainTextFont, fontPx: mailBaseFontPx(input.textFactor ?? 1) }), applyInversion: false, hasNativeDark: false, blockedExternal: false, @@ -83,7 +89,7 @@ function sameSources(a: BodyDocumentInput, b: BodyDocumentInput): boolean { export function bodyDocument(input: BodyDocumentInput): BodyDocument { const key = JSON.stringify([ input.key, input.blockRemoteImages, input.isDark, input.messageSpacing, input.plainTextFont, - input.quoteLabels.show, input.quoteLabels.hide, input.emptyLabel, + input.quoteLabels.show, input.quoteLabels.hide, input.emptyLabel, input.textFactor ?? 1, ]); const hit = documents.get(key); if (hit && sameSources(hit.input, input)) { diff --git a/src/lib/email-html.ts b/src/lib/email-html.ts index 032f19c7..39a8577f 100644 --- a/src/lib/email-html.ts +++ b/src/lib/email-html.ts @@ -955,9 +955,9 @@ export type PlainTextFont = 'sans' | 'mono'; // (no filter inversion), and a dark-mode-appropriate link colour. export function wrapPlainTextEmail( innerHtml: string, - options: { isDark?: boolean; font?: PlainTextFont } = {}, + options: { isDark?: boolean; font?: PlainTextFont; fontPx?: number } = {}, ): string { - const { isDark = true, font = 'sans' } = options; + const { isDark = true, font = 'sans', fontPx = 14 } = options; const cleaned = stripDangerousTags(innerHtml); const bg = isDark ? '#0a0a0a' : '#ffffff'; @@ -976,7 +976,7 @@ body { margin: 0; padding: 16px; font-family: ${fontFamily}; - font-size: 14px; + font-size: ${fontPx}px; line-height: 1.6; color: ${fg}; white-space: pre-wrap; diff --git a/src/lib/mail-reading.ts b/src/lib/mail-reading.ts new file mode 100644 index 00000000..25db050c --- /dev/null +++ b/src/lib/mail-reading.ts @@ -0,0 +1,76 @@ +/** + * How the app's text size reaches a message body (webmail lib/mail-reading.ts, + * c07ba1a37). The body renders in a WebView the app's styles don't reach, so + * the size is carried in: as the base font size of a plain-text body, and as + * a pass over the computed styles of an HTML body once it has laid out. + */ + +/** Size of body text in a message, in CSS px at 100 %: what mail used to be fixed at. */ +export const MAIL_BASE_FONT_PX = 14; + +const MIN_FACTOR = 0.5; +const MAX_FACTOR = 3; + +/** + * The factor message text is drawn at: the app's text size times the + * system font scale (Android font size, iOS Dynamic Type). Android's WebView + * would apply the system scale on its own; the body view turns that off + * (`textZoom` 100) so both platforms scale the same way, once. + */ +export function mailTextFactor(appFactor: number, systemFontScale: number): number { + const app = appFactor > 0 ? appFactor : 1; + const system = systemFontScale > 0 ? systemFontScale : 1; + const factor = Math.round(app * system * 100) / 100; + return Math.min(MAX_FACTOR, Math.max(MIN_FACTOR, factor)); +} + +/** The base font size of a body at `factor`, in CSS px. */ +export function mailBaseFontPx(factor: number): number { + return Math.round(MAIL_BASE_FONT_PX * factor * 100) / 100; +} + +const DONE_ATTR = 'data-bulwark-text-size'; + +/** + * Script injected into an HTML body that scales its text by `factor`: reads + * every element's computed font size first and writes the scaled sizes after, + * pinning each element to its own px size so a child sized in em doesn't grow + * twice. A line height in px grows by the same ratio. Runs once per document + * (the body view injects its scripts again after load). Text size works on + * the text rather than zooming the whole mail, so a fixed-width newsletter + * keeps its width and its text wraps inside it. Empty at factor 1. + */ +export function buildMailTextSizeScript(factor: number): string { + if (!(factor > 0) || Math.abs(factor - 1) < 0.005) return ''; + return ` +(function () { + var F = ${JSON.stringify(factor)}; + var body = document.body; + if (!body || body.hasAttribute('${DONE_ATTR}')) return; + body.setAttribute('${DONE_ATTR}', ''); + var NO_TEXT = { IMG: 1, BR: 1, HR: 1, WBR: 1, PICTURE: 1, SOURCE: 1, VIDEO: 1, AUDIO: 1, CANVAS: 1, IFRAME: 1, OBJECT: 1, EMBED: 1, STYLE: 1, SCRIPT: 1, TEMPLATE: 1 }; + var els = [body]; + var all = body.querySelectorAll('*'); + for (var i = 0; i < all.length; i++) { + var el = all[i]; + if (NO_TEXT[el.tagName] || el.closest('svg')) continue; + els.push(el); + } + var sizes = []; + for (var j = 0; j < els.length; j++) { + var cs = window.getComputedStyle(els[j]); + var font = parseFloat(cs.fontSize); + if (!(font > 0)) continue; + var lh = /px$/.test(cs.lineHeight) ? parseFloat(cs.lineHeight) : null; + sizes.push([els[j], font * F, lh === null ? null : lh * F]); + } + for (var k = 0; k < sizes.length; k++) { + var s = sizes[k]; + s[0].style.setProperty('font-size', Math.round(s[1] * 100) / 100 + 'px', 'important'); + if (s[2] !== null) s[0].style.setProperty('line-height', Math.round(s[2] * 100) / 100 + 'px', 'important'); + } + if (window.__rnReport) setTimeout(window.__rnReport, 0); +})(); +true; +`; +} From a3650ebf34830d5646df7134e9c92904955e9676 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:54:33 +0100 Subject: [PATCH 08/37] test: give the mail text size test a valid message spacing --- src/lib/__tests__/mail-reading.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/__tests__/mail-reading.test.ts b/src/lib/__tests__/mail-reading.test.ts index c7b187ab..3d051a01 100644 --- a/src/lib/__tests__/mail-reading.test.ts +++ b/src/lib/__tests__/mail-reading.test.ts @@ -9,7 +9,7 @@ import { buildBodyDocument } from '../email-body-document'; const base = { key: 'k', emptyLabel: '-', blockRemoteImages: false, cidMap: {}, isDark: false, - messageSpacing: 'normal' as const, plainTextFont: 'sans' as const, quoteLabels: { show: 's', hide: 'h' }, + messageSpacing: 'auto' as const, plainTextFont: 'sans' as const, quoteLabels: { show: 's', hide: 'h' }, }; describe('mail text size', () => { From 9794dc03f77cbc0d0fe2c36b8a6421eb7b3703b8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:54:33 +0100 Subject: [PATCH 09/37] feat: ask before deleting or archiving when the confirm settings are on --- .../__tests__/mail-removal-confirm.test.ts | 64 +++++++++++++++++++ src/lib/mail-removal-confirm.ts | 56 ++++++++++++++++ src/screens/EmailListScreen.tsx | 20 ++++-- src/screens/EmailThreadScreen.tsx | 13 ++-- src/screens/UnifiedInboxScreen.tsx | 21 ++++-- 5 files changed, 160 insertions(+), 14 deletions(-) create mode 100644 src/lib/__tests__/mail-removal-confirm.test.ts create mode 100644 src/lib/mail-removal-confirm.ts diff --git a/src/lib/__tests__/mail-removal-confirm.test.ts b/src/lib/__tests__/mail-removal-confirm.test.ts new file mode 100644 index 00000000..45859c81 --- /dev/null +++ b/src/lib/__tests__/mail-removal-confirm.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useSettingsStore } from '../../stores/settings-store'; +import { confirmMailRemoval, type AskFn } from '../mail-removal-confirm'; + +// "Confirm before deleting" and "Confirm before archiving" existed as +// settings but nothing asked: delete and archive went ahead from the open +// message, the selection bar and swipes (webmail lib/mail-removal-confirm.ts). + +const t = (key: string, fallback?: string, params?: Record) => + `${key}${params?.count !== undefined ? `:${params.count}` : ''}${fallback ? '' : ''}`; + +describe('confirmMailRemoval', () => { + beforeEach(async () => { + await AsyncStorage.clear(); + useSettingsStore.getState().resetToDefaults(); + }); + + it('goes ahead without asking while the settings are off', async () => { + const ask = vi.fn(async () => false); + expect(await confirmMailRemoval('delete', 2, t, ask)).toBe(true); + expect(await confirmMailRemoval('archive', 2, t, ask)).toBe(true); + expect(ask).not.toHaveBeenCalled(); + }); + + it('asks before a delete when "Confirm before deleting" is on, and stops on no', async () => { + useSettingsStore.setState({ confirmDelete: true }); + const no = vi.fn(async () => false); + expect(await confirmMailRemoval('delete', 3, t, no)).toBe(false); + expect(no).toHaveBeenCalledWith(expect.objectContaining({ + title: 'email_list.confirm_remove.delete_title:3', destructive: true, + })); + expect(await confirmMailRemoval('delete', 3, t, async () => true)).toBe(true); + // Archiving has its own switch. + expect(await confirmMailRemoval('archive', 3, t, no)).toBe(true); + expect(no).toHaveBeenCalledTimes(1); + }); + + it('asks before an archive when "Confirm before archiving" is on', async () => { + useSettingsStore.setState({ confirmArchive: true }); + const ask = vi.fn(async () => false); + expect(await confirmMailRemoval('archive', 1, t, ask)).toBe(false); + expect(ask).toHaveBeenCalledWith(expect.objectContaining({ title: 'email_list.confirm_remove.archive_title:1', destructive: false })); + expect(await confirmMailRemoval('archive', 0, t, ask)).toBe(true); + }); + + it('is asked from the open message, the selection bar and swipes, but not on top of the permanent delete question', () => { + const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '../..', p), 'utf8'); + const thread = src('screens/EmailThreadScreen.tsx'); + expect(thread).toMatch(/confirmMailRemoval\('delete', 1, t\)/); + expect(thread).toMatch(/confirmMailRemoval\('archive', 1, t\)/); + const list = src('screens/EmailListScreen.tsx'); + // deleteIds (swipe) and the selection bar's delete, each in the + // non-permanent branch only. + expect(list.match(/\} else if \(!\(await confirmMailRemoval\('delete', ids\.length, t\)\)\) \{/g)).toHaveLength(2); + expect(list).toMatch(/confirmMailRemoval\('archive', ids\.length, t\)/); + expect(list).toMatch(/confirmMailRemoval\('archive', selectedMessageIds\.length, t\)/); + const unified = src('screens/UnifiedInboxScreen.tsx'); + expect(unified).toMatch(/confirmMailRemoval\('archive', targets\.length, t\)/); + expect(unified).toMatch(/confirmMailRemoval\('delete', targets\.length, t\)/); + }); +}); diff --git a/src/lib/mail-removal-confirm.ts b/src/lib/mail-removal-confirm.ts new file mode 100644 index 00000000..f1f795f3 --- /dev/null +++ b/src/lib/mail-removal-confirm.ts @@ -0,0 +1,56 @@ +import { Alert } from 'react-native'; +import { useSettingsStore } from '../stores/settings-store'; + +type Translate = (key: string, fallback?: string, params?: Record) => string; + +/** Shows the question; resolves true when the user went ahead. */ +export type AskFn = (question: { title: string; message: string; confirmText: string; destructive: boolean; cancelText: string }) => Promise; + +const alertAsk: AskFn = ({ title, message, confirmText, destructive, cancelText }) => new Promise((resolve) => { + Alert.alert( + title, + message, + [ + { text: cancelText, style: 'cancel', onPress: () => resolve(false) }, + { text: confirmText, style: destructive ? 'destructive' : 'default', onPress: () => resolve(true) }, + ], + { cancelable: true, onDismiss: () => resolve(false) }, + ); +}); + +/** + * Asks before messages are deleted or archived when the user turned on + * "Confirm before deleting" or "Confirm before archiving" (webmail + * lib/mail-removal-confirm.ts). Applies to the open message, the selection + * bar and swipes. Resolves true when the action may go ahead: the setting is + * off, or the user confirmed. + * + * A permanent delete has its own, stronger question; ask this one only when + * no other confirmation is shown, so nobody is asked twice. + */ +export async function confirmMailRemoval( + kind: 'delete' | 'archive', + count: number, + t: Translate, + ask: AskFn = alertAsk, +): Promise { + const settings = useSettingsStore.getState(); + if (!(kind === 'delete' ? settings.confirmDelete : settings.confirmArchive)) return true; + if (count <= 0) return true; + const params = { count }; + return kind === 'delete' + ? ask({ + title: t('email_list.confirm_remove.delete_title', '{count, plural, one {Delete this email?} other {Delete # emails?}}', params), + message: t('email_list.confirm_remove.delete_message', '{count, plural, one {It moves to the trash.} other {They move to the trash.}}', params), + confirmText: t('email_list.confirm_remove.delete', 'Delete', params), + cancelText: t('common.cancel', 'Cancel'), + destructive: true, + }) + : ask({ + title: t('email_list.confirm_remove.archive_title', '{count, plural, one {Archive this email?} other {Archive # emails?}}', params), + message: t('email_list.confirm_remove.archive_message', '{count, plural, one {It moves to your archive.} other {They move to your archive.}}', params), + confirmText: t('email_list.confirm_remove.archive', 'Archive', params), + cancelText: t('common.cancel', 'Cancel'), + destructive: false, + }); +} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index b9308448..5bb19fd6 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -56,6 +56,7 @@ import { collapseThreads, groupByThread, expandThreadSelection, getThreadTagIds, threadKeyOf, accountScopedId, rowKeyOf, } from '../lib/thread-utils'; import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm'; +import { confirmMailRemoval } from '../lib/mail-removal-confirm'; import { draftContextFromEmail, isDraftEmail } from '../lib/draft-context'; import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email'; import type { RootStackParamList } from '../navigation/types'; @@ -725,7 +726,11 @@ export default function EmailListScreen({ deleteAction, permanentlyDeleteJunk, }); - if (permanent && !(await confirmPermanentDelete(ids.length, t))) return; + if (permanent) { + if (!(await confirmPermanentDelete(ids.length, t))) return; + } else if (!(await confirmMailRemoval('delete', ids.length, t))) { + return; + } if (ids.length === 1) await deleteEmailAction(ids[0], trashMailboxId, currentMailboxId); else await deleteEmailsBatch(ids, trashMailboxId, currentMailboxId); }, [currentMailboxId, trashMailboxId, inJunk, deleteAction, permanentlyDeleteJunk, deleteEmailAction, deleteEmailsBatch, t]); @@ -738,8 +743,10 @@ export default function EmailListScreen({ case 'archive': if (archiveMailboxId && currentMailboxId !== archiveMailboxId) { const ids = idsForRow(id); - if (ids.length > 1) reportFailure(archiveEmailsBatch(ids)); - else reportFailure(archiveEmailAction(id)); + reportFailure(confirmMailRemoval('archive', ids.length, t).then((ok) => { + if (!ok) return undefined; + return ids.length > 1 ? archiveEmailsBatch(ids) : archiveEmailAction(id); + })); } else if (!archiveMailboxId) { Alert.alert( t('email_list.error', 'Error'), @@ -904,12 +911,17 @@ export default function EmailListScreen({ deleteAction, permanentlyDeleteJunk, }); - if (permanent && !(await confirmPermanentDelete(ids.length, t))) return; + if (permanent) { + if (!(await confirmPermanentDelete(ids.length, t))) return; + } else if (!(await confirmMailRemoval('delete', ids.length, t))) { + return; + } await deleteEmailsBatch(ids, trash.id, currentMailboxId); clearSelection(); }; const handleBulkArchive = async () => { + if (!(await confirmMailRemoval('archive', selectedMessageIds.length, t))) return; await archiveEmailsBatch(selectedMessageIds); clearSelection(); }; diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index efb7a9ab..80e77f70 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -14,6 +14,7 @@ import { Code, Download, Tag, Sun, Moon, FileInput, UserRoundPlus, Filter, FileText, Type, } from '../components/icons'; import { pickEmailBody } from '../lib/email-body'; +import { confirmMailRemoval } from '../lib/mail-removal-confirm'; import { QuickRuleSheet } from '../components/filters/QuickRuleSheet'; import { isSieveSupported } from '../api/sieve'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens'; @@ -545,14 +546,18 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr ); return; } - performDelete(); + // "Confirm before deleting" (webmail lib/mail-removal-confirm.ts). + void confirmMailRemoval('delete', 1, t).then((ok) => { if (ok) performDelete(); }); }; const onArchive = () => { if (!email || !canArchive) return; - archiveEmailAction(email.id, { email, accountId: ownerAccountId }) - .catch((err) => toastFailure(t('notifications.error_archiving', 'Failed to archive email'), err)); - navigation.goBack(); + void confirmMailRemoval('archive', 1, t).then((ok) => { + if (!ok) return; + archiveEmailAction(email.id, { email, accountId: ownerAccountId }) + .catch((err) => toastFailure(t('notifications.error_archiving', 'Failed to archive email'), err)); + navigation.goBack(); + }); }; // The same store path as the list and swipe actions: files into the diff --git a/src/screens/UnifiedInboxScreen.tsx b/src/screens/UnifiedInboxScreen.tsx index 94470533..a912c350 100644 --- a/src/screens/UnifiedInboxScreen.tsx +++ b/src/screens/UnifiedInboxScreen.tsx @@ -25,6 +25,7 @@ import { formatListDate } from '../lib/date-format'; import { singleLine } from '../lib/single-line'; import { prefetchMessage, rememberRows } from '../lib/email-detail-cache'; import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm'; +import { confirmMailRemoval } from '../lib/mail-removal-confirm'; import { onStateChangeType } from '../lib/state-change-bus'; import { describeSenderVerification, senderWarningOf } from '../lib/email-headers'; import { createUnifiedReloadTracker } from '../lib/unified-reload'; @@ -244,9 +245,12 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) { patchRows(targets, { $flagged: starred ? true : null }); patchUnifiedKeywords(targets, { $flagged: starred ? true : null }).catch(fail); }; - const archive = (targets: UnifiedEmail[]) => { + // Resolve whether the messages went: a confirm can say no. + const archive = async (targets: UnifiedEmail[]): Promise => { + if (!(await confirmMailRemoval('archive', targets.length, t))) return false; removeRows(targets); moveUnifiedEmails(targets, 'archive').catch(fail); + return true; }; const spam = (targets: UnifiedEmail[]) => { removeRows(targets); @@ -254,24 +258,29 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) { markRead: role !== 'junk' && deleteAction === 'trash-and-read', }).catch(fail); }; - const remove = async (targets: UnifiedEmail[]) => { + const remove = async (targets: UnifiedEmail[]): Promise => { const permanent = targets.some((e) => isPermanentDelete({ inTrash: role === 'trash' || isInUnifiedTrash(e), inJunk: role === 'junk', deleteAction, permanentlyDeleteJunk, })); - if (permanent && !(await confirmPermanentDelete(targets.length, t))) return; + if (permanent) { + if (!(await confirmPermanentDelete(targets.length, t))) return false; + } else if (!(await confirmMailRemoval('delete', targets.length, t))) { + return false; + } removeRows(targets); deleteUnifiedEmails(targets, { permanent: deleteAction === 'permanent' || (permanentlyDeleteJunk && role === 'junk'), markRead: deleteAction === 'trash-and-read', }).catch(fail); + return true; }; const handleSwipe = (email: UnifiedEmail, action: SwipeAction) => { switch (action) { - case 'archive': if (role !== 'archive') archive([email]); break; + case 'archive': if (role !== 'archive') void archive([email]); break; case 'delete': void remove([email]); break; case 'spam': if (role !== 'sent' && role !== 'drafts') spam([email]); break; case 'read': setRead([email], !email.keywords?.$seen); break; @@ -444,7 +453,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) { )} {role !== 'archive' && ( { archive(selectedEmails); clearSelection(); }} + onPress={() => { void archive(selectedEmails).then((done) => { if (done) clearSelection(); }); }} style={styles.headerBtn} hitSlop={6} accessibilityRole="button" @@ -454,7 +463,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) { )} { void remove(selectedEmails).then(clearSelection); }} + onPress={() => { void remove(selectedEmails).then((done) => { if (done) clearSelection(); }); }} style={styles.headerBtn} hitSlop={6} accessibilityRole="button" From 352973793f0feaec18c46bc898ae5be2e90fd1b8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:59:16 +0100 Subject: [PATCH 10/37] fix: hide reply on drafts and scheduled messages in a conversation and quick reply to the newest real message (bulwarkmail/webmail#1404) --- src/api/__tests__/live-stalwart-mail.test.ts | 19 +++++ src/api/thread-scheduled.ts | 36 +++++++++ src/lib/__tests__/thread-reply-target.test.ts | 74 +++++++++++++++++++ src/lib/thread-utils.ts | 27 +++++++ src/screens/EmailThreadScreen.tsx | 22 +++++- 5 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 src/api/thread-scheduled.ts create mode 100644 src/lib/__tests__/thread-reply-target.test.ts diff --git a/src/api/__tests__/live-stalwart-mail.test.ts b/src/api/__tests__/live-stalwart-mail.test.ts index ffb1a563..ea415837 100644 --- a/src/api/__tests__/live-stalwart-mail.test.ts +++ b/src/api/__tests__/live-stalwart-mail.test.ts @@ -40,6 +40,7 @@ import { queryEmailPage, } from '../email'; import { senderWarningOf } from '../../lib/email-headers'; +import { scheduledEmailIdsInThread } from '../thread-scheduled'; import { uploadBytes } from '../blob'; import { getIdentities, updateIdentity } from '../identity'; import { isJMAPSetError } from '../jmap-result'; @@ -447,5 +448,23 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => { const [full] = await getFullEmails([id]); expect(senderWarningOf(full)?.status).toBe('failed'); }, 60_000); + + it("finds a conversation's message that waits for a scheduled send (bulwarkmail/webmail#1404)", async () => { + const [identity] = await getIdentities(); + const result = await sendEmail( + { from: [{ email: USER }], to: [{ email: USER }], subject: `scheduled reply ${mailTag}`, textBody: 'live' }, + identity.id, sent, 3600, { draftsMailboxId: drafts }, + ); + try { + const [filed] = await getFullEmails([result.emailId!]); + const ids = await scheduledEmailIdsInThread(filed.threadId); + expect([...ids]).toEqual([result.emailId]); + } finally { + await cancelScheduledSend(result.emailSubmissionId!).catch(() => undefined); + } + // Once cancelled it can be replied to again. + const [filed] = await getFullEmails([result.emailId!]); + expect((await scheduledEmailIdsInThread(filed.threadId)).size).toBe(0); + }, 90_000); }); }); diff --git a/src/api/thread-scheduled.ts b/src/api/thread-scheduled.ts new file mode 100644 index 00000000..5d84762b --- /dev/null +++ b/src/api/thread-scheduled.ts @@ -0,0 +1,36 @@ +import { jmapClient } from './jmap-client'; +import { SUBMISSION_ONLY_USING } from './submissions'; + +/** + * Ids of a conversation's messages that wait for a scheduled send: a pending + * EmailSubmission whose send time is still ahead. The conversation hides + * Reply on them and points the quick reply past them, as it does for drafts + * (webmail 8b29254a6, #1404). Empty when the account can't schedule or the + * server can't answer: the cards then stay as they were. + */ +export async function scheduledEmailIdsInThread(threadId: string, accountId?: string): Promise> { + const account = accountId ?? jmapClient.accountId; + if (!account || !jmapClient.hasDelayedSend(account)) return new Set(); + try { + const res = await jmapClient.request([ + ['EmailSubmission/query', { accountId: account, filter: { threadIds: [threadId], undoStatus: 'pending' } }, '0'], + ['EmailSubmission/get', { + accountId: account, + '#ids': { resultOf: '0', name: 'EmailSubmission/query', path: '/ids' }, + properties: ['emailId', 'sendAt', 'undoStatus'], + }, '1'], + ], SUBMISSION_ONLY_USING); + const [name, body] = res.methodResponses?.[1] ?? []; + if (name !== 'EmailSubmission/get') return new Set(); + const now = Date.now(); + const ids = new Set(); + for (const s of (body.list as Array<{ emailId?: string; sendAt?: string; undoStatus?: string }>) ?? []) { + if (!s.emailId || s.undoStatus !== 'pending') continue; + const at = s.sendAt ? new Date(s.sendAt).getTime() : NaN; + if (Number.isFinite(at) && at > now) ids.add(s.emailId); + } + return ids; + } catch { + return new Set(); + } +} diff --git a/src/lib/__tests__/thread-reply-target.test.ts b/src/lib/__tests__/thread-reply-target.test.ts new file mode 100644 index 00000000..e8e995a1 --- /dev/null +++ b/src/lib/__tests__/thread-reply-target.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { accountId: 'acc-1', request: vi.fn(), hasDelayedSend: vi.fn(() => true) }, +})); + +import { jmapClient } from '../../api/jmap-client'; +import { scheduledEmailIdsInThread } from '../../api/thread-scheduled'; +import { isReplyableMember, quickReplyTarget } from '../thread-utils'; + +// Every card of a conversation offered Reply, and the quick reply answered +// the newest member even when that was the user's own draft or a reply +// waiting for its scheduled send (webmail 8b29254a6, #1404). + +const msg = (id: string, keywords: Record = {}) => ({ id, keywords }); +const none = new Set(); + +describe('replying in a conversation (bulwarkmail/webmail#1404)', () => { + it('does not offer Reply on drafts and scheduled messages', () => { + expect(isReplyableMember(msg('a'), none)).toBe(true); + expect(isReplyableMember(msg('d', { $draft: true }), none)).toBe(false); + expect(isReplyableMember(msg('s'), new Set(['s']))).toBe(false); + }); + + it('points the quick reply at the newest message that is neither', () => { + const conversation = [msg('1'), msg('2'), msg('3'), msg('draft', { $draft: true })]; + expect(quickReplyTarget(conversation, none)?.id).toBe('3'); + expect(quickReplyTarget(conversation, new Set(['3']))?.id).toBe('2'); + expect(quickReplyTarget([msg('d', { $draft: true })], none)).toBeNull(); + }); + + it('is wired into the conversation cards and the quick reply', () => { + const screen = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailThreadScreen.tsx'), 'utf8'); + expect(screen).toMatch(/replyable=\{!!full && isReplyableMember\(m, scheduledIds\)\}/); + expect(screen).toMatch(/const newest = quickReplyTarget\(/); + expect(screen).toMatch(/scheduledEmailIdsInThread\(threadId, jmapAccountId\)/); + }); +}); + +describe('scheduledEmailIdsInThread', () => { + const request = jmapClient.request as ReturnType; + beforeEach(() => request.mockReset()); + + it('keeps pending submissions whose send time is ahead', async () => { + const future = new Date(Date.now() + 3_600_000).toISOString(); + const past = new Date(Date.now() - 60_000).toISOString(); + request.mockResolvedValue({ + methodResponses: [ + ['EmailSubmission/query', { ids: ['s1', 's2', 's3'] }, '0'], + ['EmailSubmission/get', { list: [ + { emailId: 'e1', sendAt: future, undoStatus: 'pending' }, + { emailId: 'e2', sendAt: past, undoStatus: 'pending' }, + { emailId: 'e3', sendAt: future, undoStatus: 'canceled' }, + ] }, '1'], + ], + }); + expect([...await scheduledEmailIdsInThread('t1')]).toEqual(['e1']); + const [[calls]] = request.mock.calls; + expect(calls[0][1].filter).toEqual({ threadIds: ['t1'], undoStatus: 'pending' }); + }); + + it('answers with nothing when the server refuses or the account cannot schedule', async () => { + request.mockResolvedValue({ methodResponses: [['error', { type: 'unsupportedFilter' }, '0'], ['error', {}, '1']] }); + expect((await scheduledEmailIdsInThread('t1')).size).toBe(0); + request.mockImplementationOnce(async () => { throw new Error('offline'); }); + expect((await scheduledEmailIdsInThread('t1')).size).toBe(0); + (jmapClient.hasDelayedSend as ReturnType).mockReturnValue(false); + request.mockClear(); + expect((await scheduledEmailIdsInThread('t1')).size).toBe(0); + expect(request).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/thread-utils.ts b/src/lib/thread-utils.ts index 84a4529c..9a0c0981 100644 --- a/src/lib/thread-utils.ts +++ b/src/lib/thread-utils.ts @@ -178,3 +178,30 @@ export function expandThreadSelection( } return out; } + +/** + * Whether a conversation member can be replied to: not the user's own draft + * and not a message waiting for its scheduled send (webmail 8b29254a6, + * #1404). + */ +export function isReplyableMember( + email: Pick, + scheduledIds: ReadonlySet, +): boolean { + return !email.keywords?.$draft && !scheduledIds.has(email.id); +} + +/** + * The message the quick reply answers: the newest member that can be + * replied to, so it never quotes the user's own draft or scheduled reply. + * Null when there is none. + */ +export function quickReplyTarget>( + members: readonly T[], + scheduledIds: ReadonlySet, +): T | null { + for (let i = members.length - 1; i >= 0; i--) { + if (isReplyableMember(members[i], scheduledIds)) return members[i]; + } + return null; +} diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 80e77f70..4b0b166b 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -49,7 +49,8 @@ import { replyComposeParams } from '../lib/reply-compose'; import { singleLine } from '../lib/single-line'; import { buildForwardAsAttachmentPayload } from '../lib/forward-as-attachment'; import { viewerInstance, viewerPages, type ViewerInstance } from '../lib/viewer-pages'; -import { accountScopedId } from '../lib/thread-utils'; +import { accountScopedId, isReplyableMember, quickReplyTarget } from '../lib/thread-utils'; +import { scheduledEmailIdsInThread } from '../api/thread-scheduled'; import type { Email, EmailAddress, Identity } from '../api/types'; import type { RootStackParamList } from '../navigation/types'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; @@ -1013,6 +1014,8 @@ interface EmailPaneProps { onZoomChange: (zoom: { pinching: boolean; zoomed: boolean }) => void; } +const NO_IDS: ReadonlySet = new Set(); + // One swipeable page: the subject plus either a single message or the whole // conversation as collapsible cards (newest + unread expanded, mark-read on // expand). The pager keeps three of these mounted (prev, current, next) so a @@ -1070,6 +1073,19 @@ function EmailPane({ React.useEffect(() => () => { readTimers.forEach((cancel) => cancel()); readTimers.clear(); }, [readTimers]); + // Members waiting for a scheduled send: no Reply on them, and the quick + // reply answers the newest message that is neither one nor a draft (#1404). + const [scheduledIds, setScheduledIds] = React.useState>(NO_IDS); + const isConversation = !!threadIds && threadIds.length > 1; + React.useEffect(() => { + if (!active || !threading || !threadId || !isConversation) return undefined; + let cancelled = false; + void scheduledEmailIdsInThread(threadId, jmapAccountId).then((ids) => { + if (!cancelled) setScheduledIds(ids.size > 0 ? ids : NO_IDS); + }); + return () => { cancelled = true; }; + }, [active, threading, threadId, isConversation, jmapAccountId]); + // Keeps the quick reply in view while the keyboard opens. Above the // skeleton's early return: a page opened by id alone (a notification) // renders the skeleton first, and a hook after it crashed the app. @@ -1149,7 +1165,7 @@ function EmailPane({ // and WebView stay mounted when a conversation arrives around it. const members = conversation ?? [shown]; const firstOpenId = conversation?.find((m) => expanded?.has(m.id) ?? m.id === id)?.id; - const newest = conversation ? conversation[conversation.length - 1] : email; + const newest = quickReplyTarget(conversation ?? (email ? [email] : []), scheduledIds); // The quick reply quotes the message, so it waits for the newest one's body. const newestLoaded = !!newest && !!peekDetail(newest.id, jmapAccountId); const onPaneZoom = (z: { pinching: boolean; zoomed: boolean }) => { @@ -1237,7 +1253,7 @@ function EmailPane({ fill={!asConversation} email={full ?? m} expanded={open} - replyable={!!full} + replyable={!!full && isReplyableMember(m, scheduledIds)} deferBody={!full || !bodyEnabled} onBodySettled={onBodySettled} onToggleExpanded={() => toggleCard(m.id)} From 7772da8463ba5447f784d5be616c6bdb0eddca5d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:00:21 +0100 Subject: [PATCH 11/37] fix: leave bounce and receipt report parts out of the list's attachment chips --- src/lib/__tests__/list-report-parts.test.ts | 28 +++++++++++++++++++++ src/lib/attachment-display.ts | 18 ++++++++++--- src/lib/list-attachments.ts | 6 +++-- 3 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 src/lib/__tests__/list-report-parts.test.ts diff --git a/src/lib/__tests__/list-report-parts.test.ts b/src/lib/__tests__/list-report-parts.test.ts new file mode 100644 index 00000000..301d4f34 --- /dev/null +++ b/src/lib/__tests__/list-report-parts.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { isReportPart } from '../attachment-display'; +import { realAttachments } from '../list-attachments'; +import type { Attachment } from '../../api/types'; + +// A bounce's delivery status and a read receipt's disposition showed as +// attachment chips in the list (the reader already hid them), and the RFC +// 6533 `global-` forms counted as real files everywhere (webmail 8a09ba573). + +const part = (type: string, name: string): Attachment => ({ blobId: `b-${name}`, type, name, size: 1 } as Attachment); + +describe('report parts in list chips', () => { + it('counts the global- forms as report parts', () => { + expect(isReportPart('message/global-delivery-status')).toBe(true); + expect(isReportPart('message/global-disposition-notification')).toBe(true); + expect(isReportPart('Message/Delivery-Status; charset=utf-8')).toBe(true); + expect(isReportPart('message/rfc822')).toBe(false); + }); + + it('leaves report parts out of the list chips', () => { + const parts = [ + part('message/delivery-status', 'details.txt'), + part('message/global-disposition-notification', 'receipt'), + part('application/pdf', 'invoice.pdf'), + ]; + expect(realAttachments(parts).map((a) => a.name)).toEqual(['invoice.pdf']); + }); +}); diff --git a/src/lib/attachment-display.ts b/src/lib/attachment-display.ts index 6031c94f..124329dd 100644 --- a/src/lib/attachment-display.ts +++ b/src/lib/attachment-display.ts @@ -56,10 +56,22 @@ export function isCalendarMimeType(type?: string | null): boolean { return CAL_MIME.has(type.split(';')[0].trim().toLowerCase()); } -/** Machine-readable report parts (MDN receipts, DSN bounces) - never real attachments. */ +/** + * Machine-readable report parts: the delivery status of a bounce (RFC 3464), + * the disposition of a read receipt (RFC 8098) and their RFC 6533 UTF-8 + * forms. A multipart/report needs them, but they are not files anyone + * attached, so no view lists them and a forward leaves them out (webmail + * lib/attachment-visibility.ts). + */ +const REPORT_PART_TYPES: ReadonlySet = new Set([ + 'message/delivery-status', + 'message/disposition-notification', + 'message/global-delivery-status', + 'message/global-disposition-notification', +]); + export function isReportPart(type?: string | null): boolean { - const base = (type || '').split(';')[0].trim().toLowerCase(); - return base === 'message/disposition-notification' || base === 'message/delivery-status'; + return REPORT_PART_TYPES.has((type || '').split(';')[0].trim().toLowerCase()); } export function isRfc822Attachment(att: Pick): boolean { diff --git a/src/lib/list-attachments.ts b/src/lib/list-attachments.ts index 675cc145..aa24b102 100644 --- a/src/lib/list-attachments.ts +++ b/src/lib/list-attachments.ts @@ -1,4 +1,5 @@ import type { Attachment, Email } from '../api/types'; +import { isReportPart } from './attachment-display'; /** * Attachment chips for message-list rows (webmail 0e1c34cd, #1089). @@ -14,11 +15,12 @@ import type { Attachment, Email } from '../api/types'; /** * The parts the sender actually attached. Anything marked inline or * referenced by a Content-ID (signature logos, spacers, tracking pixels) is - * dropped, as are parts without a filename, which can't be labelled or saved. + * dropped, as are parts without a filename, which can't be labelled or saved, + * and the report parts of a bounce or a read receipt. */ export function realAttachments(attachments?: readonly Attachment[] | null): Attachment[] { if (!attachments?.length) return []; - return attachments.filter((a) => a.disposition !== 'inline' && !a.cid && !!a.name); + return attachments.filter((a) => a.disposition !== 'inline' && !a.cid && !!a.name && !isReportPart(a.type)); } /** From a4d389114007d373207c03faf56d0819c89b2957 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:01:54 +0100 Subject: [PATCH 12/37] fix: give user folders in the move sheet a folder icon even when their name looks like a system folder (bulwarkmail/webmail#1235) --- src/components/MoveSheet.tsx | 25 +++++++++++++++---------- src/lib/__tests__/mailbox-icon.test.ts | 7 +++++++ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/components/MoveSheet.tsx b/src/components/MoveSheet.tsx index ac61934a..75feaf3c 100644 --- a/src/components/MoveSheet.tsx +++ b/src/components/MoveSheet.tsx @@ -13,19 +13,24 @@ import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailb import { useSheetDrag } from '../lib/use-sheet-drag'; import { useLocaleStore } from '../stores/locale-store'; import { localizeMailboxName } from '../lib/mailbox-label'; +import { mailboxIconKind, type MailboxIconKind } from '../lib/mailbox-icon'; import type { Mailbox } from '../api/types'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; import { useSheetInsets } from '../lib/use-layout'; -function moveTargetIcon(role: string | null | undefined, name: string): AppIcon { - const lower = name.toLowerCase(); - if (role === 'inbox' || lower.includes('inbox')) return Inbox; - if (role === 'sent' || lower.includes('sent')) return Send; - if (role === 'drafts' || lower.includes('draft')) return FileIcon; - if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2; - if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban; - if (role === 'archive' || lower.includes('archive')) return Archive; - return Folder; +// By role only, like the sidebar (mailboxIconKind, #1235): a user folder +// named "web/sentry" is not the Sent folder. +const MOVE_TARGET_ICONS: Partial> = { + inbox: Inbox, + sent: Send, + drafts: FileIcon, + trash: Trash2, + junk: Ban, + archive: Archive, +}; + +function moveTargetIcon(role: string | null | undefined): AppIcon { + return MOVE_TARGET_ICONS[mailboxIconKind(role, false, false)] ?? Folder; } interface MoveSheetProps { @@ -106,7 +111,7 @@ export function MoveSheet({ {visibleNodes.map((node) => { - const Icon = moveTargetIcon(node.role, node.name); + const Icon = moveTargetIcon(node.role); const isCurrent = node.id === currentMailboxId; // A shared account's header is a grouping row, not a folder, and // Drafts is never a move target (the webmail excludes it too). diff --git a/src/lib/__tests__/mailbox-icon.test.ts b/src/lib/__tests__/mailbox-icon.test.ts index b9f85961..a61c7409 100644 --- a/src/lib/__tests__/mailbox-icon.test.ts +++ b/src/lib/__tests__/mailbox-icon.test.ts @@ -25,4 +25,11 @@ describe('mailboxIconKind', () => { expect(source).toContain('mailboxIconKind('); expect(source).not.toMatch(/lower\.includes\('(?:sent|inbox|draft|trash|junk|archive|star)'\)/); }); + + // The move sheet still guessed from the name (#1235 fixed only the sidebar). + it('is what the move sheet uses too, with no guess from the folder name', () => { + const source = readFileSync(path.join(process.cwd(), 'src/components/MoveSheet.tsx'), 'utf8'); + expect(source).toContain('mailboxIconKind('); + expect(source).not.toMatch(/\.includes\('(?:sent|inbox|draft|trash|deleted|junk|spam|archive)'\)/); + }); }); From 6f66fa065a956073b6c92d26112206181fe19931 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:03:46 +0100 Subject: [PATCH 13/37] fix: draw avatar initials in black or white, whichever reads better on the colour --- src/components/SenderAvatar.tsx | 3 +- .../__tests__/avatar-initials-color.test.ts | 44 +++++++++++++++++++ src/lib/avatar-utils.ts | 39 ++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 src/lib/__tests__/avatar-initials-color.test.ts diff --git a/src/components/SenderAvatar.tsx b/src/components/SenderAvatar.tsx index 15e5c758..22396e38 100644 --- a/src/components/SenderAvatar.tsx +++ b/src/components/SenderAvatar.tsx @@ -3,6 +3,7 @@ import { View, Text, Image, StyleSheet, type StyleProp, type ViewStyle } from 'r import { useShallow } from 'zustand/react/shallow'; import { avatarContent, + initialsColor, generateEmailAvatarColor, getContactPhotoForEmail, getEmailInitials, @@ -141,7 +142,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma return ( - {initials} + {initials} ); } diff --git a/src/lib/__tests__/avatar-initials-color.test.ts b/src/lib/__tests__/avatar-initials-color.test.ts new file mode 100644 index 00000000..02891d99 --- /dev/null +++ b/src/lib/__tests__/avatar-initials-color.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { generateEmailAvatarColor, initialsColor } from '../avatar-utils'; +import { parseColor, relativeLuminance } from '../event-colors'; + +// Initials were always white, which on the yellow, green and cyan hues of +// `hsl(h, 70%, 50%)` came out near 1.5:1 (webmail 2e13accad). + +function contrast(a: { r: number; g: number; b: number; a: number }, b: { r: number; g: number; b: number; a: number }) { + const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +function hsl(h: number): { r: number; g: number; b: number; a: number } { + // Same formula as the avatar's: 70 % saturation, 50 % lightness. + const s = 0.7; const l = 0.5; + const k = (n: number) => (n + h / 30) % 12; + const f = (n: number) => l - s * Math.min(l, 1 - l) * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)); + return { r: Math.round(f(0) * 255), g: Math.round(f(8) * 255), b: Math.round(f(4) * 255), a: 1 }; +} + +describe('avatar initials colour', () => { + it('picks black on light hues and white on dark ones', () => { + expect(initialsColor('hsl(60, 70%, 50%)')).toBe('#000000'); + expect(initialsColor('hsl(180, 70%, 50%)')).toBe('#000000'); + expect(initialsColor('hsl(240, 70%, 50%)')).toBe('#ffffff'); + expect(initialsColor('#1d4ed8')).toBe('#ffffff'); + expect(initialsColor('not a colour')).toBe('#ffffff'); + }); + + it('reaches 4.5:1 on every generated hue', () => { + for (let h = 0; h < 360; h++) { + const ink = parseColor(initialsColor(`hsl(${h}, 70%, 50%)`))!; + expect(contrast(hsl(h), ink), `hue ${h}`).toBeGreaterThanOrEqual(4.5); + } + expect(generateEmailAvatarColor('Ann')).toMatch(/^hsl\(\d+, 70%, 50%\)$/); + }); + + it('is what the sender avatar draws its initials in', () => { + const src = fs.readFileSync(path.resolve(__dirname, '../../components/SenderAvatar.tsx'), 'utf8'); + expect(src).toMatch(/color: initialsColor\(bgColor\)/); + }); +}); diff --git a/src/lib/avatar-utils.ts b/src/lib/avatar-utils.ts index 584d82fd..c8670f7c 100644 --- a/src/lib/avatar-utils.ts +++ b/src/lib/avatar-utils.ts @@ -12,6 +12,7 @@ import type { ContactCard } from '../api/types'; import { getContactPhotoRef, type ContactPhotoRef } from './contact-utils'; +import { parseColor, relativeLuminance } from './event-colors'; // --- Account avatar ------------------------------------------------------- @@ -48,6 +49,44 @@ export function generateEmailAvatarColor(name: string, email?: string): string { return `hsl(${hue}, 70%, 50%)`; } +/** `hsl(h, s%, l%)` as RGB; null for anything else. */ +function hslToRgb(color: string): { r: number; g: number; b: number; a: number } | null { + const m = /^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%/i.exec(color.trim()); + if (!m) return null; + const h = (Number(m[1]) % 360) / 360; + const s = Number(m[2]) / 100; + const l = Number(m[3]) / 100; + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + return { + r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255), + g: Math.round(hue2rgb(p, q, h) * 255), + b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255), + a: 1, + }; +} + +/** + * White or black initials, whichever contrasts more with the background. + * One of the two always reaches 4.58:1, so the initials pass 4.5:1 on every + * generated colour; white alone failed on yellows, greens and cyans + * (webmail components/ui/avatar.tsx initialsColor, 2e13accad). + */ +export function initialsColor(background: string): string { + const rgb = hslToRgb(background) ?? parseColor(background); + if (!rgb) return '#ffffff'; + const l = relativeLuminance(rgb); + return 1.05 / (l + 0.05) >= (l + 0.05) / 0.05 ? '#ffffff' : '#000000'; +} + /** * Initials as the webmail's `Avatar` (components/ui/avatar.tsx) works them * out: the first letters of the first and last name part, ignoring From 43973608d612448030f1f7038b2c634f03338ca8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:05:45 +0100 Subject: [PATCH 14/37] fix: show plain-text mail in the dark theme's own colours instead of a fixed black --- src/components/EmailBodyView.tsx | 10 ++++- .../__tests__/plain-text-theme-colors.test.ts | 43 +++++++++++++++++++ src/lib/email-body-document.ts | 12 +++++- src/lib/email-html.ts | 26 ++++++++--- 4 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 src/lib/__tests__/plain-text-theme-colors.test.ts diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx index 1082e9d9..78530d4d 100644 --- a/src/components/EmailBodyView.tsx +++ b/src/components/EmailBodyView.tsx @@ -688,6 +688,13 @@ export default function EmailBodyView({ // Sanitised and wrapped once per message, body and settings, and reused // when the same body mounts again (lib/email-body-document). const emptyLabel = t('email_viewer.no_body_content', '(No body content available)'); + // A plain-text body in a dark app theme takes the theme's colours, not a + // fixed #0a0a0a. Only while the app itself is dark: a single message + // switched to dark in a light app has no dark palette to borrow. + const plainDarkColors = React.useMemo( + () => (resolvedTheme === 'dark' ? { background: c.background, text: c.text, link: c.textLink } : undefined), + [resolvedTheme, c.background, c.text, c.textLink], + ); const prepared = React.useMemo(() => bodyDocument({ key: `${jmapAccountId ?? ''}|${email.id}`, rawHtml, @@ -700,9 +707,10 @@ export default function EmailBodyView({ plainTextFont, quoteLabels, textFactor, + plainDarkColors, }), [ jmapAccountId, email.id, rawHtml, text, email.preview, emptyLabel, shouldBlock, cidMap, renderAsDark, - messageSpacing, plainTextFont, quoteLabels, textFactor, + messageSpacing, plainTextFont, quoteLabels, textFactor, plainDarkColors, ]); const source = React.useMemo(() => ({ html: prepared.html }), [prepared.html]); diff --git a/src/lib/__tests__/plain-text-theme-colors.test.ts b/src/lib/__tests__/plain-text-theme-colors.test.ts new file mode 100644 index 00000000..6ee24b3a --- /dev/null +++ b/src/lib/__tests__/plain-text-theme-colors.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { buildBodyDocument } from '../email-body-document'; +import { BUILTIN_THEMES } from '../../theme/builtin-themes'; + +// A plain-text mail read in dark mode sat on a fixed #0a0a0a whatever the +// app's theme was (UX audit 2026-10, "Plain-text mail dark background"). + +const base = { + key: 'k', emptyLabel: '-', blockRemoteImages: false, cidMap: {}, rawHtml: null, text: 'hello', + messageSpacing: 'auto' as const, plainTextFont: 'sans' as const, quoteLabels: { show: 's', hide: 'h' }, +}; + +describe('plain-text mail colours', () => { + it("takes the dark theme's ground, text and link colours", () => { + const nord = { background: '#2e3440', text: '#eceff4', link: '#88c0d0' }; + const html = buildBodyDocument({ ...base, isDark: true, plainDarkColors: nord }).html; + expect(html).toContain('background: #2e3440;'); + expect(html).toContain('color: #eceff4;'); + expect(html).toContain('a { color: #88c0d0;'); + expect(html).not.toContain('#0a0a0a'); + }); + + it('keeps white in light mode and the old dark colours without a theme', () => { + const colors = { background: '#2e3440', text: '#eceff4', link: '#88c0d0' }; + expect(buildBodyDocument({ ...base, isDark: false, plainDarkColors: colors }).html).toContain('background: #ffffff;'); + expect(buildBodyDocument({ ...base, isDark: true }).html).toContain('background: #0a0a0a;'); + }); + + it('cannot close the style sheet with a theme colour', () => { + const html = buildBodyDocument({ + ...base, isDark: true, plainDarkColors: { background: 'red;}', text: '#fff', link: '#00f' }, + }).html; + expect(html).not.toContain('