fix: render HTML-only mail as HTML, keep stylesheets and block every external-content vector in the reader (#46, #49)

This commit is contained in:
Linus Rath
2026-08-29 18:15:22 +02:00
parent dae5700688
commit 06742ef7d5
14 changed files with 1390 additions and 161 deletions
+204 -115
View File
@@ -1,28 +1,25 @@
import React from 'react'; import React from 'react';
import { View, StyleSheet, Linking, Platform, Text, Pressable } from 'react-native'; import { View, StyleSheet, Linking, Platform, Text, Pressable } from 'react-native';
import { WebView, type WebViewMessageEvent } from 'react-native-webview'; import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Image as ImageIcon, ShieldCheck } from 'lucide-react-native'; import { Image as ImageIcon, ShieldCheck } from 'lucide-react-native';
import type { Email } from '../api/types'; import type { Email } from '../api/types';
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody, hasNativeDarkMode } from '../lib/email-html'; import {
import { jmapClient } from '../api/jmap-client'; prepareEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs,
} from '../lib/email-html';
import { pickEmailBody, selectRenderableHtml } from '../lib/email-body';
import { buildQuoteCollapseScript, collapsePlainTextQuotes } from '../lib/quote-collapse';
import { parseMailtoUrl } from '../lib/unsubscribe';
import { fetchInlineImageDataUri } from '../lib/email-export';
import { useSettingsStore } from '../stores/settings-store'; import { useSettingsStore } from '../stores/settings-store';
import { useContactsStore } from '../stores/contacts-store'; import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
import { spacing, typography, type ThemePalette } from '../theme/tokens'; import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors, useResolvedTheme } from '../theme/colors'; import { useColors, useResolvedTheme } from '../theme/colors';
import type { RootStackParamList } from '../navigation/types';
// Largest inline image we'll pull inline as a data: URI. Anything bigger gets type Nav = NativeStackNavigationProp<RootStackParamList>;
// skipped so we don't freeze the JS thread base64-encoding megabytes.
const MAX_INLINE_IMAGE_BYTES = 2 * 1024 * 1024;
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)));
}
return global.btoa ? global.btoa(binary) : btoa(binary);
}
interface EmailBodyViewProps { interface EmailBodyViewProps {
email: Email; email: Email;
@@ -38,22 +35,14 @@ interface EmailBodyViewProps {
// the surrounding vertical scroll / horizontal pager so a zoom gesture can't // the surrounding vertical scroll / horizontal pager so a zoom gesture can't
// scroll the pane away or switch mails. // scroll the pane away or switch mails.
onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void; onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void;
} /** Per-message override of the light/dark rendering (More sheet toggle). */
themeOverride?: 'light' | 'dark' | null;
function extractHtmlBody(email: Email): string | null { /**
for (const part of email.htmlBody ?? []) { * Replace the message's own body - used when the outer message is an empty
const v = part.partId ? email.bodyValues?.[part.partId]?.value : undefined; * envelope around a message/rfc822 attachment or a TNEF winmail.dat and the
if (v) return v; * real content was extracted client-side.
} */
return null; bodyOverride?: { html?: string | null; text?: string | null } | null;
}
function extractTextBody(email: Email): string | null {
for (const part of email.textBody ?? []) {
const v = part.partId ? email.bodyValues?.[part.partId]?.value : undefined;
if (v) return v;
}
return null;
} }
// Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The // Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The
@@ -80,10 +69,13 @@ const DARK_REINVERT_SCRIPT = `
if (['IMG','VIDEO','SVG','CANVAS','OBJECT','EMBED'].indexOf(tag) !== -1) return; if (['IMG','VIDEO','SVG','CANVAS','OBJECT','EMBED'].indexOf(tag) !== -1) return;
var computed = win.getComputedStyle(el); var computed = win.getComputedStyle(el);
if (computed.backgroundImage && computed.backgroundImage !== 'none') { if (computed.backgroundImage && computed.backgroundImage !== 'none') {
// Only re-invert if this container doesn't have media children. // Re-invert so the background image returns to its original
if (!el.querySelector('img, video, svg, canvas, object, embed')) { // appearance. Matches the CSS background-image rules: re-invert even
el.style.filter = 'invert(1) hue-rotate(180deg)'; // when media is nested, then cancel the per-image re-invert on that
} // media so it isn't left double-inverted.
el.style.filter = 'invert(1) hue-rotate(180deg)';
el.querySelectorAll('img, video, svg, canvas, object, embed, input[type="image"]')
.forEach(function (m) { m.style.filter = 'none'; });
} }
}); });
@@ -156,6 +148,57 @@ const DARK_REINVERT_SCRIPT = `
})(); })();
`; `;
// Image fix-ups, mirroring the webmail's post-render passes:
// - collapse empty <td>/<div> wrappers left behind by blocked images so a
// blocked newsletter doesn't show rows of empty cells;
// - hide images that fail to load (dead/unreachable URLs) rather than leave
// the engine's broken-image glyph and alt text as stray labels.
const IMG_FIXUP_SCRIPT = `
(function () {
if (document.__rnImgFixupDone) return;
var doc = document;
if (!doc.body) return;
document.__rnImgFixupDone = true;
doc.querySelectorAll('img[data-blocked-src]').forEach(function (img) {
var el = img.parentElement;
while (el && el !== doc.body) {
if (el.tagName === 'TD' || el.tagName === 'TH' ||
(el.tagName === 'DIV' && el.parentElement && el.parentElement.tagName === 'TD')) {
var hasText = (el.textContent || '').replace(/[\\s\\u00A0]+/g, '').trim();
var hasMedia = el.querySelector('img:not([data-blocked-src]), video, canvas');
var hasLinks = el.querySelector('a[href]');
if (!hasText && !hasMedia && !hasLinks) {
el.setAttribute('data-blocked-collapsed-style', el.style.cssText);
el.style.display = 'none';
el.style.height = '0';
el.style.padding = '0';
el.style.overflow = 'hidden';
}
break;
}
if (el.tagName === 'TABLE' || el.tagName === 'TR') break;
el = el.parentElement;
}
});
function hideIfBroken(img) {
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) {
img.style.display = 'none';
}
}
doc.querySelectorAll('img').forEach(function (img) {
if (img.hasAttribute('data-blocked-src')) return;
if (img.complete) {
hideIfBroken(img);
} else {
img.addEventListener('error', function () { img.style.display = 'none'; }, { once: true });
img.addEventListener('load', function () { hideIfBroken(img); }, { once: true });
}
});
})();
`;
const HEIGHT_REPORTER = ` const HEIGHT_REPORTER = `
(function () { (function () {
// One-time: wrap body contents in a block-level div we can measure reliably. // One-time: wrap body contents in a block-level div we can measure reliably.
@@ -460,16 +503,19 @@ const PINCH_ZOOM = `
`; `;
export default function EmailBodyView({ export default function EmailBodyView({
email, senderEmail, jmapAccountId, onSwipe, onZoomChange, email, senderEmail, jmapAccountId, onSwipe, onZoomChange, themeOverride, bodyOverride,
}: EmailBodyViewProps) { }: EmailBodyViewProps) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const navigation = useNavigation<Nav>();
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy); const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted); const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender); const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook); const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode); const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments); const plainTextFont = useSettingsStore((s) => s.plainTextFont);
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
const contacts = useContactsStore((s) => s.contacts); const contacts = useContactsStore((s) => s.contacts);
const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails); const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails);
const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded); const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded);
@@ -488,9 +534,9 @@ export default function EmailBodyView({
const addressBookEmails = React.useMemo(() => { const addressBookEmails = React.useMemo(() => {
if (!trustedSendersAddressBook) return null; if (!trustedSendersAddressBook) return null;
const out = new Set<string>(); const out = new Set<string>();
for (const c of contacts) { for (const card of contacts) {
if (!c.emails) continue; if (!card.emails) continue;
for (const e of Object.values(c.emails)) { for (const e of Object.values(card.emails)) {
if (e?.address) out.add(e.address.toLowerCase()); if (e?.address) out.add(e.address.toLowerCase());
} }
} }
@@ -498,18 +544,21 @@ export default function EmailBodyView({
}, [contacts, trustedSendersAddressBook]); }, [contacts, trustedSendersAddressBook]);
// Most marketing email is authored against a white background, so dark-mode // Most marketing email is authored against a white background, so dark-mode
// inversion can wreck logos/banners. With this flag the user opts to render // inversion can wreck logos/banners. With this flag the user opts to render
// emails on a light surface even while the rest of the app is dark. // emails on a light surface even while the rest of the app is dark; the
const renderAsDark = !emailAlwaysLightMode && resolvedTheme === 'dark'; // per-message toggle in the More sheet overrides both.
const renderAsDark = themeOverride
? themeOverride === 'dark'
: !emailAlwaysLightMode && resolvedTheme === 'dark';
const html = React.useMemo(() => extractHtmlBody(email), [email]); // RFC 8621 §4.1.4: an HTML-only message exposes the same part in textBody
const text = React.useMemo(() => extractTextBody(email), [email]); // and htmlBody, so the text part is only an alternative when its partId
// Prefer textBody when the HTML is a minimal auto-generated wrapper that would // differs (native #46). `pickEmailBody` routes by part type and id.
// collapse newlines (mirrors webmail's hasMeaningfulHtmlBody fallback). const picked = React.useMemo(() => {
const rawHtml = React.useMemo( if (bodyOverride) return { html: bodyOverride.html ?? null, text: bodyOverride.text ?? null };
() => (html && (!text || hasMeaningfulHtmlBody(html)) ? html : null), return pickEmailBody(email);
[html, text], }, [email, bodyOverride]);
); const rawHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]);
const hasRemote = rawHtml ? hasRemoteContent(rawHtml) : false; const text = picked.text;
const trusted = senderEmail const trusted = senderEmail
? isSenderTrusted(senderEmail) ? isSenderTrusted(senderEmail)
|| trustedSenderEmails.includes(senderEmail.toLowerCase()) || trustedSenderEmails.includes(senderEmail.toLowerCase())
@@ -522,14 +571,12 @@ export default function EmailBodyView({
setAllowOnce(false); setAllowOnce(false);
}, [email.id]); }, [email.id]);
// Policy-driven gate (like the webmail's `externalBlocked`): the strict CSP
// is in effect whenever the policy is not "allow" and the sender is not
// trusted, regardless of what the detection pass finds - the pass only
// drives the banner and the placeholder swap.
const shouldBlock = const shouldBlock =
hasRemote && !!rawHtml &&
!trusted &&
!allowOnce &&
externalContentPolicy !== 'allow';
const showBanner =
hasRemote &&
!trusted && !trusted &&
!allowOnce && !allowOnce &&
externalContentPolicy !== 'allow'; externalContentPolicy !== 'allow';
@@ -555,8 +602,7 @@ export default function EmailBodyView({
} }
const toFetch = refs const toFetch = refs
.map((ref) => ({ ref, att: byCid.get(ref) })) .map((ref) => ({ ref, att: byCid.get(ref) }))
.filter((x): x is { ref: string; att: NonNullable<ReturnType<typeof byCid.get>> } => !!x.att) .filter((x): x is { ref: string; att: NonNullable<ReturnType<typeof byCid.get>> } => !!x.att);
.filter(({ att }) => !att.size || att.size <= MAX_INLINE_IMAGE_BYTES);
if (toFetch.length === 0) return; if (toFetch.length === 0) return;
let cancelled = false; let cancelled = false;
@@ -565,12 +611,9 @@ export default function EmailBodyView({
await Promise.all( await Promise.all(
toFetch.map(async ({ ref, att }) => { toFetch.map(async ({ ref, att }) => {
try { try {
const buf = await jmapClient.fetchBlobArrayBuffer( const uri = await fetchInlineImageDataUri(att, jmapAccountId);
att.blobId, att.name, att.type, jmapAccountId, if (cancelled || !uri) return;
); next[ref] = uri;
if (cancelled) return;
const b64 = arrayBufferToBase64(buf);
next[ref] = `data:${att.type || 'application/octet-stream'};base64,${b64}`;
} catch (err) { } catch (err) {
console.warn('[EmailBodyView] cid fetch failed', ref, err); console.warn('[EmailBodyView] cid fetch failed', ref, err);
} }
@@ -583,39 +626,52 @@ export default function EmailBodyView({
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [rawHtml, email.attachments, jmapAccountId]); }, [rawHtml, email.attachments, jmapAccountId]);
const source = React.useMemo(() => { const quoteLabels = React.useMemo(() => ({
show: t('email_viewer.show_quoted_text', 'Show quoted text'),
hide: t('email_viewer.hide_quoted_text', 'Hide quoted text'),
}), [t]);
const prepared = React.useMemo(() => {
if (rawHtml) { if (rawHtml) {
return { const res = prepareEmailHtml(rawHtml, {
html: wrapEmailHtml(rawHtml, { blockRemoteImages: shouldBlock,
blockRemoteImages: shouldBlock, cidMap,
cidMap, isDark: renderAsDark,
isDark: renderAsDark, messageSpacing,
}), });
}; return { ...res, isHtml: true };
} }
const fallbackText = text ?? email.preview ?? ''; const fallbackText = text ?? email.preview ?? '';
if (!fallbackText) { if (!fallbackText) {
return { const res = prepareEmailHtml(
html: wrapEmailHtml('<em style="color:#71717a">(empty message)</em>', { `<em style="color:#71717a">${t('email_viewer.no_body_content', '(No body content available)')}</em>`,
isDark: renderAsDark, { isDark: renderAsDark },
}), );
}; return { ...res, isHtml: true };
} }
const safe = collapsePlainTextQuotes(plainTextToSafeHtml(fallbackText), quoteLabels);
return { return {
html: wrapPlainTextEmail(plainTextToSafeHtml(fallbackText), { isDark: renderAsDark }), html: wrapPlainTextEmail(safe, { isDark: renderAsDark, font: plainTextFont }),
applyInversion: false,
hasNativeDark: false,
blockedExternal: false,
isHtml: false,
}; };
}, [rawHtml, text, email.preview, shouldBlock, cidMap, renderAsDark]); }, [rawHtml, text, email.preview, shouldBlock, cidMap, renderAsDark, messageSpacing, plainTextFont, quoteLabels, t]);
// Inversion is applied for HTML bodies in dark mode unless the email ships its const source = React.useMemo(() => ({ html: prepared.html }), [prepared.html]);
// own dark-mode CSS. When it's on, prepend the DOM re-inversion pass (emoji + const showBanner = shouldBlock && prepared.blockedExternal;
// stylesheet backgrounds) so it runs before the height reporter measures.
const applyInversion = // Inversion is applied for HTML bodies in dark mode unless the email ships
!!rawHtml && renderAsDark && !hasNativeDarkMode(rawHtml); // its own dark-mode CSS - decided once by prepareEmailHtml on the same
// input the CSS was built from, so the DOM pass and the stylesheet agree.
const injectedJs = React.useMemo( const injectedJs = React.useMemo(
() => () =>
(applyInversion ? DARK_REINVERT_SCRIPT + HEIGHT_REPORTER : HEIGHT_REPORTER) (prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '')
+ SWIPE_DETECTOR + PINCH_ZOOM, + (prepared.applyInversion ? DARK_REINVERT_SCRIPT : '')
[applyInversion], + (prepared.isHtml ? IMG_FIXUP_SCRIPT : '')
+ HEIGHT_REPORTER + SWIPE_DETECTOR + PINCH_ZOOM,
[prepared.isHtml, prepared.applyInversion, quoteLabels],
); );
const onMessage = (e: WebViewMessageEvent) => { const onMessage = (e: WebViewMessageEvent) => {
@@ -643,31 +699,52 @@ export default function EmailBodyView({
const onLoadImages = () => setAllowOnce(true); const onLoadImages = () => setAllowOnce(true);
const onTrustSender = () => { const onTrustSender = () => {
if (senderEmail) { if (senderEmail) {
// Keep the local allow-list for instant effect, and persist to the // Keep the local allow-list for instant effect. The server-side "Trusted
// dedicated "Trusted Senders" address book so the trust syncs across // Senders" address book (synced across devices) is only written when
// devices (matches the webmail behavior). // the user opted into it - otherwise a book would silently appear in
// their contacts (matches the webmail).
addTrustedSender(senderEmail); addTrustedSender(senderEmail);
void addToTrustedSendersBook(senderEmail).catch(() => {}); if (trustedSendersAddressBook) {
void addToTrustedSendersBook(senderEmail).catch(() => {});
}
setAllowOnce(true); setAllowOnce(true);
} }
}; };
// mailto: links inside the body open the app's own composer with the parsed
// recipients/subject/body rather than the OS mail handler (webmail 1.8.1).
const openMailto = (url: string) => {
const fields = parseMailtoUrl(url);
if (!fields) return;
navigation.navigate('Compose', {
prefillTo: fields.to.map((address) => ({ email: address })),
prefillCc: fields.cc?.map((address) => ({ email: address })),
prefillSubject: fields.subject,
prefillBody: fields.body,
});
};
return ( return (
<View style={styles.wrapper}> <View style={styles.wrapper}>
{showBanner && ( {showBanner && (
<View style={styles.banner}> <View style={styles.banner}>
{externalContentPolicy === 'ask' && ( <Text style={styles.bannerText}>
<Pressable style={styles.bannerButton} onPress={onLoadImages} hitSlop={8}> {t('email_viewer.external_content_warning', 'Images and external content have been blocked')}
<ImageIcon size={14} color={c.textSecondary} /> </Text>
<Text style={styles.bannerButtonText}>Load external content</Text> <View style={styles.bannerActions}>
</Pressable> {externalContentPolicy === 'ask' && (
)} <Pressable style={styles.bannerButton} onPress={onLoadImages} hitSlop={8}>
{senderEmail ? ( <ImageIcon size={14} color={c.textSecondary} />
<Pressable style={styles.bannerButton} onPress={onTrustSender} hitSlop={8}> <Text style={styles.bannerButtonText}>{t('email_viewer.load_external_content', 'Load images')}</Text>
<ShieldCheck size={14} color={c.textSecondary} /> </Pressable>
<Text style={styles.bannerButtonText}>Trust sender</Text> )}
</Pressable> {senderEmail ? (
) : null} <Pressable style={styles.bannerButton} onPress={onTrustSender} hitSlop={8}>
<ShieldCheck size={14} color={c.textSecondary} />
<Text style={styles.bannerButtonText}>{t('email_viewer.trust_sender', 'Always trust this sender')}</Text>
</Pressable>
) : null}
</View>
</View> </View>
)} )}
@@ -685,9 +762,9 @@ export default function EmailBodyView({
// reload the source mid-zoom). // reload the source mid-zoom).
onZoomChange?.({ pinching: false, zoomed: false }); onZoomChange?.({ pinching: false, zoomed: false });
// Re-inject after load to cover Android cases where // Re-inject after load to cover Android cases where
// `injectedJavaScript` runs too early to see the final layout. The // `injectedJavaScript` runs too early to see the final layout. Every
// re-inversion pass guards itself (__rnDarkInvertDone) so running it // pass guards itself so running it again here is a no-op once the
// again here is a no-op once the body has been processed. // body has been processed.
setTimeout(() => { setTimeout(() => {
webviewRef.current?.injectJavaScript(injectedJs); webviewRef.current?.injectJavaScript(injectedJs);
}, 50); }, 50);
@@ -714,10 +791,15 @@ export default function EmailBodyView({
containerStyle={styles.webviewContainer} containerStyle={styles.webviewContainer}
onShouldStartLoadWithRequest={(request) => { onShouldStartLoadWithRequest={(request) => {
const url = request.url; const url = request.url;
if (!url || url === 'about:blank' || url.startsWith('data:')) { // Only the initial about:blank document may load. A `data:` (or
return true; // any other) navigation would replace the email body with no way
// back, so it is refused; images are reachable via the chips.
if (!url || url === 'about:blank') return true;
if (/^mailto:/i.test(url)) {
openMailto(url);
return false;
} }
if (/^(https?|mailto|tel|sms):/i.test(url)) { if (/^(https?|tel|sms):/i.test(url)) {
void Linking.openURL(url).catch(() => undefined); void Linking.openURL(url).catch(() => undefined);
} }
return false; return false;
@@ -732,16 +814,23 @@ function makeStyles(c: ThemePalette) {
return StyleSheet.create({ return StyleSheet.create({
wrapper: { width: '100%' }, wrapper: { width: '100%' },
banner: { banner: {
flexDirection: 'row', gap: spacing.xs,
flexWrap: 'wrap',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
backgroundColor: c.surfaceHover, backgroundColor: c.surfaceHover,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: c.border, borderBottomColor: c.border,
}, },
bannerText: {
...typography.caption,
color: c.textMuted,
},
bannerActions: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
gap: spacing.md,
},
bannerButton: { bannerButton: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
+19 -1
View File
@@ -5,7 +5,7 @@ import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settin
import Input from '../Input'; import Input from '../Input';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import { useSettingsStore, type SpaceReplacement } from '../../stores/settings-store'; import { useSettingsStore, type SpaceReplacement, type PostExportAction } from '../../stores/settings-store';
import { import {
emailExportFilename, emailExportFilename,
attachmentDownloadFilename, attachmentDownloadFilename,
@@ -32,6 +32,7 @@ export function DownloadsSettings() {
const spaceReplacement = useSettingsStore((s) => s.exportSpaceReplacement); const spaceReplacement = useSettingsStore((s) => s.exportSpaceReplacement);
const lowercase = useSettingsStore((s) => s.exportLowercase); const lowercase = useSettingsStore((s) => s.exportLowercase);
const stripDiacritics = useSettingsStore((s) => s.exportStripDiacritics); const stripDiacritics = useSettingsStore((s) => s.exportStripDiacritics);
const postExportAction = useSettingsStore((s) => s.postExportAction);
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]); useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
@@ -120,6 +121,23 @@ export function DownloadsSettings() {
<ToggleSwitch checked={stripDiacritics} onChange={(v) => update('exportStripDiacritics', v)} /> <ToggleSwitch checked={stripDiacritics} onChange={(v) => update('exportStripDiacritics', v)} />
</SettingItem> </SettingItem>
</SettingsSection> </SettingsSection>
<SettingsSection
title="After Export"
description="What happens to a message after it was exported as .eml."
>
<View style={styles.group}>
<RadioGroup
value={postExportAction}
onChange={(v) => update('postExportAction', v as PostExportAction)}
options={[
{ value: 'keep', label: 'Keep in folder' },
{ value: 'archive', label: 'Archive' },
{ value: 'trash', label: 'Move to Trash' },
]}
/>
</View>
</SettingsSection>
</View> </View>
); );
} }
@@ -10,6 +10,9 @@ import {
type DeleteAction, type DeleteAction,
type MailAttachmentAction, type MailAttachmentAction,
type AttachmentPosition, type AttachmentPosition,
type PlainTextFont,
type MessageSpacing,
type ReadReceiptResponse,
} from '../../stores/settings-store'; } from '../../stores/settings-store';
import { useEmailStore } from '../../stores/email-store'; import { useEmailStore } from '../../stores/email-store';
import { archiveEmails, queryEmails, getEmails } from '../../api/email'; import { archiveEmails, queryEmails, getEmails } from '../../api/email';
@@ -37,6 +40,9 @@ export function ReadingSettings() {
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled); const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords); const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments); const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
const readReceiptResponse = useSettingsStore((s) => s.readReceiptResponse);
const [newKeyword, setNewKeyword] = useState(''); const [newKeyword, setNewKeyword] = useState('');
const [reorganizing, setReorganizing] = useState(false); const [reorganizing, setReorganizing] = useState(false);
@@ -283,6 +289,41 @@ export function ReadingSettings() {
/> />
</SettingItem> </SettingItem>
<SettingItem label="Plain Text Font" description="Font used for plain-text messages.">
<Select
value={plainTextFont}
onChange={(v) => update('plainTextFont', v as PlainTextFont)}
options={[
{ value: 'sans', label: 'App font' },
{ value: 'mono', label: 'Monospace' },
]}
/>
</SettingItem>
<SettingItem label="Message Spacing" description="Gutter around the message body.">
<Select
value={messageSpacing}
onChange={(v) => update('messageSpacing', v as MessageSpacing)}
options={[
{ value: 'auto', label: 'Automatic' },
{ value: 'always', label: 'Always padded' },
{ value: 'edge', label: 'Edge to edge' },
]}
/>
</SettingItem>
<SettingItem label="Read Receipts" description="When a sender asks to be notified that you opened a message.">
<Select
value={readReceiptResponse}
onChange={(v) => update('readReceiptResponse', v as ReadReceiptResponse)}
options={[
{ value: 'ask', label: 'Ask me' },
{ value: 'always', label: 'Always send' },
{ value: 'never', label: 'Never send' },
]}
/>
</SettingItem>
<SettingItem label="Emails Per Page" description="How many emails to load at a time."> <SettingItem label="Emails Per Page" description="How many emails to load at a time.">
<Select <Select
value={String(emailsPerPage)} value={String(emailsPerPage)}
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { pickEmailBody, selectRenderableHtml, plainTextBody } from '../email-body';
describe('pickEmailBody', () => {
it('does not treat the same part as a text alternative (HTML-only, native #46)', () => {
const email = {
htmlBody: [{ partId: '1', type: 'text/html' }],
textBody: [{ partId: '1', type: 'text/html' }],
bodyValues: { '1': { value: '<html><head><style>p{}</style></head><body><div>Hi</div></body></html>' } },
};
const picked = pickEmailBody(email);
expect(picked.html).toContain('<div>Hi</div>');
expect(picked.text).toBeNull();
// Without a distinct text alternative the HTML is rendered even when it
// fails the "meaningful" heuristic.
expect(selectRenderableHtml(picked)).toContain('<div>Hi</div>');
expect(plainTextBody({ ...email, preview: 'Hi' })).toBe('Hi');
});
it('renders text-only messages as text even when listed under htmlBody', () => {
const email = {
htmlBody: [{ partId: '1', type: 'text/plain' }],
textBody: [{ partId: '1', type: 'text/plain' }],
bodyValues: { '1': { value: 'plain <b>not html</b>' } },
};
const picked = pickEmailBody(email);
expect(picked.html).toBeNull();
expect(picked.text).toBe('plain <b>not html</b>');
expect(selectRenderableHtml(picked)).toBeNull();
});
it('keeps both alternatives for multipart/alternative', () => {
const email = {
htmlBody: [{ partId: '2', type: 'text/html' }],
textBody: [{ partId: '1', type: 'text/plain' }],
bodyValues: { '1': { value: 'plain' }, '2': { value: '<p><b>rich</b></p>' } },
};
const picked = pickEmailBody(email);
expect(picked.html).toBe('<p><b>rich</b></p>');
expect(picked.text).toBe('plain');
expect(selectRenderableHtml(picked)).toBe('<p><b>rich</b></p>');
expect(plainTextBody(email)).toBe('plain');
});
it('prefers the text alternative when the HTML is a minimal wrapper', () => {
const picked = pickEmailBody({
htmlBody: [{ partId: '2', type: 'text/html' }],
textBody: [{ partId: '1', type: 'text/plain' }],
bodyValues: { '1': { value: 'line1\nline2' }, '2': { value: '<div>line1 line2</div>' } },
});
expect(selectRenderableHtml(picked)).toBeNull();
});
});
+45 -4
View File
@@ -33,9 +33,18 @@ vi.mock('expo-file-system', () => {
write() {} write() {}
static downloadFileAsync = vi.fn(async () => undefined); static downloadFileAsync = vi.fn(async () => undefined);
} }
class Directory {
uri: string;
exists = true;
constructor(parent: { uri: string }, name: string) {
this.uri = `${parent.uri}${name}/`;
}
create() {}
list() { return []; }
}
return { return {
File, File,
Directory: class {}, Directory,
Paths: { cache: { uri: 'file:///cache/' }, document: { uri: 'file:///documents/' } }, Paths: { cache: { uri: 'file:///cache/' }, document: { uri: 'file:///documents/' } },
}; };
}); });
@@ -54,8 +63,16 @@ vi.mock('../client-cert', () => ({
secureFetch: vi.fn(), secureFetch: vi.fn(),
})); }));
const { jmapClientMock } = vi.hoisted(() => ({
jmapClientMock: {
authHeader: 'Bearer token',
ensureFreshToken: vi.fn(async () => undefined),
forceRefreshToken: vi.fn(async () => false),
},
}));
vi.mock('../../api/jmap-client', () => ({ vi.mock('../../api/jmap-client', () => ({
jmapClient: { authHeader: 'Bearer token' }, jmapClient: jmapClientMock,
})); }));
vi.mock('../../api/blob', () => ({ vi.mock('../../api/blob', () => ({
@@ -96,7 +113,7 @@ describe('shareAttachment (preview)', () => {
await shareAttachment('blob-2', 'weird.xyz', 'application/x-weird'); await shareAttachment('blob-2', 'weird.xyz', 'application/x-weird');
// file:// — expo-sharing rejects content:// URLs outright. // file:// — expo-sharing rejects content:// URLs outright.
expect(Sharing.shareAsync).toHaveBeenCalledWith('file:///cache/weird.xyz', { expect(Sharing.shareAsync).toHaveBeenCalledWith('file:///cache/bulwark-exports/weird.xyz', {
mimeType: 'application/x-weird', mimeType: 'application/x-weird',
dialogTitle: 'weird.xyz', dialogTitle: 'weird.xyz',
}); });
@@ -112,13 +129,37 @@ describe('shareAttachment (preview)', () => {
expect(IntentLauncher.startActivityAsync).not.toHaveBeenCalled(); expect(IntentLauncher.startActivityAsync).not.toHaveBeenCalled();
}); });
it('refreshes the token before downloading and retries once after a 401', async () => {
const { File } = await import('expo-file-system');
jmapClientMock.ensureFreshToken.mockClear();
jmapClientMock.forceRefreshToken.mockClear().mockResolvedValueOnce(true);
vi.mocked(File.downloadFileAsync).mockClear()
.mockRejectedValueOnce(new Error('Download failed: 401'))
.mockResolvedValueOnce(undefined as never);
await shareAttachment('blob-6', 'report.pdf', 'application/pdf');
expect(jmapClientMock.ensureFreshToken).toHaveBeenCalledTimes(1);
expect(jmapClientMock.forceRefreshToken).toHaveBeenCalledTimes(1);
expect(File.downloadFileAsync).toHaveBeenCalledTimes(2);
expect(IntentLauncher.startActivityAsync).toHaveBeenCalledTimes(1);
});
it('gives up when the 401 persists after a refresh', async () => {
const { File } = await import('expo-file-system');
jmapClientMock.forceRefreshToken.mockClear().mockResolvedValueOnce(false);
vi.mocked(File.downloadFileAsync).mockRejectedValueOnce(new Error('Download failed: 401'));
await expect(shareAttachment('blob-7', 'x.pdf', 'application/pdf')).rejects.toThrow('401');
});
it('uses the share sheet on iOS', async () => { it('uses the share sheet on iOS', async () => {
platform.OS = 'ios'; platform.OS = 'ios';
await shareAttachment('blob-4', 'photo.jpg', 'image/jpeg'); await shareAttachment('blob-4', 'photo.jpg', 'image/jpeg');
expect(IntentLauncher.startActivityAsync).not.toHaveBeenCalled(); expect(IntentLauncher.startActivityAsync).not.toHaveBeenCalled();
expect(Sharing.shareAsync).toHaveBeenCalledWith('file:///cache/photo.jpg', { expect(Sharing.shareAsync).toHaveBeenCalledWith('file:///cache/bulwark-exports/photo.jpg', {
mimeType: 'image/jpeg', mimeType: 'image/jpeg',
dialogTitle: 'photo.jpg', dialogTitle: 'photo.jpg',
}); });
+219
View File
@@ -0,0 +1,219 @@
import { describe, it, expect } from 'vitest';
import {
stripDangerousTags,
isExternalResourceUrl,
decodeCssEscapes,
stripExternalStyleSheetCss,
blockExternalResources,
hasRemoteContent,
prepareEmailHtml,
wrapPlainTextEmail,
hasNativeDarkMode,
sniffImageMime,
TRANSPARENT_BLOCKED_PIXEL,
} from '../email-html';
describe('stripDangerousTags', () => {
it('keeps <style> blocks (the WebView is an isolated document)', () => {
const out = stripDangerousTags('<style>.hero{color:#fff}</style><div class="hero">x</div>');
expect(out).toContain('<style>.hero{color:#fff}</style>');
});
it('still removes scripts, iframes, svg, link and event handlers', () => {
const out = stripDangerousTags(
'<script>1</script><iframe src="x"></iframe><svg><script>2</script></svg>'
+ '<link rel="stylesheet" href="https://x/a.css"><a onclick="evil()" href="https://x">l</a>',
);
expect(out).not.toMatch(/<script|<iframe|<svg|<link|onclick/i);
expect(out).toContain('href="https://x"');
});
it('allows only raster data: image URIs', () => {
expect(stripDangerousTags('<img src="data:image/png;base64,AAAA">')).toContain('data:image/png');
expect(stripDangerousTags('<img src="data:image/svg+xml;base64,AAAA">')).toContain('#blocked');
expect(stripDangerousTags('<a href="data:text/html,<script>">x</a>')).toContain('#blocked');
});
it('scheme-checks every srcset candidate', () => {
const out = stripDangerousTags('<img srcset="https://a/1.png 1x, javascript:alert(1) 2x">');
expect(out).toContain('srcset="https://a/1.png 1x"');
const svg = stripDangerousTags('<img srcset="data:image/png;base64,AA 1x, data:image/svg+xml;base64,AA 2x">');
expect(svg).toContain('srcset=""');
});
it('blocks entity-obfuscated javascript: URLs', () => {
expect(stripDangerousTags('<a href="&#x6A;avascript:alert(1)">x</a>')).toContain('#blocked');
expect(stripDangerousTags('<a href="java\nscript:alert(1)">x</a>')).toContain('#blocked');
});
});
describe('isExternalResourceUrl', () => {
it('detects http(s) and protocol-relative URLs including whitespace tricks', () => {
expect(isExternalResourceUrl('https://x/p.gif')).toBe(true);
expect(isExternalResourceUrl('\n\nhttps://x/p.gif')).toBe(true);
expect(isExternalResourceUrl('ht\ttps://x')).toBe(true);
expect(isExternalResourceUrl('//x/p.gif')).toBe(true);
expect(isExternalResourceUrl('data:image/png;base64,AA')).toBe(false);
expect(isExternalResourceUrl('cid:abc')).toBe(false);
expect(isExternalResourceUrl(undefined)).toBe(false);
});
});
describe('decodeCssEscapes / stripExternalStyleSheetCss', () => {
it('decodes hex and char escapes', () => {
expect(decodeCssEscapes('\\68ttp://x')).toBe('http://x');
expect(decodeCssEscapes('\\000068ttp://x')).toBe('http://x');
expect(decodeCssEscapes('\\75\\72\\6C(')).toBe('url(');
});
it('neutralises external url(), @import and @font-face sources', () => {
const css = '.a{background:url("https://t/x.png")} @import "https://t/a.css"; @font-face{src:url(https://t/f.woff)} .b{color:red}';
const out = stripExternalStyleSheetCss(css);
expect(out).not.toContain('https://t');
expect(out).toContain('.b{color:red}');
});
it('catches escaped url( keywords', () => {
const css = '.a{background:\\75\\72\\6C(https://t/x.png)}';
expect(stripExternalStyleSheetCss(css)).not.toContain('https://t');
});
it('returns the identical string when nothing is external', () => {
const css = '.a{background:url(data:image/png;base64,AA)}';
expect(stripExternalStyleSheetCss(css)).toBe(css);
});
});
describe('blockExternalResources', () => {
it('swaps external img src for a transparent pixel and hides it', () => {
const { html, blocked } = blockExternalResources('<img src="https://t/p.gif" alt="logo">');
expect(blocked).toBe(true);
expect(html).toContain(`src="${TRANSPARENT_BLOCKED_PIXEL}"`);
expect(html).toContain('data-blocked-src="https://t/p.gif"');
expect(html).toContain('alt=""');
expect(html).toContain('display:none');
});
it('covers srcset, <source>, poster, media src, background= and inline url()', () => {
const input = [
'<img srcset="https://t/a.png 1x, https://t/b.png 2x">',
'<picture><source srcset="https://t/c.webp"></picture>',
'<video poster="https://t/p.jpg" src="https://t/v.mp4"></video>',
'<table background="https://t/bg.png"><tr><td style="background:url(https://t/x.png);color:red">x</td></tr></table>',
'<div style="background-image:url(\\68ttps://t/esc.png)">y</div>',
].join('');
const { html, blocked } = blockExternalResources(input);
expect(blocked).toBe(true);
// Only the data-blocked-* stashes may still carry the URLs.
expect(html.replace(/data-blocked-[a-z]+="[^"]*"/g, '')).not.toContain('https://t/');
expect(html).toContain('data-blocked-srcset');
expect(html).toContain('data-blocked-poster');
expect(html).toContain('data-blocked-background');
expect(html).toContain('color:red');
});
it('catches whitespace-obfuscated src and leaves inline images alone', () => {
const { html, blocked } = blockExternalResources('<img src="\n https://t/p.gif"><img src="cid:a"><img src="data:image/png;base64,AA">');
expect(blocked).toBe(true);
expect(html).toContain('src="cid:a"');
expect(html).toContain('data:image/png;base64,AA');
});
it('scrubs <style> blocks', () => {
const { html, blocked } = blockExternalResources('<style>.a{background:url(https://t/x.png)}</style>');
expect(blocked).toBe(true);
expect(html).not.toContain('https://t');
});
it('reports nothing blocked for a local-only body', () => {
const { html, blocked } = blockExternalResources('<p style="color:red">hi</p><img src="cid:x">');
expect(blocked).toBe(false);
expect(html).toContain('<p style="color:red">hi</p>');
expect(hasRemoteContent('<p>hi</p>')).toBe(false);
expect(hasRemoteContent('<img srcset="https://t/a.png 1x">')).toBe(true);
});
});
describe('prepareEmailHtml', () => {
it('uses the strict CSP only when blocking', () => {
const strict = prepareEmailHtml('<p>x</p>', { blockRemoteImages: true });
expect(strict.html).toContain('img-src data: cid:;');
expect(strict.html).toContain("media-src 'none'");
const open = prepareEmailHtml('<p>x</p>', { blockRemoteImages: false });
expect(open.html).toContain('img-src data: cid: https: http:');
});
it('evaluates native dark mode once and reports the inversion decision', () => {
const native = prepareEmailHtml('<style>@media (prefers-color-scheme: dark){body{color:#fff}}</style><p>x</p>', { isDark: true });
expect(native.hasNativeDark).toBe(true);
expect(native.applyInversion).toBe(false);
expect(native.html).toContain('color-scheme: light dark');
expect(native.html).not.toContain('invert(1)');
const plain = prepareEmailHtml('<p>x</p>', { isDark: true });
expect(plain.applyInversion).toBe(true);
expect(plain.html).toContain('body { filter: invert(1) hue-rotate(180deg)');
// Current webmail revision: background-image containers re-invert without the :has guard.
expect(plain.html).toMatch(/\[style\*="background-image"\],\s*\[background\]\s*\{\s*filter: invert\(1\)/);
const light = prepareEmailHtml('<p>x</p>', { isDark: false });
expect(light.applyInversion).toBe(false);
});
it('reports blocked external content', () => {
const res = prepareEmailHtml('<img src="https://t/p.gif">', { blockRemoteImages: true });
expect(res.blockedExternal).toBe(true);
const none = prepareEmailHtml('<p>hi</p>', { blockRemoteImages: true });
expect(none.blockedExternal).toBe(false);
});
it('inlines cid images from the map and keeps a transparent placeholder otherwise', () => {
const res = prepareEmailHtml('<img src="cid:a"><img src="cid:b">', { cidMap: { a: 'data:image/png;base64,AA' } });
expect(res.html).toContain('src="data:image/png;base64,AA"');
expect(res.html).toContain(`src="${TRANSPARENT_BLOCKED_PIXEL}"`);
});
it('tightens Word/Outlook HTML and drops the gutter for full-bleed canvases in auto mode', () => {
const word = prepareEmailHtml('<div class="WordSection1"><p class="MsoNormal">x</p></div>');
expect(word.html).toContain('p.MsoNormal');
const bleed = prepareEmailHtml('<style>.x{}</style><table width="100%" bgcolor="#000"><tr><td>x</td></tr></table>', { messageSpacing: 'auto' });
expect(bleed.html).toContain('padding: 0;');
const gutter = prepareEmailHtml('<p>x</p>', { messageSpacing: 'always' });
expect(gutter.html).toContain('padding: 16px;');
const edge = prepareEmailHtml('<p>x</p>', { messageSpacing: 'edge' });
expect(edge.html).toContain('padding: 0;');
});
it('neutralises height:100% wrappers', () => {
expect(prepareEmailHtml('<p>x</p>').html).toContain('[style*="height:100%"]');
});
});
describe('wrapPlainTextEmail', () => {
it('uses the app font by default and monospace on request', () => {
expect(wrapPlainTextEmail('x', { font: 'sans' })).toContain('-apple-system');
expect(wrapPlainTextEmail('x', { font: 'mono' })).toContain('ui-monospace');
expect(wrapPlainTextEmail('x')).not.toContain('ui-monospace');
});
});
describe('hasNativeDarkMode', () => {
it('detects prefers-color-scheme rules', () => {
expect(hasNativeDarkMode('@media (prefers-color-scheme:dark){}')).toBe(true);
expect(hasNativeDarkMode('<p>x</p>')).toBe(false);
});
});
describe('sniffImageMime', () => {
it('recognises PNG/JPEG/GIF/WebP magic bytes', () => {
expect(sniffImageMime(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), 'application/octet-stream')).toBe('image/png');
expect(sniffImageMime(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]))).toBe('image/jpeg');
expect(sniffImageMime(new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]))).toBe('image/gif');
expect(sniffImageMime(new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]))).toBe('image/webp');
});
it('falls back to a declared image type, else octet-stream', () => {
expect(sniffImageMime(new Uint8Array([1, 2, 3]), 'image/tiff')).toBe('image/tiff');
expect(sniffImageMime(new Uint8Array([1, 2, 3]), 'application/octet-stream')).toBe('application/octet-stream');
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { collapsePlainTextQuotes, buildQuoteCollapseScript, QUOTE_TOGGLE_ATTR } from '../quote-collapse';
const labels = { show: 'Show quoted text', hide: 'Hide quoted text' };
describe('collapsePlainTextQuotes', () => {
it('wraps the trailing quote run in <details>', () => {
const html = 'Thanks!\n\nOn Monday, Bob wrote:\n&gt; original line 1\n&gt;\n&gt; original line 2';
const out = collapsePlainTextQuotes(html, labels);
expect(out).toContain('<details><summary title="Show quoted text"');
expect(out).toContain('&gt; original line 1\n&gt;\n&gt; original line 2</details>');
expect(out.startsWith('Thanks!\n\nOn Monday, Bob wrote:\n')).toBe(true);
});
it('keeps a trailing signature block visible', () => {
const html = 'Hi\n&gt; quoted\n\n-- \nBob';
const out = collapsePlainTextQuotes(html, labels);
expect(out).toContain('</details>\n\n-- \nBob');
});
it('is a no-op for interleaved replies and quote-only bodies', () => {
expect(collapsePlainTextQuotes('&gt; a\nreply below', labels)).toBe('&gt; a\nreply below');
expect(collapsePlainTextQuotes('&gt; only quote', labels)).toBe('&gt; only quote');
expect(collapsePlainTextQuotes('no quotes here', labels)).toBe('no quotes here');
});
it('escapes the label', () => {
const out = collapsePlainTextQuotes('a\n&gt; b', { show: 'x"<y', hide: 'h' });
expect(out).toContain('title="x&quot;&lt;y"');
});
});
describe('buildQuoteCollapseScript', () => {
it('embeds the selectors and labels as JSON', () => {
const script = buildQuoteCollapseScript(labels);
expect(script).toContain('"div.gmail_quote"');
expect(script).toContain('"#divRplyFwdMsg"');
expect(script).toContain(`"${QUOTE_TOGGLE_ATTR}"`);
expect(script).toContain('"Show quoted text"');
expect(script).toContain('"Hide quoted text"');
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import {
parseUnsubscribeUrls, isValidUnsubscribeUrl, parseMailtoUrl, isOneClickUnsubscribe,
} from '../unsubscribe';
describe('parseUnsubscribeUrls', () => {
it('prefers http over mailto and validates both', () => {
const r = parseUnsubscribeUrls('<mailto:unsub@list.example?subject=stop>, <https://list.example/u?x=1>');
expect(r.preferred).toBe('http');
expect(r.http).toBe('https://list.example/u?x=1');
expect(r.mailto).toBe('mailto:unsub@list.example?subject=stop');
});
it('falls back to mailto and ignores junk', () => {
expect(parseUnsubscribeUrls('<mailto:a@b.co>').preferred).toBe('mailto');
expect(parseUnsubscribeUrls('<ftp://x>').preferred).toBeUndefined();
expect(parseUnsubscribeUrls('')).toEqual({});
});
});
describe('isValidUnsubscribeUrl', () => {
it('accepts http(s) and mailto with a valid address only', () => {
expect(isValidUnsubscribeUrl('https://x.example/u')).toBe(true);
expect(isValidUnsubscribeUrl('mailto:a@b.co?subject=x')).toBe(true);
expect(isValidUnsubscribeUrl('mailto:not-an-address')).toBe(false);
expect(isValidUnsubscribeUrl('javascript:alert(1)')).toBe(false);
});
});
describe('parseMailtoUrl', () => {
it('parses recipients, subject and body without turning + into spaces', () => {
const r = parseMailtoUrl('mailto:a+tag@b.co,c@d.co?subject=Hi%20there&body=Line%0Atwo&cc=e@f.co');
expect(r).toEqual({ to: ['a+tag@b.co', 'c@d.co'], cc: ['e@f.co'], subject: 'Hi there', body: 'Line\ntwo' });
});
it('returns null without a recipient', () => {
expect(parseMailtoUrl('mailto:?subject=x')).toBeNull();
expect(parseMailtoUrl('https://x')).toBeNull();
});
});
describe('isOneClickUnsubscribe', () => {
it('requires the RFC 8058 header and an https URL', () => {
expect(isOneClickUnsubscribe('List-Unsubscribe=One-Click', 'https://x/u')).toBe(true);
expect(isOneClickUnsubscribe('List-Unsubscribe=One-Click', 'http://x/u')).toBe(false);
expect(isOneClickUnsubscribe(undefined, 'https://x/u')).toBe(false);
});
});
+81
View File
@@ -0,0 +1,81 @@
import type { BodyPart, Email } from '../api/types';
import { hasMeaningfulHtmlBody } from './email-html';
export interface PickedBody {
/** The HTML alternative, when the message really has one. */
html: string | null;
/** The plain-text alternative, when it is a distinct part. */
text: string | null;
}
function partType(part: BodyPart): 'html' | 'text' | 'other' {
const t = (part.type || '').split(';')[0].trim().toLowerCase();
if (!t) return 'other';
if (t === 'text/html') return 'html';
if (t === 'text/plain') return 'text';
return 'other';
}
/**
* Pick the HTML and text bodies of a message per RFC 8621 §4.1.4.
*
* An HTML-only message exposes the SAME part in `textBody` and `htmlBody`
* (the server's best-effort alternative), and a text-only message likewise
* lists its text part in both. Treating `textBody[0]` as a real alternative
* without comparing part ids rendered HTML-only mail as raw source (native
* #46) and quoted it that way in replies. Routing is by the part's `type`
* first and by distinct `partId` second.
*/
export function pickEmailBody(
email: Pick<Email, 'htmlBody' | 'textBody' | 'bodyValues'>,
): PickedBody {
const values = email.bodyValues ?? {};
let html: string | null = null;
let text: string | null = null;
const htmlIds = new Set<string>();
for (const part of email.htmlBody ?? []) {
if (!part.partId) continue;
const v = values[part.partId]?.value;
if (!v) continue;
const kind = partType(part);
if (kind === 'html' || (kind === 'other' && !part.type)) {
if (html === null) { html = v; htmlIds.add(part.partId); }
} else if (kind === 'text' && text === null) {
text = v;
}
}
for (const part of email.textBody ?? []) {
if (!part.partId || htmlIds.has(part.partId)) continue;
const v = values[part.partId]?.value;
if (!v) continue;
const kind = partType(part);
if (kind === 'text' || (kind === 'other' && !part.type)) {
if (text === null) text = v;
} else if (kind === 'html' && html === null) {
html = v;
htmlIds.add(part.partId);
}
}
return { html, text };
}
/**
* Which alternative to render: the HTML part unless it is a minimal
* auto-generated wrapper around the text alternative (which would collapse
* newlines) - the webmail's `hasMeaningfulHtmlBody` preference.
*/
export function selectRenderableHtml(picked: PickedBody): string | null {
if (!picked.html) return null;
if (!picked.text) return picked.html;
return hasMeaningfulHtmlBody(picked.html) ? picked.html : null;
}
/** Plain-text body for quoting / quick reply; never returns HTML source. */
export function plainTextBody(email: Pick<Email, 'htmlBody' | 'textBody' | 'bodyValues' | 'preview'>): string {
const picked = pickEmailBody(email);
if (picked.text) return picked.text;
return email.preview ?? '';
}
+243 -41
View File
@@ -4,7 +4,7 @@ import * as IntentLauncher from 'expo-intent-launcher';
import * as Sharing from 'expo-sharing'; import * as Sharing from 'expo-sharing';
import { jmapClient } from '../api/jmap-client'; import { jmapClient } from '../api/jmap-client';
import { getDownloadUrl } from '../api/blob'; import { getDownloadUrl } from '../api/blob';
import type { Email } from '../api/types'; import type { Attachment, Email } from '../api/types';
import { useSettingsStore } from '../stores/settings-store'; import { useSettingsStore } from '../stores/settings-store';
import { import {
attachmentDownloadFilename, attachmentDownloadFilename,
@@ -12,10 +12,19 @@ import {
type EmailFilenameOptions, type EmailFilenameOptions,
} from './download-filename'; } from './download-filename';
import { getClientCertAlias, secureFetch } from './client-cert'; import { getClientCertAlias, secureFetch } from './client-cert';
import { sniffImageMime } from './email-html';
const RFC822 = 'message/rfc822'; const RFC822 = 'message/rfc822';
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001; const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
// Largest inline image we base64-encode in JS memory. Anything bigger is
// streamed to a cache file and encoded natively so the JS thread doesn't
// freeze on megabytes of string concatenation.
const MAX_IN_MEMORY_INLINE_IMAGE_BYTES = 2 * 1024 * 1024;
// Temp files handed to viewers / the share sheet older than this are swept.
const STALE_EXPORT_MS = 24 * 60 * 60 * 1000;
// Read the user's filename template + transform preferences for exports. // Read the user's filename template + transform preferences for exports.
function emailFileOptions(): EmailFilenameOptions { function emailFileOptions(): EmailFilenameOptions {
const s = useSettingsStore.getState(); const s = useSettingsStore.getState();
@@ -37,10 +46,6 @@ function attachmentFileOptions(): EmailFilenameOptions {
}; };
} }
function authHeader(): string {
return jmapClient.authHeader;
}
function safeAttachmentName(name: string | undefined, type: string | undefined): string { function safeAttachmentName(name: string | undefined, type: string | undefined): string {
const fallbackExt = type?.split('/')[1]?.replace(/[^a-z0-9]/gi, '').slice(0, 8) || 'bin'; const fallbackExt = type?.split('/')[1]?.replace(/[^a-z0-9]/gi, '').slice(0, 8) || 'bin';
const cleaned = (name ?? '').replace(/[\\/:*?"<>|]/g, '_').trim(); const cleaned = (name ?? '').replace(/[\\/:*?"<>|]/g, '_').trim();
@@ -48,6 +53,62 @@ function safeAttachmentName(name: string | undefined, type: string | undefined):
return `attachment.${fallbackExt}`; return `attachment.${fallbackExt}`;
} }
// ─── Temp-file housekeeping ─────────────────────────────────────────────
// Every temp file we hand to another app lives in one sub-directory of the
// cache so the sweep below only ever touches our own files.
function exportsDir(): Directory {
return new Directory(Paths.cache, 'bulwark-exports');
}
function ensureDir(dir: Directory): void {
try {
if (!dir.exists) dir.create({ intermediates: true, idempotent: true });
} catch { /* best effort - the download will surface a real error */ }
}
function deleteQuietly(file: File): void {
try {
if (file.exists) file.delete();
} catch { /* already gone or still in use - the sweep gets it later */ }
}
/**
* Remove temp files older than a day. Files shared into other apps could not
* always be deleted right after the share sheet closed (the receiving app may
* still be reading them), so anything that slipped through is collected here.
* Runs once per process, lazily before the first export, and is also safe to
* call at launch.
*/
let sweptThisProcess = false;
export async function sweepStaleExportFiles(maxAgeMs = STALE_EXPORT_MS): Promise<void> {
if (sweptThisProcess) return;
sweptThisProcess = true;
try {
const dir = exportsDir();
if (!dir.exists) return;
const now = Date.now();
for (const entry of dir.list()) {
if (!(entry instanceof File)) continue;
const modified = (entry as { modificationTime?: number | null }).modificationTime;
const age = typeof modified === 'number' ? now - modified : Number.POSITIVE_INFINITY;
if (age > maxAgeMs) deleteQuietly(entry);
}
} catch { /* housekeeping only */ }
}
// Delete a temp file once the share sheet has resolved. iOS copies the file
// for the receiving extension, so it can go immediately; Android hands the
// content URI to the target app, which may still be streaming it, so give it
// a grace period and leave the rest to the sweep.
function scheduleTempCleanup(file: File): void {
if (Platform.OS === 'android') {
setTimeout(() => deleteQuietly(file), 60_000);
} else {
deleteQuietly(file);
}
}
// expo-sharing only accepts `file://` URLs and rejects `content://` with // expo-sharing only accepts `file://` URLs and rejects `content://` with
// "Only local file URLs are supported". On Android it then wraps the file // "Only local file URLs are supported". On Android it then wraps the file
// itself with its bundled SharingFileProvider before launching the share // itself with its bundled SharingFileProvider before launching the share
@@ -78,38 +139,73 @@ async function openWithViewer(file: File, mimeType: string): Promise<boolean> {
} }
} }
function isUnauthorizedError(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return /\b401\b|unauthori[sz]ed/i.test(msg);
}
// Routes the download via the client-cert-aware native module when the user // Routes the download via the client-cert-aware native module when the user
// has picked a cert, and via expo-file-system's native streaming downloader // has picked a cert, and via expo-file-system's native streaming downloader
// otherwise. The streaming path scales to large attachments without buffering // otherwise. The streaming path scales to large attachments without buffering
// in JS, so we keep using it as the default. // in JS, so we keep using it as the default.
//
// Both paths bypass the JMAP client's fetch wrapper, so the OAuth token has to
// be refreshed here: proactively before the header is captured, and once
// reactively when the server still answers 401 (a token that expired between
// the check and the request).
async function downloadInto( async function downloadInto(
url: string, url: string,
dest: File, dest: File,
parent: Directory, parent: Directory,
): Promise<File> { ): Promise<File> {
await jmapClient.ensureFreshToken();
const alias = await getClientCertAlias(); const alias = await getClientCertAlias();
if (!alias) { ensureDir(parent);
// The static returns a separately-typed `FileSystemFile`; we already const attempt = async (): Promise<File> => {
// have a fully-typed `File` referencing the same uri, so we ignore the if (!alias) {
// return value and re-use our `dest` reference for downstream code. // The static returns a separately-typed `FileSystemFile`; we already
await File.downloadFileAsync(url, dest, { // have a fully-typed `File` referencing the same uri, so we ignore the
headers: { Authorization: authHeader() }, // return value and re-use our `dest` reference for downstream code.
idempotent: true, await File.downloadFileAsync(url, dest, {
headers: { Authorization: jmapClient.authHeader },
idempotent: true,
});
return dest;
}
const response = await secureFetch(url, {
headers: { Authorization: jmapClient.authHeader },
}); });
if (!response.ok) {
throw new Error(`Download failed: ${response.status}`);
}
if (dest.exists) dest.delete();
const buffer = await response.arrayBuffer();
dest.create();
dest.write(new Uint8Array(buffer));
return dest; return dest;
};
try {
return await attempt();
} catch (err) {
if (isUnauthorizedError(err) && (await jmapClient.forceRefreshToken())) {
return attempt();
}
throw err;
} }
const response = await secureFetch(url, { }
headers: { Authorization: authHeader() },
}); /** Download a blob into the exports cache directory (caller cleans up). */
if (!response.ok) { export async function cacheBlobFile(
throw new Error(`Download failed: ${response.status}`); blobId: string,
} filename: string,
if (!parent.exists) parent.create({ intermediates: true, idempotent: true }); mimeType: string,
if (dest.exists) dest.delete(); accountId?: string,
const buffer = await response.arrayBuffer(); ): Promise<File> {
dest.create(); void sweepStaleExportFiles();
dest.write(new Uint8Array(buffer)); const dir = exportsDir();
return dest; const dest = new File(dir, filename);
const url = getDownloadUrl(blobId, filename, mimeType, accountId);
return downloadInto(url, dest, dir);
} }
export async function shareAttachment( export async function shareAttachment(
@@ -124,20 +220,24 @@ export async function shareAttachment(
? attachmentDownloadFilename(email, { name, type }, attachmentFileOptions()) ? attachmentDownloadFilename(email, { name, type }, attachmentFileOptions())
: safeAttachmentName(name, type); : safeAttachmentName(name, type);
const mimeType = type || 'application/octet-stream'; const mimeType = type || 'application/octet-stream';
const dest = new File(Paths.cache, filename); const downloaded = await cacheBlobFile(blobId, filename, mimeType, accountId);
const url = getDownloadUrl(blobId, filename, mimeType, accountId);
const downloaded = await downloadInto(url, dest, Paths.cache);
if (Platform.OS === 'android' && (await openWithViewer(downloaded, mimeType))) { if (Platform.OS === 'android' && (await openWithViewer(downloaded, mimeType))) {
// The viewer may keep reading the content URI after the activity result;
// the daily sweep removes the file.
return; return;
} }
if (!(await Sharing.isAvailableAsync())) { if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device'); throw new Error('Sharing is not available on this device');
} }
await Sharing.shareAsync(downloaded.uri, { try {
mimeType, await Sharing.shareAsync(downloaded.uri, {
dialogTitle: filename, mimeType,
}); dialogTitle: filename,
});
} finally {
scheduleTempCleanup(downloaded);
}
} }
// Save-to-disk variant. iOS doesn't expose a user-visible "Downloads" folder, // Save-to-disk variant. iOS doesn't expose a user-visible "Downloads" folder,
@@ -170,13 +270,113 @@ export async function downloadAttachment(
}); });
} }
/** Share an already-materialised local file (zip bundles, extracted parts). */
export async function shareLocalFile(file: File, mimeType: string, dialogTitle?: string): Promise<void> {
if (Platform.OS === 'android' && (await openWithViewer(file, mimeType))) return;
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
try {
await Sharing.shareAsync(file.uri, { mimeType, dialogTitle: dialogTitle ?? file.name });
} finally {
scheduleTempCleanup(file);
}
}
/** Write bytes to the exports cache and hand them to a viewer / the share sheet. */
export async function shareBytes(bytes: Uint8Array, filename: string, mimeType: string): Promise<void> {
void sweepStaleExportFiles();
const dir = exportsDir();
ensureDir(dir);
const file = new File(dir, safeAttachmentName(filename, mimeType));
if (file.exists) file.delete();
file.create();
file.write(bytes);
await shareLocalFile(file, mimeType, filename);
}
async function authedBlobFetch(url: string): Promise<Response> {
await jmapClient.ensureFreshToken();
let r = await secureFetch(url, { headers: { Authorization: jmapClient.authHeader } });
if (r.status === 401 && (await jmapClient.forceRefreshToken())) {
r = await secureFetch(url, { headers: { Authorization: jmapClient.authHeader } });
}
if (!r.ok) throw new Error(`Download failed: ${r.status}`);
return r;
}
export async function fetchRawEmail(blobId: string, accountId?: string): Promise<string> { export async function fetchRawEmail(blobId: string, accountId?: string): Promise<string> {
const url = getDownloadUrl(blobId, 'email.eml', RFC822, accountId); const url = getDownloadUrl(blobId, 'email.eml', RFC822, accountId);
const r = await secureFetch(url, { headers: { Authorization: authHeader() } }); const r = await authedBlobFetch(url);
if (!r.ok) throw new Error(`Download failed: ${r.status}`);
return r.text(); return r.text();
} }
/** Fetch a blob's bytes with the same auth/refresh handling as the downloads. */
export async function fetchBlobBytes(
blobId: string,
name: string | undefined,
type: string | undefined,
accountId?: string,
): Promise<Uint8Array> {
const url = getDownloadUrl(blobId, name ?? 'blob', type ?? 'application/octet-stream', accountId);
const r = await authedBlobFetch(url);
return new Uint8Array(await r.arrayBuffer());
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)));
}
return global.btoa ? global.btoa(binary) : btoa(binary);
}
function base64PrefixBytes(b64: string, count: number): Uint8Array {
const slice = b64.slice(0, Math.ceil(count / 3) * 4);
try {
const bin = global.atob ? global.atob(slice) : atob(slice);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
} catch {
return new Uint8Array(0);
}
}
/**
* Resolve an inline (cid) image part to a `data:` URI for the body WebView.
* Small parts are fetched into memory; larger ones are streamed to a cache
* file and base64-encoded natively (the JS thread would otherwise stall for
* seconds on a multi-megabyte photo). The MIME is sniffed from the bytes so
* `application/octet-stream` image parts still render (#543).
*/
export async function fetchInlineImageDataUri(
att: Pick<Attachment, 'blobId' | 'name' | 'type' | 'size'>,
accountId?: string,
): Promise<string | null> {
if (!att.blobId) return null;
if (!att.size || att.size <= MAX_IN_MEMORY_INLINE_IMAGE_BYTES) {
const buf = await jmapClient.fetchBlobArrayBuffer(att.blobId, att.name, att.type, accountId);
const bytes = new Uint8Array(buf);
const mime = sniffImageMime(bytes, att.type);
return `data:${mime};base64,${bytesToBase64(bytes)}`;
}
const file = await cacheBlobFile(
att.blobId,
`inline-${att.blobId.replace(/[^a-z0-9]/gi, '_')}`,
att.type || 'application/octet-stream',
accountId,
);
try {
const b64 = await file.base64();
const mime = sniffImageMime(base64PrefixBytes(b64, 16), att.type);
return `data:${mime};base64,${b64}`;
} finally {
deleteQuietly(file);
}
}
function safeFilename(subject: string | undefined): string { function safeFilename(subject: string | undefined): string {
const base = (subject ?? 'email').replace(/[^\w\s-]/g, '').trim().slice(0, 60) || 'email'; const base = (subject ?? 'email').replace(/[^\w\s-]/g, '').trim().slice(0, 60) || 'email';
return `${base}.eml`; return `${base}.eml`;
@@ -192,15 +392,17 @@ export async function shareEmailEml(
const filename = email const filename = email
? emailExportFilename(email, emailFileOptions()) ? emailExportFilename(email, emailFileOptions())
: safeFilename(subjectFallback); : safeFilename(subjectFallback);
const dest = new File(Paths.cache, filename); const downloaded = await cacheBlobFile(blobId, filename, RFC822, accountId);
const url = getDownloadUrl(blobId, dest.name, RFC822, accountId);
const downloaded = await downloadInto(url, dest, Paths.cache);
if (!(await Sharing.isAvailableAsync())) { if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device'); throw new Error('Sharing is not available on this device');
} }
await Sharing.shareAsync(downloaded.uri, { try {
mimeType: RFC822, await Sharing.shareAsync(downloaded.uri, {
dialogTitle: 'Share email', mimeType: RFC822,
UTI: 'public.email-message', dialogTitle: 'Share email',
}); UTI: 'public.email-message',
});
} finally {
scheduleTempCleanup(downloaded);
}
} }
Binary file not shown.
+230
View File
@@ -0,0 +1,230 @@
/**
* Quote collapsing for the email viewer (webmail #480, port of
* `lib/quote-collapse.ts`).
*
* When a reply carries the original message as a trailing quote, hide it by
* default and inject a Gmail-style "•••" pill that toggles it.
*
* The HTML path runs inside the WebView, so it ships as an injected script
* (`buildQuoteCollapseScript`) rather than a DOM function - the page has no
* app CSS and the RN side has no DOM. Plain-text bodies are collapsed at
* string level (`collapsePlainTextQuotes`) with a script-less
* <details>/<summary> toggle.
*
* 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.
*/
export interface QuoteCollapseLabels {
/** Accessible label while collapsed, e.g. "Show quoted text". */
show: string;
/** Accessible label while expanded, e.g. "Hide quoted text". */
hide: string;
}
// Document order decides which marker wins when several are present.
export const QUOTE_CONTAINER_SELECTORS = [
// Bulwark's own reply/forward island.
'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',
];
export const QUOTE_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',
];
/** 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';
/**
* The in-page implementation of `setupQuoteCollapse`, as a self-contained
* script for `injectedJavaScript`. Idempotent per document. Uses inline
* styles only (style-src allows inline; neutral greys read fine after the
* dark-mode invert filter too).
*/
export function buildQuoteCollapseScript(labels: QuoteCollapseLabels): string {
const cfg = JSON.stringify({
containers: QUOTE_CONTAINER_SELECTORS,
separators: QUOTE_SEPARATOR_SELECTORS,
toggleAttr: QUOTE_TOGGLE_ATTR,
collapsedAttr: QUOTE_COLLAPSED_ATTR,
show: labels.show,
hide: labels.hide,
});
return `
(function () {
var C = ${cfg};
var doc = document;
var body = doc.body;
if (!body || body.querySelector('[' + C.toggleAttr + ']')) return;
var all = C.containers.concat(C.separators).join(',');
function hasVisibleContent(marker, side) {
var bit = side === 'before' ? Node.DOCUMENT_POSITION_PRECEDING : Node.DOCUMENT_POSITION_FOLLOWING;
var walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
for (var node = walker.nextNode(); node; node = walker.nextNode()) {
var rel = marker.compareDocumentPosition(node);
if (rel & Node.DOCUMENT_POSITION_CONTAINED_BY) continue;
if (!(rel & bit)) continue;
if (node.nodeType === Node.TEXT_NODE) {
if ((node.nodeValue || '').trim() !== '') return true;
} else if (node.tagName === 'IMG') {
if (!node.hasAttribute('data-blocked-src') && node.style.display !== 'none') return true;
}
}
return false;
}
function precedingTextEndsWithColon(el) {
var walker = doc.createTreeWalker(body, NodeFilter.SHOW_TEXT);
var last = null;
for (var node = walker.nextNode(); node; node = walker.nextNode()) {
var rel = el.compareDocumentPosition(node);
if (rel & Node.DOCUMENT_POSITION_CONTAINED_BY) continue;
if (!(rel & Node.DOCUMENT_POSITION_PRECEDING)) break;
var text = (node.nodeValue || '').trim();
if (text !== '') last = text;
}
return last !== null && /:$/.test(last);
}
function findAttributedBlockquote() {
var bqs = body.querySelectorAll('blockquote');
for (var i = 0; i < bqs.length; i++) {
var bq = bqs[i];
if (bq.parentElement && bq.parentElement.closest('blockquote')) continue;
if (precedingTextEndsWithColon(bq)) return bq;
}
return null;
}
function hideElement(el) {
el.setAttribute(C.collapsedAttr, el.style.display || '');
el.style.display = 'none';
}
function collectSeparatorRange(marker) {
var range = [marker];
var el = marker;
while (el && el !== body) {
for (var sib = el.nextElementSibling; sib; sib = sib.nextElementSibling) range.push(sib);
el = el.parentElement;
}
return range;
}
var marker = body.querySelector(all) || findAttributedBlockquote();
if (!marker) return;
var isSeparator = C.separators.some(function (sel) { return marker.matches(sel); });
if (!hasVisibleContent(marker, 'before')) return;
if (!isSeparator && hasVisibleContent(marker, 'after')) return;
var hidden = isSeparator ? collectSeparatorRange(marker) : [marker];
var button = doc.createElement('button');
button.type = 'button';
button.setAttribute(C.toggleAttr, '');
button.setAttribute('aria-expanded', 'false');
button.setAttribute('aria-label', C.show);
button.title = C.show;
button.textContent = '\\u2022\\u2022\\u2022';
button.style.cssText =
'display:inline-block;margin:12px 0 4px;padding:6px 14px;border:none;' +
'border-radius:999px;background:#e3e6ea;color:#3c4043;font-size:11px;' +
'line-height:1;letter-spacing:2px;cursor:pointer;font-family:inherit;';
marker.parentNode && marker.parentNode.insertBefore(button, marker);
hidden.forEach(hideElement);
button.addEventListener('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
var expanded = button.getAttribute('aria-expanded') === 'true';
hidden.forEach(function (el) {
if (expanded) {
hideElement(el);
} else {
el.style.display = el.getAttribute(C.collapsedAttr) || '';
el.removeAttribute(C.collapsedAttr);
}
});
button.setAttribute('aria-expanded', String(!expanded));
var label = expanded ? C.show : C.hide;
button.setAttribute('aria-label', label);
button.title = label;
if (window.__rnReport) setTimeout(window.__rnReport, 0);
});
})();
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). The quote
* run is wrapped in native <details>/<summary>, which toggles without any
* script.
*
* 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:6px 14px;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 : ''}`;
}
+119
View File
@@ -0,0 +1,119 @@
// List-Unsubscribe (RFC 2369 / RFC 8058) and mailto: parsing. Port of the
// relevant parts of the webmail's `lib/validation.ts`.
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function isValidEmail(value: string): boolean {
return EMAIL_RE.test((value ?? '').trim());
}
export function isValidUnsubscribeUrl(url: string): boolean {
if (!url?.trim()) return false;
if (url.startsWith('mailto:')) {
const email = url.substring(7);
const emailPart = email.split('?')[0];
return isValidEmail(emailPart);
}
try {
const parsed = new URL(url);
return ['http:', 'https:'].includes(parsed.protocol);
} catch {
return false;
}
}
export interface UnsubscribeUrls {
http?: string;
mailto?: string;
preferred?: 'http' | 'mailto';
}
/**
* Parse a List-Unsubscribe header and extract all valid URLs. RFC 2369 allows
* multiple comma-separated URLs in <url> format.
*/
export function parseUnsubscribeUrls(header: string): UnsubscribeUrls {
if (!header?.trim()) return {};
const matches = header.match(/<([^>]+)>/g);
if (!matches) return {};
const urls = matches.map((m) => m.slice(1, -1).trim());
const http = urls.find((u) =>
(u.startsWith('http://') || u.startsWith('https://')) && isValidUnsubscribeUrl(u),
);
const mailto = urls.find((u) => u.startsWith('mailto:') && isValidUnsubscribeUrl(u));
const preferred = http ? 'http' : (mailto ? 'mailto' : undefined);
return { http, mailto, preferred };
}
/**
* RFC 8058 one-click: when the message also carries
* `List-Unsubscribe-Post: List-Unsubscribe=One-Click`, the https URL accepts
* a POST with that body and unsubscribes without a confirmation page.
*/
export function isOneClickUnsubscribe(listUnsubscribePost: string | undefined, httpUrl: string | undefined): boolean {
if (!listUnsubscribePost || !httpUrl) return false;
if (!/^https:\/\//i.test(httpUrl)) return false;
return /list-unsubscribe\s*=\s*one-click/i.test(listUnsubscribePost);
}
export interface MailtoFields {
to: string[];
cc?: string[];
subject?: string;
body?: string;
}
/**
* Parse a mailto: URL into its parts so the client can send the message
* itself. Query values are percent-decoded manually rather than via
* URLSearchParams because RFC 6068 uses %-encoding only - a literal "+" in a
* subject or address must stay a plus, not become a space.
*/
export function parseMailtoUrl(url: string): MailtoFields | null {
if (!url || !/^mailto:/i.test(url)) return null;
const rest = url.slice(7);
const queryIndex = rest.indexOf('?');
const addressPart = queryIndex === -1 ? rest : rest.slice(0, queryIndex);
const query = queryIndex === -1 ? '' : rest.slice(queryIndex + 1);
const decode = (value: string): string => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const to = addressPart
.split(',')
.map((a) => decode(a).trim())
.filter((a) => isValidEmail(a));
let subject: string | undefined;
let body: string | undefined;
const cc: string[] = [];
for (const pair of query.split('&')) {
const eq = pair.indexOf('=');
if (eq === -1) continue;
const key = pair.slice(0, eq).toLowerCase();
const value = decode(pair.slice(eq + 1));
if (key === 'subject') subject = value;
else if (key === 'body') body = value;
else if (key === 'to') {
for (const a of value.split(',')) if (isValidEmail(a.trim())) to.push(a.trim());
} else if (key === 'cc') {
for (const a of value.split(',')) if (isValidEmail(a.trim())) cc.push(a.trim());
}
}
if (to.length === 0 && cc.length === 0) return null;
return { to, cc: cc.length ? cc : undefined, subject, body };
}
+46
View File
@@ -10,6 +10,10 @@ export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent'; export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent';
export type MailAttachmentAction = 'preview' | 'download'; export type MailAttachmentAction = 'preview' | 'download';
export type AttachmentPosition = 'beside-sender' | 'below-header'; export type AttachmentPosition = 'beside-sender' | 'below-header';
export type PlainTextFont = 'sans' | 'mono';
export type MessageSpacing = 'auto' | 'always' | 'edge';
export type ReadReceiptResponse = 'ask' | 'always' | 'never';
export type PostExportAction = 'keep' | 'archive' | 'trash';
export type SwipeAction = export type SwipeAction =
| 'none' | 'none'
| 'archive' | 'archive'
@@ -20,6 +24,7 @@ export type SwipeAction =
| 'pin' | 'pin'
| 'move'; | 'move';
export type SwipeMode = 'instant' | 'reveal'; export type SwipeMode = 'instant' | 'reveal';
export type SignaturePosition = 'above_quote' | 'below_quote';
// Actions that can be placed in the email reader's bottom quick-action bar. // Actions that can be placed in the email reader's bottom quick-action bar.
// The first three are the reply family (the default bar); any reply-family // The first three are the reply family (the default bar); any reply-family
// action the user removes from the bar is relocated to the top toolbar so it // action the user removes from the bar is relocated to the top toolbar so it
@@ -150,6 +155,19 @@ interface PersistedSettings {
// Undo-send window: every send is deferred by this many seconds (via the // Undo-send window: every send is deferred by this many seconds (via the
// server's FUTURERELEASE support) so it can be cancelled. 0 = send instantly. // server's FUTURERELEASE support) so it can be cancelled. 0 = send instantly.
sendDelaySeconds: number; sendDelaySeconds: number;
// Signature placement in replies/forwards and the RFC 3676 "-- " separator.
signaturePosition: SignaturePosition;
signatureSeparatorEnabled: boolean;
// Pre-check "request read receipt" in the composer.
requestReadReceiptDefault: boolean;
// Confirm before sending a message without a subject (#684).
emptySubjectWarningEnabled: boolean;
// Draft autosave debounce, milliseconds.
autoSaveDraftInterval: number;
// Character separating user from tag (e.g. "user+tag@"), RFC 5233.
subAddressDelimiter: string;
// Default sender identity per JMAP account id (#507).
preferredIdentityIds: Record<string, string>;
// Reading // Reading
markAsReadDelay: number; markAsReadDelay: number;
@@ -163,6 +181,13 @@ interface PersistedSettings {
disableThreading: boolean; disableThreading: boolean;
mailAttachmentAction: MailAttachmentAction; mailAttachmentAction: MailAttachmentAction;
attachmentPosition: AttachmentPosition; attachmentPosition: AttachmentPosition;
// Reader body: font for text/plain bodies (#830), gutter around the body,
// how to answer read-receipt requests (RFC 8098) and what to do with a
// message after it was exported as .eml.
plainTextFont: PlainTextFont;
messageSpacing: MessageSpacing;
readReceiptResponse: ReadReceiptResponse;
postExportAction: PostExportAction;
// Layout / list interactions // Layout / list interactions
swipeLeftAction: SwipeAction; swipeLeftAction: SwipeAction;
@@ -296,6 +321,13 @@ const DEFAULT_PERSISTED: PersistedSettings = {
], ],
plainTextMode: false, plainTextMode: false,
sendDelaySeconds: 0, sendDelaySeconds: 0,
signaturePosition: 'below_quote',
signatureSeparatorEnabled: true,
requestReadReceiptDefault: false,
emptySubjectWarningEnabled: true,
autoSaveDraftInterval: 60000,
subAddressDelimiter: '+',
preferredIdentityIds: {},
markAsReadDelay: 0, markAsReadDelay: 0,
deleteAction: 'trash', deleteAction: 'trash',
@@ -306,6 +338,10 @@ const DEFAULT_PERSISTED: PersistedSettings = {
disableThreading: false, disableThreading: false,
mailAttachmentAction: 'preview', mailAttachmentAction: 'preview',
attachmentPosition: 'beside-sender', attachmentPosition: 'beside-sender',
plainTextFont: 'sans',
messageSpacing: 'auto',
readReceiptResponse: 'ask',
postExportAction: 'keep',
swipeLeftAction: 'archive', swipeLeftAction: 'archive',
swipeRightAction: 'read', swipeRightAction: 'read',
@@ -467,11 +503,21 @@ const VALIDATORS: Partial<Record<keyof PersistedSettings, (v: unknown) => boolea
attachmentReminderKeywords: stringArray, attachmentReminderKeywords: stringArray,
// Same set the webmail accepts (stores/settings-store.ts importSettings). // Same set the webmail accepts (stores/settings-store.ts importSettings).
sendDelaySeconds: oneOf([0, 10, 30, 60]), sendDelaySeconds: oneOf([0, 10, 30, 60]),
signaturePosition: oneOf(['above_quote', 'below_quote']),
autoSaveDraftInterval: intBetween(1000, 3600000),
// RFC 5321 atext specials minus alphanumerics and "@" (lib/sub-addressing).
subAddressDelimiter: (v) => typeof v === 'string' && /^[!#$%&'*+\-./=?^_`{|}~]$/.test(v),
preferredIdentityIds: (v) => !!v && typeof v === 'object' && !Array.isArray(v)
&& Object.values(v as Record<string, unknown>).every((x) => typeof x === 'string'),
markAsReadDelay: (v) => typeof v === 'number' && Number.isFinite(v) && v >= -1, markAsReadDelay: (v) => typeof v === 'number' && Number.isFinite(v) && v >= -1,
deleteAction: oneOf(['trash', 'trash-and-read', 'permanent']), deleteAction: oneOf(['trash', 'trash-and-read', 'permanent']),
emailsPerPage: intBetween(1, 500), emailsPerPage: intBetween(1, 500),
mailAttachmentAction: oneOf(['preview', 'download']), mailAttachmentAction: oneOf(['preview', 'download']),
attachmentPosition: oneOf(['beside-sender', 'below-header']), attachmentPosition: oneOf(['beside-sender', 'below-header']),
plainTextFont: oneOf(['sans', 'mono']),
messageSpacing: oneOf(['auto', 'always', 'edge']),
readReceiptResponse: oneOf(['ask', 'always', 'never']),
postExportAction: oneOf(['keep', 'archive', 'trash']),
swipeLeftAction: oneOf(SWIPE_ACTIONS), swipeLeftAction: oneOf(SWIPE_ACTIONS),
swipeRightAction: oneOf(SWIPE_ACTIONS), swipeRightAction: oneOf(SWIPE_ACTIONS),
swipeMode: oneOf(['instant', 'reveal']), swipeMode: oneOf(['instant', 'reveal']),