mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
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:
+204
-115
@@ -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',
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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="javascript: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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> original line 1\n>\n> original line 2';
|
||||||
|
const out = collapsePlainTextQuotes(html, labels);
|
||||||
|
expect(out).toContain('<details><summary title="Show quoted text"');
|
||||||
|
expect(out).toContain('> original line 1\n>\n> 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> 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('> a\nreply below', labels)).toBe('> a\nreply below');
|
||||||
|
expect(collapsePlainTextQuotes('> only quote', labels)).toBe('> only quote');
|
||||||
|
expect(collapsePlainTextQuotes('no quotes here', labels)).toBe('no quotes here');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes the label', () => {
|
||||||
|
const out = collapsePlainTextQuotes('a\n> b', { show: 'x"<y', hide: 'h' });
|
||||||
|
expect(out).toContain('title="x"<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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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.
@@ -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, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ">"-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*>/.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 : ''}`;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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']),
|
||||||
|
|||||||
Reference in New Issue
Block a user