mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +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 { View, StyleSheet, Linking, Platform, Text, Pressable } from 'react-native';
|
||||
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 type { Email } from '../api/types';
|
||||
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody, hasNativeDarkMode } from '../lib/email-html';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import {
|
||||
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 { useContactsStore } from '../stores/contacts-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||||
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
|
||||
// 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);
|
||||
}
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
interface EmailBodyViewProps {
|
||||
email: Email;
|
||||
@@ -38,22 +35,14 @@ interface EmailBodyViewProps {
|
||||
// the surrounding vertical scroll / horizontal pager so a zoom gesture can't
|
||||
// scroll the pane away or switch mails.
|
||||
onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void;
|
||||
}
|
||||
|
||||
function extractHtmlBody(email: Email): string | null {
|
||||
for (const part of email.htmlBody ?? []) {
|
||||
const v = part.partId ? email.bodyValues?.[part.partId]?.value : undefined;
|
||||
if (v) return v;
|
||||
}
|
||||
return 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;
|
||||
/** Per-message override of the light/dark rendering (More sheet toggle). */
|
||||
themeOverride?: 'light' | 'dark' | null;
|
||||
/**
|
||||
* Replace the message's own body - used when the outer message is an empty
|
||||
* envelope around a message/rfc822 attachment or a TNEF winmail.dat and the
|
||||
* real content was extracted client-side.
|
||||
*/
|
||||
bodyOverride?: { html?: string | null; text?: string | null } | null;
|
||||
}
|
||||
|
||||
// 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;
|
||||
var computed = win.getComputedStyle(el);
|
||||
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
|
||||
// Only re-invert if this container doesn't have media children.
|
||||
if (!el.querySelector('img, video, svg, canvas, object, embed')) {
|
||||
el.style.filter = 'invert(1) hue-rotate(180deg)';
|
||||
}
|
||||
// Re-invert so the background image returns to its original
|
||||
// appearance. Matches the CSS background-image rules: re-invert even
|
||||
// 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 = `
|
||||
(function () {
|
||||
// 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({
|
||||
email, senderEmail, jmapAccountId, onSwipe, onZoomChange,
|
||||
email, senderEmail, jmapAccountId, onSwipe, onZoomChange, themeOverride, bodyOverride,
|
||||
}: EmailBodyViewProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const navigation = useNavigation<Nav>();
|
||||
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
|
||||
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
|
||||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||
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 trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails);
|
||||
const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded);
|
||||
@@ -488,9 +534,9 @@ export default function EmailBodyView({
|
||||
const addressBookEmails = React.useMemo(() => {
|
||||
if (!trustedSendersAddressBook) return null;
|
||||
const out = new Set<string>();
|
||||
for (const c of contacts) {
|
||||
if (!c.emails) continue;
|
||||
for (const e of Object.values(c.emails)) {
|
||||
for (const card of contacts) {
|
||||
if (!card.emails) continue;
|
||||
for (const e of Object.values(card.emails)) {
|
||||
if (e?.address) out.add(e.address.toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -498,18 +544,21 @@ export default function EmailBodyView({
|
||||
}, [contacts, trustedSendersAddressBook]);
|
||||
// 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
|
||||
// emails on a light surface even while the rest of the app is dark.
|
||||
const renderAsDark = !emailAlwaysLightMode && resolvedTheme === 'dark';
|
||||
// emails on a light surface even while the rest of the app is dark; the
|
||||
// per-message toggle in the More sheet overrides both.
|
||||
const renderAsDark = themeOverride
|
||||
? themeOverride === 'dark'
|
||||
: !emailAlwaysLightMode && resolvedTheme === 'dark';
|
||||
|
||||
const html = React.useMemo(() => extractHtmlBody(email), [email]);
|
||||
const text = React.useMemo(() => extractTextBody(email), [email]);
|
||||
// Prefer textBody when the HTML is a minimal auto-generated wrapper that would
|
||||
// collapse newlines (mirrors webmail's hasMeaningfulHtmlBody fallback).
|
||||
const rawHtml = React.useMemo(
|
||||
() => (html && (!text || hasMeaningfulHtmlBody(html)) ? html : null),
|
||||
[html, text],
|
||||
);
|
||||
const hasRemote = rawHtml ? hasRemoteContent(rawHtml) : false;
|
||||
// RFC 8621 §4.1.4: an HTML-only message exposes the same part in textBody
|
||||
// and htmlBody, so the text part is only an alternative when its partId
|
||||
// differs (native #46). `pickEmailBody` routes by part type and id.
|
||||
const picked = React.useMemo(() => {
|
||||
if (bodyOverride) return { html: bodyOverride.html ?? null, text: bodyOverride.text ?? null };
|
||||
return pickEmailBody(email);
|
||||
}, [email, bodyOverride]);
|
||||
const rawHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]);
|
||||
const text = picked.text;
|
||||
const trusted = senderEmail
|
||||
? isSenderTrusted(senderEmail)
|
||||
|| trustedSenderEmails.includes(senderEmail.toLowerCase())
|
||||
@@ -522,14 +571,12 @@ export default function EmailBodyView({
|
||||
setAllowOnce(false);
|
||||
}, [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 =
|
||||
hasRemote &&
|
||||
!trusted &&
|
||||
!allowOnce &&
|
||||
externalContentPolicy !== 'allow';
|
||||
|
||||
const showBanner =
|
||||
hasRemote &&
|
||||
!!rawHtml &&
|
||||
!trusted &&
|
||||
!allowOnce &&
|
||||
externalContentPolicy !== 'allow';
|
||||
@@ -555,8 +602,7 @@ export default function EmailBodyView({
|
||||
}
|
||||
const toFetch = refs
|
||||
.map((ref) => ({ ref, att: byCid.get(ref) }))
|
||||
.filter((x): x is { ref: string; att: NonNullable<ReturnType<typeof byCid.get>> } => !!x.att)
|
||||
.filter(({ att }) => !att.size || att.size <= MAX_INLINE_IMAGE_BYTES);
|
||||
.filter((x): x is { ref: string; att: NonNullable<ReturnType<typeof byCid.get>> } => !!x.att);
|
||||
if (toFetch.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
@@ -565,12 +611,9 @@ export default function EmailBodyView({
|
||||
await Promise.all(
|
||||
toFetch.map(async ({ ref, att }) => {
|
||||
try {
|
||||
const buf = await jmapClient.fetchBlobArrayBuffer(
|
||||
att.blobId, att.name, att.type, jmapAccountId,
|
||||
);
|
||||
if (cancelled) return;
|
||||
const b64 = arrayBufferToBase64(buf);
|
||||
next[ref] = `data:${att.type || 'application/octet-stream'};base64,${b64}`;
|
||||
const uri = await fetchInlineImageDataUri(att, jmapAccountId);
|
||||
if (cancelled || !uri) return;
|
||||
next[ref] = uri;
|
||||
} catch (err) {
|
||||
console.warn('[EmailBodyView] cid fetch failed', ref, err);
|
||||
}
|
||||
@@ -583,39 +626,52 @@ export default function EmailBodyView({
|
||||
return () => { cancelled = true; };
|
||||
}, [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) {
|
||||
return {
|
||||
html: wrapEmailHtml(rawHtml, {
|
||||
blockRemoteImages: shouldBlock,
|
||||
cidMap,
|
||||
isDark: renderAsDark,
|
||||
}),
|
||||
};
|
||||
const res = prepareEmailHtml(rawHtml, {
|
||||
blockRemoteImages: shouldBlock,
|
||||
cidMap,
|
||||
isDark: renderAsDark,
|
||||
messageSpacing,
|
||||
});
|
||||
return { ...res, isHtml: true };
|
||||
}
|
||||
const fallbackText = text ?? email.preview ?? '';
|
||||
if (!fallbackText) {
|
||||
return {
|
||||
html: wrapEmailHtml('<em style="color:#71717a">(empty message)</em>', {
|
||||
isDark: renderAsDark,
|
||||
}),
|
||||
};
|
||||
const res = prepareEmailHtml(
|
||||
`<em style="color:#71717a">${t('email_viewer.no_body_content', '(No body content available)')}</em>`,
|
||||
{ isDark: renderAsDark },
|
||||
);
|
||||
return { ...res, isHtml: true };
|
||||
}
|
||||
const safe = collapsePlainTextQuotes(plainTextToSafeHtml(fallbackText), quoteLabels);
|
||||
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
|
||||
// own dark-mode CSS. When it's on, prepend the DOM re-inversion pass (emoji +
|
||||
// stylesheet backgrounds) so it runs before the height reporter measures.
|
||||
const applyInversion =
|
||||
!!rawHtml && renderAsDark && !hasNativeDarkMode(rawHtml);
|
||||
const source = React.useMemo(() => ({ html: prepared.html }), [prepared.html]);
|
||||
const showBanner = shouldBlock && prepared.blockedExternal;
|
||||
|
||||
// Inversion is applied for HTML bodies in dark mode unless the email ships
|
||||
// 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(
|
||||
() =>
|
||||
(applyInversion ? DARK_REINVERT_SCRIPT + HEIGHT_REPORTER : HEIGHT_REPORTER)
|
||||
+ SWIPE_DETECTOR + PINCH_ZOOM,
|
||||
[applyInversion],
|
||||
(prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '')
|
||||
+ (prepared.applyInversion ? DARK_REINVERT_SCRIPT : '')
|
||||
+ (prepared.isHtml ? IMG_FIXUP_SCRIPT : '')
|
||||
+ HEIGHT_REPORTER + SWIPE_DETECTOR + PINCH_ZOOM,
|
||||
[prepared.isHtml, prepared.applyInversion, quoteLabels],
|
||||
);
|
||||
|
||||
const onMessage = (e: WebViewMessageEvent) => {
|
||||
@@ -643,31 +699,52 @@ export default function EmailBodyView({
|
||||
const onLoadImages = () => setAllowOnce(true);
|
||||
const onTrustSender = () => {
|
||||
if (senderEmail) {
|
||||
// Keep the local allow-list for instant effect, and persist to the
|
||||
// dedicated "Trusted Senders" address book so the trust syncs across
|
||||
// devices (matches the webmail behavior).
|
||||
// Keep the local allow-list for instant effect. The server-side "Trusted
|
||||
// Senders" address book (synced across devices) is only written when
|
||||
// the user opted into it - otherwise a book would silently appear in
|
||||
// their contacts (matches the webmail).
|
||||
addTrustedSender(senderEmail);
|
||||
void addToTrustedSendersBook(senderEmail).catch(() => {});
|
||||
if (trustedSendersAddressBook) {
|
||||
void addToTrustedSendersBook(senderEmail).catch(() => {});
|
||||
}
|
||||
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 (
|
||||
<View style={styles.wrapper}>
|
||||
{showBanner && (
|
||||
<View style={styles.banner}>
|
||||
{externalContentPolicy === 'ask' && (
|
||||
<Pressable style={styles.bannerButton} onPress={onLoadImages} hitSlop={8}>
|
||||
<ImageIcon size={14} color={c.textSecondary} />
|
||||
<Text style={styles.bannerButtonText}>Load external content</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{senderEmail ? (
|
||||
<Pressable style={styles.bannerButton} onPress={onTrustSender} hitSlop={8}>
|
||||
<ShieldCheck size={14} color={c.textSecondary} />
|
||||
<Text style={styles.bannerButtonText}>Trust sender</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Text style={styles.bannerText}>
|
||||
{t('email_viewer.external_content_warning', 'Images and external content have been blocked')}
|
||||
</Text>
|
||||
<View style={styles.bannerActions}>
|
||||
{externalContentPolicy === 'ask' && (
|
||||
<Pressable style={styles.bannerButton} onPress={onLoadImages} hitSlop={8}>
|
||||
<ImageIcon size={14} color={c.textSecondary} />
|
||||
<Text style={styles.bannerButtonText}>{t('email_viewer.load_external_content', 'Load images')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{senderEmail ? (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -685,9 +762,9 @@ export default function EmailBodyView({
|
||||
// reload the source mid-zoom).
|
||||
onZoomChange?.({ pinching: false, zoomed: false });
|
||||
// Re-inject after load to cover Android cases where
|
||||
// `injectedJavaScript` runs too early to see the final layout. The
|
||||
// re-inversion pass guards itself (__rnDarkInvertDone) so running it
|
||||
// again here is a no-op once the body has been processed.
|
||||
// `injectedJavaScript` runs too early to see the final layout. Every
|
||||
// pass guards itself so running it again here is a no-op once the
|
||||
// body has been processed.
|
||||
setTimeout(() => {
|
||||
webviewRef.current?.injectJavaScript(injectedJs);
|
||||
}, 50);
|
||||
@@ -714,10 +791,15 @@ export default function EmailBodyView({
|
||||
containerStyle={styles.webviewContainer}
|
||||
onShouldStartLoadWithRequest={(request) => {
|
||||
const url = request.url;
|
||||
if (!url || url === 'about:blank' || url.startsWith('data:')) {
|
||||
return true;
|
||||
// Only the initial about:blank document may load. A `data:` (or
|
||||
// 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);
|
||||
}
|
||||
return false;
|
||||
@@ -732,16 +814,23 @@ function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
wrapper: { width: '100%' },
|
||||
banner: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.surfaceHover,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
bannerText: {
|
||||
...typography.caption,
|
||||
color: c.textMuted,
|
||||
},
|
||||
bannerActions: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
},
|
||||
bannerButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settin
|
||||
import Input from '../Input';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore, type SpaceReplacement } from '../../stores/settings-store';
|
||||
import { useSettingsStore, type SpaceReplacement, type PostExportAction } from '../../stores/settings-store';
|
||||
import {
|
||||
emailExportFilename,
|
||||
attachmentDownloadFilename,
|
||||
@@ -32,6 +32,7 @@ export function DownloadsSettings() {
|
||||
const spaceReplacement = useSettingsStore((s) => s.exportSpaceReplacement);
|
||||
const lowercase = useSettingsStore((s) => s.exportLowercase);
|
||||
const stripDiacritics = useSettingsStore((s) => s.exportStripDiacritics);
|
||||
const postExportAction = useSettingsStore((s) => s.postExportAction);
|
||||
|
||||
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||
|
||||
@@ -120,6 +121,23 @@ export function DownloadsSettings() {
|
||||
<ToggleSwitch checked={stripDiacritics} onChange={(v) => update('exportStripDiacritics', v)} />
|
||||
</SettingItem>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
type DeleteAction,
|
||||
type MailAttachmentAction,
|
||||
type AttachmentPosition,
|
||||
type PlainTextFont,
|
||||
type MessageSpacing,
|
||||
type ReadReceiptResponse,
|
||||
} from '../../stores/settings-store';
|
||||
import { useEmailStore } from '../../stores/email-store';
|
||||
import { archiveEmails, queryEmails, getEmails } from '../../api/email';
|
||||
@@ -37,6 +40,9 @@ export function ReadingSettings() {
|
||||
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
|
||||
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
|
||||
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 [reorganizing, setReorganizing] = useState(false);
|
||||
@@ -283,6 +289,41 @@ export function ReadingSettings() {
|
||||
/>
|
||||
</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.">
|
||||
<Select
|
||||
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() {}
|
||||
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 {
|
||||
File,
|
||||
Directory: class {},
|
||||
Directory,
|
||||
Paths: { cache: { uri: 'file:///cache/' }, document: { uri: 'file:///documents/' } },
|
||||
};
|
||||
});
|
||||
@@ -54,8 +63,16 @@ vi.mock('../client-cert', () => ({
|
||||
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', () => ({
|
||||
jmapClient: { authHeader: 'Bearer token' },
|
||||
jmapClient: jmapClientMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../api/blob', () => ({
|
||||
@@ -96,7 +113,7 @@ describe('shareAttachment (preview)', () => {
|
||||
await shareAttachment('blob-2', 'weird.xyz', 'application/x-weird');
|
||||
|
||||
// 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',
|
||||
dialogTitle: 'weird.xyz',
|
||||
});
|
||||
@@ -112,13 +129,37 @@ describe('shareAttachment (preview)', () => {
|
||||
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 () => {
|
||||
platform.OS = 'ios';
|
||||
|
||||
await shareAttachment('blob-4', 'photo.jpg', 'image/jpeg');
|
||||
|
||||
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',
|
||||
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 { jmapClient } from '../api/jmap-client';
|
||||
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 {
|
||||
attachmentDownloadFilename,
|
||||
@@ -12,10 +12,19 @@ import {
|
||||
type EmailFilenameOptions,
|
||||
} from './download-filename';
|
||||
import { getClientCertAlias, secureFetch } from './client-cert';
|
||||
import { sniffImageMime } from './email-html';
|
||||
|
||||
const RFC822 = 'message/rfc822';
|
||||
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.
|
||||
function emailFileOptions(): EmailFilenameOptions {
|
||||
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 {
|
||||
const fallbackExt = type?.split('/')[1]?.replace(/[^a-z0-9]/gi, '').slice(0, 8) || 'bin';
|
||||
const cleaned = (name ?? '').replace(/[\\/:*?"<>|]/g, '_').trim();
|
||||
@@ -48,6 +53,62 @@ function safeAttachmentName(name: string | undefined, type: string | undefined):
|
||||
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
|
||||
// "Only local file URLs are supported". On Android it then wraps the file
|
||||
// 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
|
||||
// has picked a cert, and via expo-file-system's native streaming downloader
|
||||
// otherwise. The streaming path scales to large attachments without buffering
|
||||
// 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(
|
||||
url: string,
|
||||
dest: File,
|
||||
parent: Directory,
|
||||
): Promise<File> {
|
||||
await jmapClient.ensureFreshToken();
|
||||
const alias = await getClientCertAlias();
|
||||
if (!alias) {
|
||||
// The static returns a separately-typed `FileSystemFile`; we already
|
||||
// have a fully-typed `File` referencing the same uri, so we ignore the
|
||||
// return value and re-use our `dest` reference for downstream code.
|
||||
await File.downloadFileAsync(url, dest, {
|
||||
headers: { Authorization: authHeader() },
|
||||
idempotent: true,
|
||||
ensureDir(parent);
|
||||
const attempt = async (): Promise<File> => {
|
||||
if (!alias) {
|
||||
// The static returns a separately-typed `FileSystemFile`; we already
|
||||
// have a fully-typed `File` referencing the same uri, so we ignore the
|
||||
// return value and re-use our `dest` reference for downstream code.
|
||||
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;
|
||||
};
|
||||
try {
|
||||
return await attempt();
|
||||
} catch (err) {
|
||||
if (isUnauthorizedError(err) && (await jmapClient.forceRefreshToken())) {
|
||||
return attempt();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const response = await secureFetch(url, {
|
||||
headers: { Authorization: authHeader() },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed: ${response.status}`);
|
||||
}
|
||||
if (!parent.exists) parent.create({ intermediates: true, idempotent: true });
|
||||
if (dest.exists) dest.delete();
|
||||
const buffer = await response.arrayBuffer();
|
||||
dest.create();
|
||||
dest.write(new Uint8Array(buffer));
|
||||
return dest;
|
||||
}
|
||||
|
||||
/** Download a blob into the exports cache directory (caller cleans up). */
|
||||
export async function cacheBlobFile(
|
||||
blobId: string,
|
||||
filename: string,
|
||||
mimeType: string,
|
||||
accountId?: string,
|
||||
): Promise<File> {
|
||||
void sweepStaleExportFiles();
|
||||
const dir = exportsDir();
|
||||
const dest = new File(dir, filename);
|
||||
const url = getDownloadUrl(blobId, filename, mimeType, accountId);
|
||||
return downloadInto(url, dest, dir);
|
||||
}
|
||||
|
||||
export async function shareAttachment(
|
||||
@@ -124,20 +220,24 @@ export async function shareAttachment(
|
||||
? attachmentDownloadFilename(email, { name, type }, attachmentFileOptions())
|
||||
: safeAttachmentName(name, type);
|
||||
const mimeType = type || 'application/octet-stream';
|
||||
const dest = new File(Paths.cache, filename);
|
||||
const url = getDownloadUrl(blobId, filename, mimeType, accountId);
|
||||
const downloaded = await downloadInto(url, dest, Paths.cache);
|
||||
const downloaded = await cacheBlobFile(blobId, filename, mimeType, accountId);
|
||||
|
||||
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;
|
||||
}
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
}
|
||||
await Sharing.shareAsync(downloaded.uri, {
|
||||
mimeType,
|
||||
dialogTitle: filename,
|
||||
});
|
||||
try {
|
||||
await Sharing.shareAsync(downloaded.uri, {
|
||||
mimeType,
|
||||
dialogTitle: filename,
|
||||
});
|
||||
} finally {
|
||||
scheduleTempCleanup(downloaded);
|
||||
}
|
||||
}
|
||||
|
||||
// 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> {
|
||||
const url = getDownloadUrl(blobId, 'email.eml', RFC822, accountId);
|
||||
const r = await secureFetch(url, { headers: { Authorization: authHeader() } });
|
||||
if (!r.ok) throw new Error(`Download failed: ${r.status}`);
|
||||
const r = await authedBlobFetch(url);
|
||||
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 {
|
||||
const base = (subject ?? 'email').replace(/[^\w\s-]/g, '').trim().slice(0, 60) || 'email';
|
||||
return `${base}.eml`;
|
||||
@@ -192,15 +392,17 @@ export async function shareEmailEml(
|
||||
const filename = email
|
||||
? emailExportFilename(email, emailFileOptions())
|
||||
: safeFilename(subjectFallback);
|
||||
const dest = new File(Paths.cache, filename);
|
||||
const url = getDownloadUrl(blobId, dest.name, RFC822, accountId);
|
||||
const downloaded = await downloadInto(url, dest, Paths.cache);
|
||||
const downloaded = await cacheBlobFile(blobId, filename, RFC822, accountId);
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
}
|
||||
await Sharing.shareAsync(downloaded.uri, {
|
||||
mimeType: RFC822,
|
||||
dialogTitle: 'Share email',
|
||||
UTI: 'public.email-message',
|
||||
});
|
||||
try {
|
||||
await Sharing.shareAsync(downloaded.uri, {
|
||||
mimeType: RFC822,
|
||||
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 MailAttachmentAction = 'preview' | 'download';
|
||||
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 =
|
||||
| 'none'
|
||||
| 'archive'
|
||||
@@ -20,6 +24,7 @@ export type SwipeAction =
|
||||
| 'pin'
|
||||
| 'move';
|
||||
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.
|
||||
// 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
|
||||
@@ -150,6 +155,19 @@ interface PersistedSettings {
|
||||
// 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.
|
||||
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
|
||||
markAsReadDelay: number;
|
||||
@@ -163,6 +181,13 @@ interface PersistedSettings {
|
||||
disableThreading: boolean;
|
||||
mailAttachmentAction: MailAttachmentAction;
|
||||
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
|
||||
swipeLeftAction: SwipeAction;
|
||||
@@ -296,6 +321,13 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
],
|
||||
plainTextMode: false,
|
||||
sendDelaySeconds: 0,
|
||||
signaturePosition: 'below_quote',
|
||||
signatureSeparatorEnabled: true,
|
||||
requestReadReceiptDefault: false,
|
||||
emptySubjectWarningEnabled: true,
|
||||
autoSaveDraftInterval: 60000,
|
||||
subAddressDelimiter: '+',
|
||||
preferredIdentityIds: {},
|
||||
|
||||
markAsReadDelay: 0,
|
||||
deleteAction: 'trash',
|
||||
@@ -306,6 +338,10 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
disableThreading: false,
|
||||
mailAttachmentAction: 'preview',
|
||||
attachmentPosition: 'beside-sender',
|
||||
plainTextFont: 'sans',
|
||||
messageSpacing: 'auto',
|
||||
readReceiptResponse: 'ask',
|
||||
postExportAction: 'keep',
|
||||
|
||||
swipeLeftAction: 'archive',
|
||||
swipeRightAction: 'read',
|
||||
@@ -467,11 +503,21 @@ const VALIDATORS: Partial<Record<keyof PersistedSettings, (v: unknown) => boolea
|
||||
attachmentReminderKeywords: stringArray,
|
||||
// Same set the webmail accepts (stores/settings-store.ts importSettings).
|
||||
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,
|
||||
deleteAction: oneOf(['trash', 'trash-and-read', 'permanent']),
|
||||
emailsPerPage: intBetween(1, 500),
|
||||
mailAttachmentAction: oneOf(['preview', 'download']),
|
||||
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),
|
||||
swipeRightAction: oneOf(SWIPE_ACTIONS),
|
||||
swipeMode: oneOf(['instant', 'reveal']),
|
||||
|
||||
Reference in New Issue
Block a user