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] 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')}