feat: collapse quoted reply text behind a "..." toggle #480

This commit is contained in:
Linus Rath
2026-07-22 19:17:23 +02:00
parent 813185e58d
commit b7c8cd999e
28 changed files with 603 additions and 12 deletions
+24 -6
View File
@@ -7,6 +7,7 @@ import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilen
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { EMAIL_IFRAME_SANITIZE_CONFIG, applyNewTabToAnchor, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { EMAIL_IFRAME_SANITIZE_CONFIG, applyNewTabToAnchor, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { withBasePath } from "@/lib/browser-navigation"; import { withBasePath } from "@/lib/browser-navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
@@ -1702,7 +1703,11 @@ export function EmailViewer({
const textContent = email.bodyValues[email.textBody[0].partId].value; const textContent = email.bodyValues[email.textBody[0].partId].value;
return { return {
html: plainTextToSafeHtml(textContent), // Trailing ">"-quoted block collapses behind a <details> toggle (#480).
html: collapsePlainTextQuotes(plainTextToSafeHtml(textContent), {
show: t('show_quoted_text'),
hide: t('hide_quoted_text'),
}),
isHtml: false, isHtml: false,
hasStyleTag: false, hasStyleTag: false,
externalBlocked: false, externalBlocked: false,
@@ -1742,6 +1747,11 @@ export function EmailViewer({
// Override email content with S/MIME decrypted content when available // Override email content with S/MIME decrypted content when available
const effectiveEmailContent = useMemo(() => { const effectiveEmailContent = useMemo(() => {
const plainToHtml = (text: string) =>
collapsePlainTextQuotes(plainTextToSafeHtml(text), {
show: t('show_quoted_text'),
hide: t('hide_quoted_text'),
});
if (pluginRenderedHtml) { if (pluginRenderedHtml) {
const htmlWithCidUrls = pluginRenderedHtml.replace( const htmlWithCidUrls = pluginRenderedHtml.replace(
/\bcid:([^"'\s)]+)/gi, /\bcid:([^"'\s)]+)/gi,
@@ -1753,7 +1763,7 @@ export function EmailViewer({
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(pluginRenderedHtml), externalBlocked: false }; return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(pluginRenderedHtml), externalBlocked: false };
} }
if (pluginRenderedText) { if (pluginRenderedText) {
return { html: plainTextToSafeHtml(pluginRenderedText), isHtml: false, hasStyleTag: false, externalBlocked: false }; return { html: plainToHtml(pluginRenderedText), isHtml: false, hasStyleTag: false, externalBlocked: false };
} }
// TNEF (winmail.dat) extracted content // TNEF (winmail.dat) extracted content
if (tnefHtml) { if (tnefHtml) {
@@ -1761,7 +1771,7 @@ export function EmailViewer({
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(tnefHtml), externalBlocked: false }; return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(tnefHtml), externalBlocked: false };
} }
if (tnefText) { if (tnefText) {
return { html: plainTextToSafeHtml(tnefText), isHtml: false, hasStyleTag: false, externalBlocked: false }; return { html: plainToHtml(tnefText), isHtml: false, hasStyleTag: false, externalBlocked: false };
} }
// Embedded message/rfc822 unwrapped content // Embedded message/rfc822 unwrapped content
if (embeddedEmailHtml) { if (embeddedEmailHtml) {
@@ -1769,10 +1779,10 @@ export function EmailViewer({
return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(embeddedEmailHtml), externalBlocked: false }; return { html: cleanHtml, isHtml: true, hasStyleTag: /<style[\s>]/i.test(embeddedEmailHtml), externalBlocked: false };
} }
if (embeddedEmailText) { if (embeddedEmailText) {
return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false, hasStyleTag: false, externalBlocked: false }; return { html: plainToHtml(embeddedEmailText), isHtml: false, hasStyleTag: false, externalBlocked: false };
} }
return emailContent; return emailContent;
}, [cidBlobUrls, emailContent, pluginRenderedHtml, pluginRenderedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]); }, [cidBlobUrls, emailContent, pluginRenderedHtml, pluginRenderedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText, t]);
const resolveAttachmentName = useCallback( const resolveAttachmentName = useCallback(
(attachment: EffectiveAttachment) => { (attachment: EffectiveAttachment) => {
@@ -2244,6 +2254,14 @@ export function EmailViewer({
if (initializedDocRef.current === doc) return; if (initializedDocRef.current === doc) return;
initializedDocRef.current = doc; initializedDocRef.current = doc;
{ {
// Collapse the quoted original of a reply behind a "•••" toggle
// (#480). Before the height wiring, so the initial measurement
// already reflects the collapsed body.
setupQuoteCollapse(doc, {
show: t('show_quoted_text'),
hide: t('hide_quoted_text'),
});
// Auto-resize iframe to fit content // Auto-resize iframe to fit content
// Measure max(documentElement, body): a height:100% wrapper can leave // Measure max(documentElement, body): a height:100% wrapper can leave
// documentElement.scrollHeight short while the real content lives in body. // documentElement.scrollHeight short while the real content lives in body.
@@ -2415,7 +2433,7 @@ export function EmailViewer({
} catch { } catch {
// Cross-origin restrictions - iframe will still display content // Cross-origin restrictions - iframe will still display content
} }
}, [isDark, emailHasNativeDarkMode, email?.id]); }, [isDark, emailHasNativeDarkMode, email?.id, t]);
// Wire up the iframe as soon as its sandboxed document has parsed, rather than // Wire up the iframe as soon as its sandboxed document has parsed, rather than
// waiting for the iframe 'load' event. 'load' also waits on every subresource, // waiting for the iframe 'load' event. 'load' also waits on every subresource,
+17 -3
View File
@@ -5,6 +5,7 @@ import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types"; import { Email, ThreadGroup } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform"; import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
import { useThemeStore } from "@/stores/theme-store"; import { useThemeStore } from "@/stores/theme-store";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
@@ -424,7 +425,14 @@ function EmailCard({
// Plain text fallback // Plain text fallback
if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) { if (email.textBody?.[0]?.partId && email.bodyValues[email.textBody[0].partId]) {
const text = email.bodyValues[email.textBody[0].partId].value; const text = email.bodyValues[email.textBody[0].partId].value;
return { html: plainTextToSafeHtml(text, 'text-primary hover:underline'), isHtml: false }; return {
// Trailing ">"-quoted block collapses behind a <details> toggle (#480).
html: collapsePlainTextQuotes(plainTextToSafeHtml(text, 'text-primary hover:underline'), {
show: t('email_viewer.show_quoted_text'),
hide: t('email_viewer.hide_quoted_text'),
}),
isHtml: false,
};
} }
} }
@@ -438,7 +446,7 @@ function EmailCard({
} }
return { html: "", isHtml: false }; return { html: "", isHtml: false };
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls]); }, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls, t]);
// Render the sanitized HTML body inside a sandboxed iframe so a malicious // Render the sanitized HTML body inside a sandboxed iframe so a malicious
// (or accidentally-bypassed) email cannot inject styles/scripts/forms into // (or accidentally-bypassed) email cannot inject styles/scripts/forms into
@@ -469,6 +477,12 @@ function EmailCard({
try { try {
const doc = iframe.contentDocument; const doc = iframe.contentDocument;
if (!doc?.body) return; if (!doc?.body) return;
// Collapse the quoted original of a reply behind a "•••" toggle (#480),
// before the first resize so the height reflects the collapsed body.
setupQuoteCollapse(doc, {
show: t('email_viewer.show_quoted_text'),
hide: t('email_viewer.hide_quoted_text'),
});
const resize = () => { const resize = () => {
iframe.style.height = doc.documentElement.scrollHeight + 'px'; iframe.style.height = doc.documentElement.scrollHeight + 'px';
}; };
@@ -482,7 +496,7 @@ function EmailCard({
} catch { } catch {
// contentDocument may be inaccessible under stricter sandboxes; ignore. // contentDocument may be inaccessible under stricter sandboxes; ignore.
} }
}, []); }, [t]);
return ( return (
<div className={cn( <div className={cn(
+248
View File
@@ -0,0 +1,248 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { setupQuoteCollapse, collapsePlainTextQuotes, QUOTE_TOGGLE_ATTR, QUOTE_COLLAPSED_ATTR } from '@/lib/quote-collapse';
import { plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from '@/lib/email-sanitization';
const labels = { show: 'Show quoted text', hide: 'Hide quoted text' };
const toggle = () => document.body.querySelector<HTMLButtonElement>(`[${QUOTE_TOGGLE_ATTR}]`);
describe('setupQuoteCollapse', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('collapses a Gmail-style trailing quote behind a toggle', () => {
document.body.innerHTML = `
<div dir="ltr">Thanks, sounds good!</div>
<div class="gmail_quote">
<div class="gmail_attr">On Mon, Jul 20, 2026 John wrote:</div>
<blockquote>Original message body</blockquote>
</div>`;
expect(setupQuoteCollapse(document, labels)).toBe(true);
const quote = document.querySelector<HTMLElement>('.gmail_quote')!;
expect(quote.style.display).toBe('none');
const btn = toggle()!;
expect(btn).toBeTruthy();
expect(btn.getAttribute('aria-expanded')).toBe('false');
expect(btn.title).toBe(labels.show);
// Button sits directly before the quote it controls.
expect(btn.nextElementSibling).toBe(quote);
});
it('toggle expands and re-collapses, restoring the original inline display', () => {
document.body.innerHTML = `
<p>reply text</p>
<div class="gmail_quote" style="display:flex">quoted</div>`;
setupQuoteCollapse(document, labels);
const quote = document.querySelector<HTMLElement>('.gmail_quote')!;
const btn = toggle()!;
btn.click();
expect(quote.style.display).toBe('flex');
expect(quote.hasAttribute(QUOTE_COLLAPSED_ATTR)).toBe(false);
expect(btn.getAttribute('aria-expanded')).toBe('true');
expect(btn.title).toBe(labels.hide);
btn.click();
expect(quote.style.display).toBe('none');
expect(btn.getAttribute('aria-expanded')).toBe('false');
expect(btn.title).toBe(labels.show);
});
it('collapses an Apple Mail cite blockquote but keeps the attribution line visible', () => {
document.body.innerHTML = `
<div>See you then.</div>
<div>On 20 Jul 2026, at 10:00, Jane wrote:</div>
<blockquote type="cite">the original</blockquote>`;
expect(setupQuoteCollapse(document, labels)).toBe(true);
const quote = document.querySelector<HTMLElement>('blockquote')!;
expect(quote.style.display).toBe('none');
expect(document.body.textContent).toContain('Jane wrote:');
});
it('collapses Bulwark\'s own data-quoted-html reply island', () => {
document.body.innerHTML = `
<p>my answer</p>
<div>On Jul 20, John &lt;j@example.com&gt; wrote:</div>
<div data-quoted-html style="border-left:2px solid #c5c5c5;padding-left:12px;">original</div>`;
expect(setupQuoteCollapse(document, labels)).toBe(true);
expect(document.querySelector<HTMLElement>('[data-quoted-html]')!.style.display).toBe('none');
});
it('collapses everything after an Outlook divRplyFwdMsg separator', () => {
document.body.innerHTML = `
<div>Top-posted answer</div>
<div id="divRplyFwdMsg"><b>From:</b> John<br><b>Sent:</b> Monday</div>
<div>quoted paragraph one</div>
<div>quoted paragraph two</div>`;
expect(setupQuoteCollapse(document, labels)).toBe(true);
const divs = [...document.querySelectorAll<HTMLElement>('body > div')];
expect(divs[0].style.display).toBe('');
expect(divs[1].style.display).toBe('none');
expect(divs[2].style.display).toBe('none');
expect(divs[3].style.display).toBe('none');
});
it('collapses from a Thunderbird moz-cite-prefix attribution onwards', () => {
document.body.innerHTML = `
<p>reply</p>
<div class="moz-cite-prefix">On 20.07.2026, Jane wrote:</div>
<blockquote type="cite">original</blockquote>`;
expect(setupQuoteCollapse(document, labels)).toBe(true);
expect(document.querySelector<HTMLElement>('.moz-cite-prefix')!.style.display).toBe('none');
expect(document.querySelector<HTMLElement>('blockquote')!.style.display).toBe('none');
});
it('does not collapse when the quote is the entire message', () => {
document.body.innerHTML = `
<div class="gmail_quote">forwarded content only</div>`;
expect(setupQuoteCollapse(document, labels)).toBe(false);
expect(toggle()).toBeNull();
expect(document.querySelector<HTMLElement>('.gmail_quote')!.style.display).toBe('');
});
it('does not collapse an interleaved reply (content after the quote container)', () => {
document.body.innerHTML = `
<p>first point</p>
<blockquote type="cite">their question</blockquote>
<p>my answer below the quote</p>`;
expect(setupQuoteCollapse(document, labels)).toBe(false);
expect(toggle()).toBeNull();
});
it('ignores whitespace-only trailing content after the quote container', () => {
document.body.innerHTML = `
<p>reply</p>
<div class="gmail_quote">quoted</div>
<div><br></div>
${String.fromCharCode(160)}`;
expect(setupQuoteCollapse(document, labels)).toBe(true);
});
it('is idempotent per document', () => {
document.body.innerHTML = `
<p>reply</p>
<div class="gmail_quote">quoted</div>`;
expect(setupQuoteCollapse(document, labels)).toBe(true);
expect(setupQuoteCollapse(document, labels)).toBe(false);
expect(document.querySelectorAll(`[${QUOTE_TOGGLE_ATTR}]`)).toHaveLength(1);
});
it('does nothing when no quote marker exists', () => {
document.body.innerHTML = `<p>plain email with content</p><p>and more content</p>`;
expect(setupQuoteCollapse(document, labels)).toBe(false);
expect(toggle()).toBeNull();
});
it('collapses a bare blockquote preceded by an attribution line ending in a colon', () => {
// Shape of a Bulwark reply to a plain-text original (issue #480 follow-up):
// no type/class on the blockquote, attribution in the preceding <p>.
document.body.innerHTML = `
<div>
<p>Hi Tracey,<br>thanks for reaching out.</p>
<p>On Mo., 18. Mai 2026, 20:27, Tracey Tiefisher wrote:<br></p>
<blockquote><p>Hi Linus,</p><blockquote><p>older nested quote</p></blockquote></blockquote>
<p></p>
</div>`;
expect(setupQuoteCollapse(document, labels)).toBe(true);
const outer = document.querySelector<HTMLElement>('blockquote')!;
expect(outer.style.display).toBe('none');
expect(document.body.textContent).toContain('Tracey Tiefisher wrote:');
});
it('leaves a bare blockquote alone when the preceding text is not an attribution', () => {
document.body.innerHTML = `
<p>Our newsletter quote of the day</p>
<blockquote>Stay hungry, stay foolish.</blockquote>`;
expect(setupQuoteCollapse(document, labels)).toBe(false);
expect(toggle()).toBeNull();
});
it('collapses a nested wrapper case: separator inside a wrapper div hides content outside it too', () => {
document.body.innerHTML = `
<div>answer</div>
<div><div id="appendonsend"></div><div>quoted intro</div></div>
<div>quoted rest</div>`;
expect(setupQuoteCollapse(document, labels)).toBe(true);
const last = document.body.lastElementChild as HTMLElement;
expect(last.textContent).toBe('quoted rest');
expect(last.style.display).toBe('none');
});
});
describe('sanitizer keeps the quote marker attribute', () => {
it('preserves data-quoted-html through sanitizeEmailHtml', () => {
const out = sanitizeEmailHtml('<p>hi</p><div data-quoted-html style="padding-left:12px">orig</div>');
expect(out).toContain('data-quoted-html');
});
});
describe('collapsePlainTextQuotes', () => {
const raw = [
'Hallo Linus,',
'',
'ich sehe eben, dass du das gefixt hast, thx!',
'',
'On Dienstag, 31. Maerz 2026 13:56 Richard Weinberger wrote:',
'> On Dienstag, 31. Maerz 2026 00:28 Richard Weinberger wrote:',
'> > Hi!',
'> ',
'> Here are some more issues:',
'> ',
'> - getClientIP() vulnerable to IP spoofing.',
'',
'',
'-- ',
'sigma star gmbh | Eduard-Bodem-Gasse 6, 6020 Innsbruck, AUT',
].join('\n');
it('wraps the trailing quote run in <details>, keeping reply, attribution and signature visible', () => {
const out = collapsePlainTextQuotes(plainTextToSafeHtml(raw), labels);
expect(out).toContain('<details>');
expect(out).toContain('•••');
expect(out).toContain(`title="${labels.show}"`);
// Quote run is inside the details, attribution and signature outside.
const details = out.slice(out.indexOf('<details>'), out.indexOf('</details>'));
expect(details).toContain('getClientIP()');
// The nested quoted attribution (00:28) collapses, the outer one (13:56) stays.
expect(details).toContain('00:28');
expect(details).not.toContain('13:56');
const outside = out.replace(details, '');
expect(outside).toContain('Hallo Linus,');
expect(outside).toContain('13:56 Richard Weinberger wrote:');
expect(outside).toContain('sigma star gmbh');
});
it('round-trips through sanitizePlainTextRenderedHtml', () => {
const out = sanitizePlainTextRenderedHtml(collapsePlainTextQuotes(plainTextToSafeHtml(raw), labels));
expect(out).toContain('<details>');
expect(out).toContain('<summary');
expect(out).toContain(`title="${labels.show}"`);
});
it('does not collapse a bottom-posted reply (content after the quote run)', () => {
const text = 'Tom wrote:\n> what do you think?\n\nSounds good to me!';
const safe = plainTextToSafeHtml(text);
expect(collapsePlainTextQuotes(safe, labels)).toBe(safe);
});
it('does not collapse when the quote is the whole message', () => {
const text = '> forwarded line one\n> forwarded line two';
const safe = plainTextToSafeHtml(text);
expect(collapsePlainTextQuotes(safe, labels)).toBe(safe);
});
it('leaves mail without quote lines untouched', () => {
const safe = plainTextToSafeHtml('just a normal message\nwith two lines');
expect(collapsePlainTextQuotes(safe, labels)).toBe(safe);
});
it('never treats a ">" mid-line as a quote', () => {
const safe = plainTextToSafeHtml('a -> b\nx > y comparison');
expect(collapsePlainTextQuotes(safe, labels)).toBe(safe);
});
});
+9 -3
View File
@@ -8,7 +8,11 @@ import DOMPurify from 'dompurify';
*/ */
export const EMAIL_SANITIZE_CONFIG = { export const EMAIL_SANITIZE_CONFIG = {
ADD_TAGS: [], ADD_TAGS: [],
ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'], // data-quoted-html is Bulwark's own reply-quote marker (see
// components/email/quoted-html.ts). Explicitly whitelisted despite
// ALLOW_DATA_ATTR:false so the viewer can detect and collapse the quoted
// original (lib/quote-collapse.ts); it's inert otherwise.
ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color', 'data-quoted-html'],
ALLOW_DATA_ATTR: false, ALLOW_DATA_ATTR: false,
FORCE_BODY: true, FORCE_BODY: true,
// Allow blob: URIs so authenticated inline images (CID) are not stripped. // Allow blob: URIs so authenticated inline images (CID) are not stripped.
@@ -182,8 +186,10 @@ export function sanitizeI18nHtml(html: string): string {
* future code path passes raw HTML in by mistake. * future code path passes raw HTML in by mistake.
*/ */
const PLAIN_TEXT_RENDERED_CONFIG = { const PLAIN_TEXT_RENDERED_CONFIG = {
ALLOWED_TAGS: ['a', 'br', 'p', 'div', 'span'], // details/summary/title carry the script-less quote-collapse toggle emitted
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style'], // by collapsePlainTextQuotes (lib/quote-collapse.ts).
ALLOWED_TAGS: ['a', 'br', 'p', 'div', 'span', 'details', 'summary'],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style', 'title'],
// DOMPurify URI-tests every attribute value not on its URI-safe list, so the // DOMPurify URI-tests every attribute value not on its URI-safe list, so the
// strict ALLOWED_URI_REGEXP below would strip target="_blank" (and rel) — // strict ALLOWED_URI_REGEXP below would strip target="_blank" (and rel) —
// "_blank" is not a URI. This branch renders into the main document rather // "_blank" is not a URI. This branch renders into the main document rather
+259
View File
@@ -0,0 +1,259 @@
/**
* Quote collapsing for the email viewer (issue #480).
*
* When a reply carries the original message as a trailing quote, hide it by
* default and inject a Gmail-style "•••" pill that toggles it. Runs against
* the live iframe DOM from the parent frame AFTER sanitization, so the
* injected button and its click listener are not subject to the sanitizer or
* the iframe's script-blocking CSP (parent-attached DOM listeners still fire).
*
* Two kinds of markers:
* - container: an element that wraps the entire quoted original. Hiding the
* element hides the quote.
* - separator: a header/divider element ("From: …" block, attribution line)
* that the quoted original FOLLOWS as siblings. Everything from the marker
* to the end of the body is hidden.
*/
// Order matters only for readability - document order decides which marker
// wins when several are present (querySelector on the comma-joined list).
const CONTAINER_SELECTORS = [
// Bulwark's own reply/forward island (see components/email/quoted-html.ts).
// The attribute survives sanitization via an explicit ADD_ATTR entry.
'div[data-quoted-html]',
// Gmail: gmail_quote_container (2023+) wraps gmail_attr + gmail_quote.
'div.gmail_quote_container',
'div.gmail_quote',
// Apple Mail / Thunderbird quoted body.
'blockquote[type="cite"]',
'div.yahoo_quoted',
'blockquote.protonmail_quote',
'div.protonmail_quote',
];
const SEPARATOR_SELECTORS = [
// Outlook (desktop + OWA): "From:/Sent:/To:/Subject:" header block; the
// quoted message body follows as siblings.
'#divRplyFwdMsg',
'#appendonsend',
// Thunderbird attribution line ("On …, X wrote:"); the blockquote follows.
'div.moz-cite-prefix',
];
const ALL_SELECTORS = [...CONTAINER_SELECTORS, ...SEPARATOR_SELECTORS].join(',');
/** Marks the injected toggle button (also the idempotence guard). */
export const QUOTE_TOGGLE_ATTR = 'data-quote-toggle';
/** Marks elements hidden by the collapse; value stores the original inline display. */
export const QUOTE_COLLAPSED_ATTR = 'data-quote-collapsed';
export interface QuoteCollapseLabels {
/** Accessible label/tooltip while collapsed, e.g. "Show quoted text". */
show: string;
/** Accessible label/tooltip while expanded, e.g. "Hide quoted text". */
hide: string;
}
/** True when any text node outside `marker` on the given side of it has visible content. */
function hasVisibleContent(body: HTMLElement, marker: Element, side: 'before' | 'after'): boolean {
const positionBit = side === 'before'
? Node.DOCUMENT_POSITION_PRECEDING
: Node.DOCUMENT_POSITION_FOLLOWING;
const doc = body.ownerDocument;
const walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const rel = marker.compareDocumentPosition(node);
// Skip the marker's own subtree and anything not on the requested side.
if (rel & Node.DOCUMENT_POSITION_CONTAINED_BY) continue;
if (!(rel & positionBit)) continue;
if (node.nodeType === Node.TEXT_NODE) {
if ((node.nodeValue || '').trim() !== '') return true;
} else if ((node as Element).tagName === 'IMG') {
// An image counts as content (image-only replies exist), but not the
// 1x1 placeholders left by blocked external images.
const img = node as HTMLImageElement;
if (!img.hasAttribute('data-blocked-src') && (img.style.display !== 'none')) return true;
}
}
return false;
}
/**
* Fallback for replies whose quote is a bare <blockquote> (no type/class -
* e.g. Bulwark's own reply to a plain-text original, and various mobile
* clients): an outermost blockquote directly preceded by an attribution line.
* Attribution is sniffed language-agnostically as the nearest preceding text
* ending with a colon ("On …, X wrote:", "Am … schrieb X:", "… a écrit :").
*/
function findAttributedBlockquote(body: HTMLElement): Element | null {
for (const bq of Array.from(body.querySelectorAll('blockquote'))) {
if (bq.parentElement?.closest('blockquote')) continue; // outermost only
if (precedingTextEndsWithColon(body, bq)) return bq;
}
return null;
}
/** True when the last non-blank text node before `el` ends with a colon. */
function precedingTextEndsWithColon(body: HTMLElement, el: Element): boolean {
const walker = body.ownerDocument.createTreeWalker(body, NodeFilter.SHOW_TEXT);
let last: string | null = null;
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const rel = el.compareDocumentPosition(node);
if (rel & Node.DOCUMENT_POSITION_CONTAINED_BY) continue;
if (!(rel & Node.DOCUMENT_POSITION_PRECEDING)) break; // reached/passed el
const text = (node.nodeValue || '').trim();
if (text !== '') last = text;
}
return last !== null && last.endsWith(':');
}
/** Hide an element, remembering its original inline display for restore. */
function hideElement(el: HTMLElement): void {
el.setAttribute(QUOTE_COLLAPSED_ATTR, el.style.display || '');
el.style.display = 'none';
}
/**
* The elements to hide for a separator marker: the marker itself plus
* everything after it in document order (climbing to body handles markers
* nested inside wrapper divs).
*/
function collectSeparatorRange(body: HTMLElement, marker: Element): HTMLElement[] {
const range: HTMLElement[] = [marker as HTMLElement];
let el: Element | null = marker;
while (el && el !== body) {
for (let sib = el.nextElementSibling; sib; sib = sib.nextElementSibling) {
range.push(sib as HTMLElement);
}
el = el.parentElement;
}
return range;
}
/**
* Detect a trailing quoted original in `doc` and collapse it behind a "•••"
* toggle button. No-op (returns false) when no marker is found, when the
* quote IS the whole message (nothing visible before it - collapsing would
* render the mail as a lone button), or - for container markers - when
* visible content follows the quote (interleaved/bottom-posted reply, where
* hiding "the rest" would swallow real content).
*
* Idempotent per document. Returns true when a quote was collapsed.
*/
export function setupQuoteCollapse(doc: Document, labels: QuoteCollapseLabels): boolean {
const body = doc.body;
if (!body || body.querySelector(`[${QUOTE_TOGGLE_ATTR}]`)) return false;
const marker = body.querySelector(ALL_SELECTORS) ?? findAttributedBlockquote(body);
if (!marker) return false;
const isSeparator = SEPARATOR_SELECTORS.some((sel) => marker.matches(sel));
// Never collapse the entire message down to just the toggle.
if (!hasVisibleContent(body, marker, 'before')) return false;
// Interleaved reply: real content after the quote container - leave as is.
if (!isSeparator && hasVisibleContent(body, marker, 'after')) return false;
const hidden = isSeparator
? collectSeparatorRange(body, marker)
: [marker as HTMLElement];
const button = doc.createElement('button');
button.type = 'button';
button.setAttribute(QUOTE_TOGGLE_ATTR, '');
button.setAttribute('aria-expanded', 'false');
button.setAttribute('aria-label', labels.show);
button.title = labels.show;
button.textContent = '•••';
// Inline styles only: the iframe has no app CSS, and style-src allows
// inline. Neutral grays read fine after the dark-mode invert filter too.
button.style.cssText =
'display:inline-block;margin:12px 0 4px;padding:3px 12px;border:none;' +
'border-radius:999px;background:#e3e6ea;color:#3c4043;font-size:11px;' +
'line-height:1;letter-spacing:2px;cursor:pointer;font-family:inherit;';
button.addEventListener('mouseenter', () => { button.style.background = '#d4d8dd'; });
button.addEventListener('mouseleave', () => { button.style.background = '#e3e6ea'; });
marker.parentNode?.insertBefore(button, marker);
hidden.forEach(hideElement);
button.addEventListener('click', () => {
const expanded = button.getAttribute('aria-expanded') === 'true';
hidden.forEach((el) => {
if (expanded) {
hideElement(el);
} else {
el.style.display = el.getAttribute(QUOTE_COLLAPSED_ATTR) || '';
el.removeAttribute(QUOTE_COLLAPSED_ATTR);
}
});
button.setAttribute('aria-expanded', String(!expanded));
const label = expanded ? labels.show : labels.hide;
button.setAttribute('aria-label', label);
button.title = label;
});
return true;
}
const escapeAttr = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
/**
* Collapse the trailing ">"-quoted block of a plain-text email.
*
* Operates on the ESCAPED html produced by plainTextToSafeHtml (lines are
* plain text with entities plus <a> tags, still newline-separated), because
* the plain-text branch renders into the main DOM via dangerouslySetInnerHTML
* - there is no post-render hook to attach a JS toggle to. Instead the quote
* run is wrapped in native <details>/<summary>, which toggles without any
* script and survives sanitizePlainTextRenderedHtml (details/summary are
* whitelisted there for exactly this markup).
*
* Collapsed: the last run of "&gt;"-prefixed lines (interior blank lines
* included). Left visible: everything before it (reply + attribution line),
* and a trailing "-- " signature block after it. No-op when real content
* follows the run (bottom-posted/interleaved reply), when there is no content
* before it, or when there are no quote lines at all.
*/
export function collapsePlainTextQuotes(safeHtml: string, labels: QuoteCollapseLabels): string {
const lines = safeHtml.split('\n');
const isQuote = (l: string) => /^\s*&gt;/.test(l);
let end = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (isQuote(lines[i])) { end = i; break; }
}
if (end === -1) return safeHtml;
// Everything after the run must be blank or a "-- " signature block.
let inSignature = false;
for (let i = end + 1; i < lines.length; i++) {
if (inSignature || lines[i].trim() === '') continue;
if (/^--\s*$/.test(lines[i].trim())) { inSignature = true; continue; }
return safeHtml;
}
// Walk back to the start of the run; blank lines BETWEEN quote lines are
// part of it (start only ever lands on a quote line).
let start = end;
for (let i = end - 1; i >= 0; i--) {
if (isQuote(lines[i])) start = i;
else if (lines[i].trim() !== '') break;
}
// Never collapse the entire message down to just the toggle.
if (!lines.slice(0, start).some((l) => l.trim() !== '')) return safeHtml;
const before = lines.slice(0, start).join('\n');
const quoted = lines.slice(start, end + 1).join('\n');
const after = lines.slice(end + 1).join('\n');
// Same pill look as the HTML-path toggle button; list-style:none hides the
// native disclosure triangle.
const summary =
`<summary title="${escapeAttr(labels.show)}" style="display:inline-block;` +
'list-style:none;margin:4px 0;padding:3px 12px;border-radius:999px;' +
'background:#e3e6ea;color:#3c4043;font-size:11px;line-height:1;' +
`letter-spacing:2px;cursor:pointer;">•••</summary>`;
return `${before}\n<details>${summary}${quoted}</details>${after ? '\n' + after : ''}`;
}
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(بلا موضوع)", "no_subject": "(بلا موضوع)",
"no_body_content": "(لا يوجد محتوى نصي متاح)", "no_body_content": "(لا يوجد محتوى نصي متاح)",
"no_preview_available": "لا تتوفر معاينة", "no_preview_available": "لا تتوفر معاينة",
"show_quoted_text": "إظهار النص المقتبس",
"hide_quoted_text": "إخفاء النص المقتبس",
"loading_email": "جارٍ تحميل الرسالة...", "loading_email": "جارٍ تحميل الرسالة...",
"loading": "جارٍ التحميل...", "loading": "جارٍ التحميل...",
"reply": "رد", "reply": "رد",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(Bez předmětu)", "no_subject": "(Bez předmětu)",
"no_body_content": "(Žádný obsah těla)", "no_body_content": "(Žádný obsah těla)",
"no_preview_available": "Náhled není k dispozici", "no_preview_available": "Náhled není k dispozici",
"show_quoted_text": "Zobrazit citovaný text",
"hide_quoted_text": "Skrýt citovaný text",
"loading_email": "Načítání zprávy...", "loading_email": "Načítání zprávy...",
"loading": "Načítání...", "loading": "Načítání...",
"reply": "Odpovědět", "reply": "Odpovědět",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(Intet emne)", "no_subject": "(Intet emne)",
"no_body_content": "(Intet indhold tilgængeligt)", "no_body_content": "(Intet indhold tilgængeligt)",
"no_preview_available": "Forhåndsvisning ikke tilgængelig", "no_preview_available": "Forhåndsvisning ikke tilgængelig",
"show_quoted_text": "Vis citeret tekst",
"hide_quoted_text": "Skjul citeret tekst",
"loading_email": "Indlæser e-mail...", "loading_email": "Indlæser e-mail...",
"loading": "Indlæser...", "loading": "Indlæser...",
"reply": "Svar", "reply": "Svar",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(Kein Betreff)", "no_subject": "(Kein Betreff)",
"no_body_content": "(Kein Inhalt verfügbar)", "no_body_content": "(Kein Inhalt verfügbar)",
"no_preview_available": "Keine Vorschau verfügbar", "no_preview_available": "Keine Vorschau verfügbar",
"show_quoted_text": "Zitierten Text anzeigen",
"hide_quoted_text": "Zitierten Text ausblenden",
"loading_email": "E-Mail wird geladen...", "loading_email": "E-Mail wird geladen...",
"loading": "Lädt...", "loading": "Lädt...",
"reply": "Antworten", "reply": "Antworten",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(No Subject)", "no_subject": "(No Subject)",
"no_body_content": "(No body content available)", "no_body_content": "(No body content available)",
"no_preview_available": "No preview available", "no_preview_available": "No preview available",
"show_quoted_text": "Show quoted text",
"hide_quoted_text": "Hide quoted text",
"loading_email": "Loading email...", "loading_email": "Loading email...",
"loading": "Loading...", "loading": "Loading...",
"reply": "Reply", "reply": "Reply",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(Sin Asunto)", "no_subject": "(Sin Asunto)",
"no_body_content": "(Sin contenido disponible)", "no_body_content": "(Sin contenido disponible)",
"no_preview_available": "Vista previa no disponible", "no_preview_available": "Vista previa no disponible",
"show_quoted_text": "Mostrar texto citado",
"hide_quoted_text": "Ocultar texto citado",
"loading_email": "Cargando correo...", "loading_email": "Cargando correo...",
"loading": "Cargando...", "loading": "Cargando...",
"reply": "Responder", "reply": "Responder",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(بدون موضوع)", "no_subject": "(بدون موضوع)",
"no_body_content": "(محتوایی موجود نیست)", "no_body_content": "(محتوایی موجود نیست)",
"no_preview_available": "پیش‌نمایشی در دسترس نیست", "no_preview_available": "پیش‌نمایشی در دسترس نیست",
"show_quoted_text": "نمایش متن نقل‌قول‌شده",
"hide_quoted_text": "پنهان کردن متن نقل‌قول‌شده",
"loading_email": "در حال بارگذاری ایمیل...", "loading_email": "در حال بارگذاری ایمیل...",
"loading": "در حال بارگذاری...", "loading": "در حال بارگذاری...",
"reply": "پاسخ", "reply": "پاسخ",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(Sans objet)", "no_subject": "(Sans objet)",
"no_body_content": "(Aucun contenu disponible)", "no_body_content": "(Aucun contenu disponible)",
"no_preview_available": "Aucun aperçu disponible", "no_preview_available": "Aucun aperçu disponible",
"show_quoted_text": "Afficher le texte cité",
"hide_quoted_text": "Masquer le texte cité",
"loading_email": "Chargement de l'email...", "loading_email": "Chargement de l'email...",
"loading": "Chargement...", "loading": "Chargement...",
"reply": "Répondre", "reply": "Répondre",
+2
View File
@@ -478,6 +478,8 @@
}, },
"no_body_content": "(אין תוכן גוף זמין)", "no_body_content": "(אין תוכן גוף זמין)",
"no_preview_available": "אין תצוגה מקדימה זמינה", "no_preview_available": "אין תצוגה מקדימה זמינה",
"show_quoted_text": "הצג טקסט מצוטט",
"hide_quoted_text": "הסתר טקסט מצוטט",
"unread": "סמן כלא נקרא", "unread": "סמן כלא נקרא",
"read": "קרא", "read": "קרא",
"spam_short": "דואר זבל", "spam_short": "דואר זבל",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(Nincs tárgy)", "no_subject": "(Nincs tárgy)",
"no_body_content": "(Nincs elérhető tartalom)", "no_body_content": "(Nincs elérhető tartalom)",
"no_preview_available": "Nincs előnézet", "no_preview_available": "Nincs előnézet",
"show_quoted_text": "Idézett szöveg megjelenítése",
"hide_quoted_text": "Idézett szöveg elrejtése",
"loading_email": "E-mail betöltése...", "loading_email": "E-mail betöltése...",
"loading": "Betöltés...", "loading": "Betöltés...",
"reply": "Válasz", "reply": "Válasz",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(Nessun oggetto)", "no_subject": "(Nessun oggetto)",
"no_body_content": "(Nessun contenuto disponibile)", "no_body_content": "(Nessun contenuto disponibile)",
"no_preview_available": "Anteprima non disponibile", "no_preview_available": "Anteprima non disponibile",
"show_quoted_text": "Mostra testo citato",
"hide_quoted_text": "Nascondi testo citato",
"loading_email": "Caricamento messaggio...", "loading_email": "Caricamento messaggio...",
"loading": "Caricamento...", "loading": "Caricamento...",
"reply": "Rispondi", "reply": "Rispondi",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(件名なし)", "no_subject": "(件名なし)",
"no_body_content": "(本文がありません)", "no_body_content": "(本文がありません)",
"no_preview_available": "プレビューは利用できません", "no_preview_available": "プレビューは利用できません",
"show_quoted_text": "引用テキストを表示",
"hide_quoted_text": "引用テキストを非表示",
"loading_email": "メールを読み込み中...", "loading_email": "メールを読み込み中...",
"loading": "読み込み中...", "loading": "読み込み中...",
"reply": "返信", "reply": "返信",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(제목 없음)", "no_subject": "(제목 없음)",
"no_body_content": "(본문 내용 없음)", "no_body_content": "(본문 내용 없음)",
"no_preview_available": "미리 보기를 사용할 수 없습니다", "no_preview_available": "미리 보기를 사용할 수 없습니다",
"show_quoted_text": "인용된 텍스트 표시",
"hide_quoted_text": "인용된 텍스트 숨기기",
"loading_email": "메일을 불러오는 중...", "loading_email": "메일을 불러오는 중...",
"loading": "불러오는 중...", "loading": "불러오는 중...",
"reply": "답장", "reply": "답장",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(nav temata)", "no_subject": "(nav temata)",
"no_body_content": "(nav satura)", "no_body_content": "(nav satura)",
"no_preview_available": "Priekšskatījums nav pieejams", "no_preview_available": "Priekšskatījums nav pieejams",
"show_quoted_text": "Rādīt citēto tekstu",
"hide_quoted_text": "Paslēpt citēto tekstu",
"loading_email": "Ielādē vēstuli...", "loading_email": "Ielādē vēstuli...",
"loading": "Ielādē...", "loading": "Ielādē...",
"reply": "Atbildēt", "reply": "Atbildēt",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(Geen onderwerp)", "no_subject": "(Geen onderwerp)",
"no_body_content": "(Geen inhoud beschikbaar)", "no_body_content": "(Geen inhoud beschikbaar)",
"no_preview_available": "Geen voorbeeld beschikbaar", "no_preview_available": "Geen voorbeeld beschikbaar",
"show_quoted_text": "Geciteerde tekst tonen",
"hide_quoted_text": "Geciteerde tekst verbergen",
"loading_email": "E-mail laden...", "loading_email": "E-mail laden...",
"loading": "Laden...", "loading": "Laden...",
"reply": "Beantwoorden", "reply": "Beantwoorden",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(Bez tematu)", "no_subject": "(Bez tematu)",
"no_body_content": "(Brak treści)", "no_body_content": "(Brak treści)",
"no_preview_available": "Podgląd niedostępny", "no_preview_available": "Podgląd niedostępny",
"show_quoted_text": "Pokaż cytowany tekst",
"hide_quoted_text": "Ukryj cytowany tekst",
"loading_email": "Ładowanie wiadomości...", "loading_email": "Ładowanie wiadomości...",
"loading": "Ładowanie...", "loading": "Ładowanie...",
"reply": "Odpowiedz", "reply": "Odpowiedz",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(Sem Assunto)", "no_subject": "(Sem Assunto)",
"no_body_content": "(Sem conteúdo disponível)", "no_body_content": "(Sem conteúdo disponível)",
"no_preview_available": "Pré-visualização não disponível", "no_preview_available": "Pré-visualização não disponível",
"show_quoted_text": "Mostrar texto citado",
"hide_quoted_text": "Ocultar texto citado",
"loading_email": "Carregando e-mail...", "loading_email": "Carregando e-mail...",
"loading": "Carregando...", "loading": "Carregando...",
"reply": "Responder", "reply": "Responder",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(Fără subiect)", "no_subject": "(Fără subiect)",
"no_body_content": "(Nu există conținut disponibil)", "no_body_content": "(Nu există conținut disponibil)",
"no_preview_available": "Nu este disponibilă previzualizarea", "no_preview_available": "Nu este disponibilă previzualizarea",
"show_quoted_text": "Afișează textul citat",
"hide_quoted_text": "Ascunde textul citat",
"loading_email": "Se încarcă e-mailul...", "loading_email": "Se încarcă e-mailul...",
"loading": "Se încarcă...", "loading": "Se încarcă...",
"reply": "Răspunde", "reply": "Răspunde",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(Без темы)", "no_subject": "(Без темы)",
"no_body_content": "(Содержимое отсутствует)", "no_body_content": "(Содержимое отсутствует)",
"no_preview_available": "Предварительный просмотр недоступен", "no_preview_available": "Предварительный просмотр недоступен",
"show_quoted_text": "Показать цитируемый текст",
"hide_quoted_text": "Скрыть цитируемый текст",
"loading_email": "Загрузка письма...", "loading_email": "Загрузка письма...",
"loading": "Загрузка...", "loading": "Загрузка...",
"reply": "Ответить", "reply": "Ответить",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(Bez predmetu)", "no_subject": "(Bez predmetu)",
"no_body_content": "(Obsah správy nie je dostupný)", "no_body_content": "(Obsah správy nie je dostupný)",
"no_preview_available": "Náhľad nie je dostupný", "no_preview_available": "Náhľad nie je dostupný",
"show_quoted_text": "Zobraziť citovaný text",
"hide_quoted_text": "Skryť citovaný text",
"loading_email": "Načítavanie e-mailu...", "loading_email": "Načítavanie e-mailu...",
"loading": "Načítavanie...", "loading": "Načítavanie...",
"reply": "Odpovedať", "reply": "Odpovedať",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(Konu Yok)", "no_subject": "(Konu Yok)",
"no_body_content": "(İçerik yok)", "no_body_content": "(İçerik yok)",
"no_preview_available": "Önizleme kullanılamıyor", "no_preview_available": "Önizleme kullanılamıyor",
"show_quoted_text": "Alıntılanan metni göster",
"hide_quoted_text": "Alıntılanan metni gizle",
"loading_email": "E-posta yükleniyor...", "loading_email": "E-posta yükleniyor...",
"loading": "Yükleniyor...", "loading": "Yükleniyor...",
"reply": "Yanıtla", "reply": "Yanıtla",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(без теми)", "no_subject": "(без теми)",
"no_body_content": "(вміст відсутній)", "no_body_content": "(вміст відсутній)",
"no_preview_available": "Попередній перегляд недоступний", "no_preview_available": "Попередній перегляд недоступний",
"show_quoted_text": "Показати цитований текст",
"hide_quoted_text": "Приховати цитований текст",
"loading_email": "Завантаження електронної пошти...", "loading_email": "Завантаження електронної пошти...",
"loading": "Завантаження...", "loading": "Завантаження...",
"reply": "Відповісти", "reply": "Відповісти",
+2
View File
@@ -277,6 +277,8 @@
"no_subject": "(无主题)", "no_subject": "(无主题)",
"no_body_content": "(无正文内容)", "no_body_content": "(无正文内容)",
"no_preview_available": "无可用预览", "no_preview_available": "无可用预览",
"show_quoted_text": "显示引用文本",
"hide_quoted_text": "隐藏引用文本",
"loading_email": "正在加载邮件...", "loading_email": "正在加载邮件...",
"loading": "正在加载…", "loading": "正在加载…",
"reply": "回复", "reply": "回复",