feat: read mail as plain text when plain text only is on, or per message from the menu

This commit is contained in:
Linus Rath
2026-10-31 03:48:14 +01:00
parent a930d17786
commit 7dc457780d
7 changed files with 427 additions and 8 deletions
+48 -2
View File
@@ -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 (
<View style={[styles.wrapper, fill && styles.wrapperFill]}>
{plainText !== null && (
<View style={styles.plainNote}>
<Text style={styles.bannerText}>
{t('email_viewer.shown_as_plain_text', 'This message is shown as plain text.')}
</Text>
{onShowFormatted && (
<Pressable onPress={onShowFormatted} hitSlop={8} accessibilityRole="button">
<Text style={styles.plainNoteLink}>{t('email_viewer.show_formatted', 'Show formatted')}</Text>
</Pressable>
)}
</View>
)}
{showBanner && (
<View style={styles.banner}>
<Text style={styles.bannerText}>
@@ -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',
@@ -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\}/);
});
});
+8 -2
View File
@@ -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}
@@ -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('<p>Hello <b>Ann</b>,</p><p>first line<br>second line</p>'))
.toBe('Hello Ann,\n\nfirst line\nsecond line');
});
it('keeps link targets and image alt text', () => {
expect(htmlToReadableText('<p>Read <a href="https://ex.com/a">the report</a> <img src="x.png" alt="Logo"></p>'))
.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('<style>p{color:red}</style><a href="https://ex.com/">ex.com</a><script>x()</script>'))
.toBe('ex.com');
expect(htmlToReadableText('<a href="mailto:ann@ex.com?subject=Hi">Write to Ann</a>'))
.toBe('Write to Ann (ann@ex.com)');
});
it('turns layout tables into lines and lists into bullets', () => {
const html = '<table><tr><td>Name</td><td>Ann</td></tr><tr><td>City</td><td>Berlin</td></tr></table><ul><li>one</li><li>two</li></ul>';
expect(htmlToReadableText(html)).toBe('Name Ann\nCity Berlin\n\n• one\n• two');
});
it('skips hidden preheaders and images without alt text', () => {
expect(htmlToReadableText('<div style="display:none">preheader</div><img src="t.gif"><p>Body</p>')).toBe('Body');
});
it('reads a whole document: head, entities, comments and unclosed tags', () => {
const html = '<!DOCTYPE html><html><head><title>T</title><style>a{}</style></head><body>'
+ '<!-- tracking --><p>Fish &amp; chips&nbsp;&#8364;5<p>Next <a href="https://ex.com/?a=1&amp;b=2">deal</a>'
+ '<ul><li>one<li>two</ul><script>if (a < b) document.write("<p>x</p>")</script></body></html>';
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('<pre>a b\n c</pre>')).toBe('a b\n c');
expect(htmlToReadableText('<p>On Monday Bob wrote:</p><blockquote>hi</blockquote>')).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: '<p>Hi <b>there</b></p>', text: 'Hi there (text part)' })).toBe('Hi there (text part)');
expect(plainTextReading({ html: '<p>Hi <b>there</b></p>', text: null })).toBe('Hi there');
expect(plainTextReading({ html: '<p>Hi</p>', text: ' ' })).toBe('Hi');
expect(plainTextReading({ html: null, text: 'just text' })).toBeNull();
});
});
+246
View File
@@ -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 = /<!--[\s\S]*?(?:-->|$)|<![^>]*>?|<\?[^>]*>?|<(\/?)([a-zA-Z][a-zA-Z0-9:-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g;
const ATTR = /([^\s=/>"']+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
function parseAttrs(raw: string): Map<string, string> {
const attrs = new Map<string, string>();
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<string, string>) => {
// Close what this element implicitly ends (a <p> before a block, the
// previous <li>, 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(`</${tag.toLowerCase()}`, pos);
const stop = endAt < 0 ? html.length : endAt;
if (!SKIP.has(tag)) text(html.slice(pos, stop));
pos = stop;
TOKEN.lastIndex = stop;
}
}
if (pos < html.length) text(html.slice(pos));
while (stack.length > 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);
}
+1 -1
View File
@@ -363,7 +363,7 @@ const LETTER_ENTITIES: Record<string, string> = {
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);
+38 -3
View File
@@ -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<EmailAddress | null>(null);
// Per-message override of the light/dark rendering (More sheet toggle).
const [themeOverrides, setThemeOverrides] = React.useState<Record<string, 'light' | 'dark'>>({});
// Per-message "Show as plain text" (More sheet); unset follows "Plain Text Only".
const [plainOverrides, setPlainOverrides] = React.useState<Record<string, boolean>>({});
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<string, 'light' | 'dark'>;
plainOverrides: Record<string, boolean>;
onShowFormatted: (email: Email) => void;
ensureDetail: (id: string) => Promise<Email | null>;
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 && (
<MoreMenuItem
icon={plainView ? <Type size={18} color={c.textSecondary} /> : <FileText size={18} color={c.textSecondary} />}
label={plainView ? t('email_viewer.show_formatted', 'Show formatted') : t('email_viewer.show_as_plain_text', 'Show as plain text')}
onPress={onTogglePlainView}
/>
)}
<MoreMenuItem
icon={renderDark ? <Sun size={18} color={c.textSecondary} /> : <Moon size={18} color={c.textSecondary} />}
label={renderDark ? t('email_viewer.view_light_mode', 'View in light mode') : t('email_viewer.view_dark_mode', 'View in dark mode')}