Merge branch 'cu-reader' into catch-up-2026-11

# Conflicts:
#	docs/native-sync-2026-11.md
#	docs/parity/11-webmail-1.19.md
#	locales/rn/en.json
#	src/stores/settings-store.ts
This commit is contained in:
Linus Rath
2026-10-31 05:02:08 +01:00
86 changed files with 3473 additions and 234 deletions
+86 -7
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { View, StyleSheet, Linking, Platform, Text, Pressable, useWindowDimensions } from 'react-native';
import { View, StyleSheet, Linking, Platform, Text, Pressable, PixelRatio, useWindowDimensions } 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';
@@ -14,11 +14,15 @@ import { parseMailtoUrl } from '../lib/unsubscribe';
import { fetchInlineImageDataUri } from '../lib/email-export';
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders';
import { senderVerificationOf } from '../lib/email-headers';
import { plainTextReading } from '../lib/html-to-readable-text';
import { buildMailTextSizeScript, mailTextFactor } from '../lib/mail-reading';
import { buildPauseAnimatedImagesScript, withReducedMotion } from '../lib/mail-motion';
import { useHasContacts } from '../lib/capabilities';
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 { useShouldAnimate, useTypography } from '../theme/dynamic';
import { useColors, useResolvedTheme } from '../theme/colors';
import { perfEndAfterPaint, perfLap } from '../lib/perf-marks';
import type { RootStackParamList } from '../navigation/types';
@@ -54,6 +58,13 @@ interface EmailBodyViewProps {
* page below the header) rather than a guessed height.
*/
fill?: boolean;
/**
* Per-message "Show as plain text" from the More sheet; null or undefined
* follows the "Plain Text Only" setting.
*/
plainView?: boolean | null;
/** "Show formatted" in the plain-text note: turn the per-message view off. */
onShowFormatted?: () => void;
}
// Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The
@@ -521,6 +532,7 @@ const PINCH_ZOOM = `
export default function EmailBodyView({
email, senderEmail, jmapAccountId, onSwipe, onZoomChange, themeOverride, bodyOverride, onSettled, fill,
plainView: plainViewOverride, onShowFormatted,
}: EmailBodyViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -535,6 +547,16 @@ export default function EmailBodyView({
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
// Body text follows the app's text size and the system font scale (webmail
// c07ba1a37); it was fixed at 14 px. The app's factor is read off its
// scaled body style, so it follows whatever steps the text size setting has.
const dyn = useTypography();
const textFactor = mailTextFactor(dyn.body.fontSize / typography.body.fontSize, PixelRatio.getFontScale());
// Reduce motion (the system setting, or the app's animations switch) holds
// inside mail too: CSS animations stop and animated images pause behind a
// Play button (webmail 24a13cc1f, bb7869798).
const reduceMotion = !useShouldAnimate();
const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails);
const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded);
const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook);
@@ -562,8 +584,19 @@ export default function EmailBodyView({
if (bodyOverride) return { html: bodyOverride.html ?? null, text: bodyOverride.text ?? null };
return pickEmailBody(email);
}, [email, bodyOverride]);
const rawHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]);
const text = picked.text;
const formattedHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]);
// HTML mail as plain text: "Plain Text Only" for every message, or the More
// sheet's per-message switch. The message's own text part when it has one,
// else a readable conversion that keeps link targets and image alt text
// (webmail 1e929b0a9). A plain-text view loads nothing remote, so the
// external-content banner goes too.
const plainView = plainViewOverride ?? plainTextMode;
const plainText = React.useMemo(
() => (plainView ? plainTextReading({ html: formattedHtml, text: picked.text }) : null),
[plainView, formattedHtml, picked.text],
);
const rawHtml = plainText !== null ? null : formattedHtml;
const text = plainText ?? picked.text;
// A From address the server's checks don't back may be forged: neither
// honour an earlier "Always trust" for it nor offer one, or a forgery of a
// trusted sender would load remote content (and trusting it would let the
@@ -660,6 +693,13 @@ export default function EmailBodyView({
// Sanitised and wrapped once per message, body and settings, and reused
// when the same body mounts again (lib/email-body-document).
const emptyLabel = t('email_viewer.no_body_content', '(No body content available)');
// A plain-text body in a dark app theme takes the theme's colours, not a
// fixed #0a0a0a. Only while the app itself is dark: a single message
// switched to dark in a light app has no dark palette to borrow.
const plainDarkColors = React.useMemo(
() => (resolvedTheme === 'dark' ? { background: c.background, text: c.text, link: c.textLink } : undefined),
[resolvedTheme, c.background, c.text, c.textLink],
);
const prepared = React.useMemo(() => bodyDocument({
key: `${jmapAccountId ?? ''}|${email.id}`,
rawHtml,
@@ -671,12 +711,21 @@ export default function EmailBodyView({
messageSpacing,
plainTextFont,
quoteLabels,
textFactor,
plainDarkColors,
}), [
jmapAccountId, email.id, rawHtml, text, email.preview, emptyLabel, shouldBlock, cidMap, renderAsDark,
messageSpacing, plainTextFont, quoteLabels,
messageSpacing, plainTextFont, quoteLabels, textFactor, plainDarkColors,
]);
const source = React.useMemo(() => ({ html: prepared.html }), [prepared.html]);
const source = React.useMemo(
() => ({ html: reduceMotion && prepared.isHtml ? withReducedMotion(prepared.html) : prepared.html }),
[prepared.html, prepared.isHtml, reduceMotion],
);
const pauseLabels = React.useMemo(() => ({
play: t('email_viewer.play_animation', 'Play animation'),
pause: t('email_viewer.pause_animation', 'Pause animation'),
}), [t]);
const showBanner = shouldBlock && prepared.blockedExternal;
// Still cut off after the API's larger refetch (or the refetch failed).
const showTruncated = !bodyOverride && hasTruncatedDisplayedBody(email);
@@ -686,11 +735,13 @@ export default function EmailBodyView({
// input the CSS was built from, so the DOM pass and the stylesheet agree.
const injectedJs = React.useMemo(
() =>
(prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '')
(prepared.isHtml ? buildMailTextSizeScript(textFactor) : '')
+ (prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '')
+ (prepared.applyInversion ? DARK_REINVERT_SCRIPT : '')
+ (prepared.isHtml ? IMG_FIXUP_SCRIPT : '')
+ (prepared.isHtml && reduceMotion ? buildPauseAnimatedImagesScript(pauseLabels) : '')
+ HEIGHT_REPORTER + SWIPE_DETECTOR + PINCH_ZOOM,
[prepared.isHtml, prepared.applyInversion, quoteLabels],
[prepared.isHtml, prepared.applyInversion, quoteLabels, textFactor, reduceMotion, pauseLabels],
);
// Performance harness: how much of opening a message is the WebView.
@@ -765,6 +816,18 @@ export default function EmailBodyView({
return (
<View style={[styles.wrapper, fill && styles.wrapperFill]}>
{plainText !== null && (
<View style={styles.plainNote}>
<Text style={styles.bannerText}>
{t('email_viewer.shown_as_plain_text', 'This message is shown as plain text.')}
</Text>
{onShowFormatted && (
<Pressable onPress={onShowFormatted} hitSlop={8} accessibilityRole="button">
<Text style={styles.plainNoteLink}>{t('email_viewer.show_formatted', 'Show formatted')}</Text>
</Pressable>
)}
</View>
)}
{showBanner && (
<View style={styles.banner}>
<Text style={styles.bannerText}>
@@ -821,6 +884,9 @@ export default function EmailBodyView({
// Native Android zoom would scale the viewport inside the fixed-size
// container; pinch zoom is implemented in the page instead.
setBuiltInZoomControls={false}
// The system font size is part of textFactor; Android's WebView
// would scale the text by it a second time.
textZoom={100}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
setSupportMultipleWindows={false}
@@ -895,6 +961,19 @@ function makeStyles(c: ThemePalette) {
...typography.caption,
color: c.textSecondary,
},
plainNote: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
columnGap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingTop: spacing.sm,
},
plainNoteLink: {
...typography.caption,
color: c.primary,
textDecorationLine: 'underline',
},
webContainer: {
width: '100%',
backgroundColor: 'transparent',
+15 -10
View File
@@ -13,19 +13,24 @@ import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailb
import { useSheetDrag } from '../lib/use-sheet-drag';
import { useLocaleStore } from '../stores/locale-store';
import { localizeMailboxName } from '../lib/mailbox-label';
import { mailboxIconKind, type MailboxIconKind } from '../lib/mailbox-icon';
import type { Mailbox } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
function moveTargetIcon(role: string | null | undefined, name: string): AppIcon {
const lower = name.toLowerCase();
if (role === 'inbox' || lower.includes('inbox')) return Inbox;
if (role === 'sent' || lower.includes('sent')) return Send;
if (role === 'drafts' || lower.includes('draft')) return FileIcon;
if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2;
if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban;
if (role === 'archive' || lower.includes('archive')) return Archive;
return Folder;
// By role only, like the sidebar (mailboxIconKind, #1235): a user folder
// named "web/sentry" is not the Sent folder.
const MOVE_TARGET_ICONS: Partial<Record<MailboxIconKind, AppIcon>> = {
inbox: Inbox,
sent: Send,
drafts: FileIcon,
trash: Trash2,
junk: Ban,
archive: Archive,
};
function moveTargetIcon(role: string | null | undefined): AppIcon {
return MOVE_TARGET_ICONS[mailboxIconKind(role, false, false)] ?? Folder;
}
interface MoveSheetProps {
@@ -106,7 +111,7 @@ export function MoveSheet({
</View>
<ScrollView>
{visibleNodes.map((node) => {
const Icon = moveTargetIcon(node.role, node.name);
const Icon = moveTargetIcon(node.role);
const isCurrent = node.id === currentMailboxId;
// A shared account's header is a grouping row, not a folder, and
// Drafts is never a move target (the webmail excludes it too).
+100
View File
@@ -0,0 +1,100 @@
import React from 'react';
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
import { Plus } from './icons';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useLocaleStore } from '../stores/locale-store';
import { keywordToken, useKeywordsStore, type KeywordDef } from '../stores/keywords-store';
import { checkNewTag, newTagDefinition } from '../lib/new-tag';
/**
* "New tag" at the end of a tag picker: a name field; the tag gets the next
* colour of the palette, is saved with the other tags and goes onto the
* message(s) straight away (webmail components/email/tag-picker.tsx).
*/
export function NewTagRow({ keywords, onCreated }: {
/** The tags the picker lists: a new id must not repeat one of them. */
keywords: KeywordDef[];
/** Apply the new tag's keyword token. */
onCreated: (token: string) => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const addKeyword = useKeywordsStore((s) => s.add);
const [creating, setCreating] = React.useState(false);
const [name, setName] = React.useState('');
const { error } = checkNewTag(name, keywords);
const errorText = error === 'tag_exists'
? t('email_viewer.tag_exists', 'A tag with this name already exists')
: error === 'tag_name_invalid' ? t('email_viewer.tag_name_invalid', 'Choose a shorter name') : null;
const create = () => {
const def = newTagDefinition(name, keywords);
if (!def) return;
addKeyword(def);
setCreating(false);
setName('');
onCreated(keywordToken(def.id));
};
if (!creating) {
return (
<Pressable
onPress={() => setCreating(true)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
accessibilityRole="button"
testID="tag-picker-new"
>
<Plus size={16} color={c.textMuted} />
<Text style={styles.label}>{t('email_viewer.new_tag', 'New tag')}</Text>
</Pressable>
);
}
return (
<View style={styles.form}>
<TextInput
autoFocus
value={name}
onChangeText={setName}
onSubmitEditing={create}
returnKeyType="done"
placeholder={t('email_viewer.new_tag_placeholder', 'Tag name')}
placeholderTextColor={c.textMuted}
accessibilityLabel={t('email_viewer.new_tag_placeholder', 'Tag name')}
style={[styles.input, errorText ? styles.inputError : null]}
/>
{errorText && <Text style={styles.error} accessibilityRole="alert">{errorText}</Text>}
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 44,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: c.border,
},
rowPressed: { backgroundColor: c.surfaceHover },
label: { ...typography.body, color: c.textSecondary, flex: 1 },
form: { paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, gap: spacing.xs },
input: {
...typography.body,
color: c.text,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
minHeight: 40,
},
inputError: { borderColor: c.error },
error: { ...typography.caption, color: c.error },
});
}
+43 -12
View File
@@ -2,6 +2,8 @@ import React from 'react';
import { View, Text, Image, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
import { useShallow } from 'zustand/react/shallow';
import {
avatarContent,
initialsColor,
generateEmailAvatarColor,
getContactPhotoForEmail,
getEmailInitials,
@@ -14,6 +16,8 @@ import type { ContactPhotoRef } from '../lib/contact-utils';
import { useContactPhotoSource } from '../lib/use-contact-photo';
import { useSettingsStore } from '../stores/settings-store';
import { useContactsStore } from '../stores/contacts-store';
import { useColors } from '../theme/colors';
import { ShieldAlert } from './icons';
interface SenderAvatarProps {
name?: string | null;
@@ -32,23 +36,32 @@ interface SenderAvatarProps {
* the contact has no photo, so no other card's photo is borrowed.
*/
photo?: ContactPhotoRef | null;
/**
* The message's sender check failed or is unverified (see
* useSenderWarning): a warning shield with this label instead of any photo,
* favicon or initials, so a forged From doesn't get the real sender's logo
* or photo (webmail #1439).
*/
senderWarning?: string | null;
}
// Mirrors the webmail `Avatar` priority for the subset available on native:
// address-book photo → company favicon (if sender domain is non-personal and
// `senderFavicons` is on) → initials over HSL-hashed color.
export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo }: SenderAvatarProps) {
const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages;
// sender warning → address-book photo → company favicon (if sender domain is
// non-personal and `senderFavicons` is on) → initials over HSL-hashed color.
export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo, senderWarning }: SenderAvatarProps) {
const c = useColors();
const warned = !!senderWarning;
const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned;
// Compared shallowly, so only avatars whose photo changed re-render when the
// contacts change; the index behind it is built once per contacts array.
const indexedPhoto = useContactsStore(
useShallow((s) => (photo === undefined ? getContactPhotoForEmail(s.contacts, email) : undefined)),
useShallow((s) => (photo === undefined && !warned ? getContactPhotoForEmail(s.contacts, email) : undefined)),
);
const contactPhoto = photo === undefined ? indexedPhoto : photo;
// A blob photo (Stalwart 1.0) has no source while it waits for its
// download or after it failed; the favicon or initials show meanwhile.
const { source: photoSource, onLoad: onPhotoLoad, onError: onPhotoError } =
useContactPhotoSource(disableImages ? null : contactPhoto);
useContactPhotoSource(disableImages || warned ? null : contactPhoto);
const [imgError, setImgError] = React.useState(false);
React.useEffect(() => {
@@ -61,8 +74,11 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
const faviconDomain = getFaviconDomain(email ?? undefined);
const domainFailed = faviconDomain ? hasFaviconFailed(faviconDomain) : false;
const showPhoto = !disableImages && !!photoSource;
const showFavicon = !showPhoto && senderFavicons && !!faviconDomain && !imgError && !domainFailed;
const content = avatarContent({
senderWarning,
hasPhoto: !disableImages && !!photoSource,
showFavicon: senderFavicons && !!faviconDomain && !imgError && !domainFailed,
});
const handleError = React.useCallback(() => {
setImgError(true);
@@ -75,14 +91,29 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: showPhoto ? 'transparent' : showFavicon ? '#ffffff' : bgColor,
backgroundColor: content === 'warning'
? c.errorBg
: content === 'photo' ? 'transparent' : content === 'favicon' ? '#ffffff' : bgColor,
},
style,
];
const fontSize = Math.round(size * 0.4);
if (showPhoto && photoSource) {
if (content === 'warning') {
return (
<View
style={containerStyle}
accessible
accessibilityLabel={`${primaryName || email || ''} (${senderWarning})`}
testID="sender-warning-avatar"
>
<ShieldAlert size={Math.round(size * 0.5)} color={c.error} />
</View>
);
}
if (content === 'photo' && photoSource) {
return (
<View style={containerStyle}>
<Image
@@ -96,7 +127,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
);
}
if (showFavicon && faviconDomain) {
if (content === 'favicon' && faviconDomain) {
return (
<View style={containerStyle}>
<Image
@@ -111,7 +142,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
return (
<View style={containerStyle}>
<Text style={[styles.initials, { fontSize }]}>{initials}</Text>
<Text style={[styles.initials, { fontSize, color: initialsColor(bgColor) }]}>{initials}</Text>
</View>
);
}
+14 -4
View File
@@ -15,7 +15,7 @@ import {
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useAnimDuration } from '../theme/dynamic';
import { useEmailStore, spannedAccounts } from '../stores/email-store';
import { useEmailStore, spannedAccounts, trashAndJunkIds } from '../stores/email-store';
import { useAuthStore } from '../stores/auth-store';
import { useAccountStore } from '../stores/account-store';
import { useSettingsStore } from '../stores/settings-store';
@@ -128,7 +128,7 @@ function RowCounts({ unread, total, showTotal, onPressUnread }: {
disabled={!onPressUnread}
hitSlop={8}
accessibilityRole={onPressUnread ? 'button' : undefined}
accessibilityLabel={onPressUnread ? t('sidebar.show_unread', 'Show unread') : undefined}
accessibilityLabel={onPressUnread ? t('sidebar.unread_filter', 'Show unread only ({count})', { count: unread }) : undefined}
>
<Text style={styles.countUnread}>{unread}</Text>
</Pressable>
@@ -167,11 +167,19 @@ function SidebarRow({
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const leftPad = ROW_PX_BASE + depth * INDENT_STEP;
// Which folder is open, and the unread count with the folder's name
// ("Inbox, 3 unread"), for screen readers (webmail 7c282f1f4).
const accessibleName = unread > 0 && !isAccountHeader
? t('shell.name_unread', '{name}, {count} unread', { name: label, count: unread })
: label;
return (
<Pressable
onPress={onPress}
onLongPress={onLongPress}
delayLongPress={350}
accessibilityRole={isAccountHeader ? 'header' : 'button'}
accessibilityLabel={accessibleName}
accessibilityState={isAccountHeader ? undefined : { selected: isSelected }}
style={({ pressed }) => [
styles.row,
isSelected && styles.rowSelected,
@@ -454,8 +462,10 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
React.useEffect(() => {
if (!visible || !tagsExpanded || keywordDefs.length === 0 || !activeAccountId || !jmapClient.isConnected) return;
const accounts = tagAccountsKey.split('|').map((id) => id || undefined);
void ensureTagCounts(activeAccountId, keywordDefs.map((k) => k.id), accounts);
}, [visible, tagsExpanded, keywordDefs, tagAccountsKey, activeAccountId, tagCountsGeneration, ensureTagCounts]);
// Trash and Junk stay out of the counts as they do of the tag view (#1156).
const excluded = Object.fromEntries(accounts.map((id) => [id ?? '', trashAndJunkIds(mailboxes, id)]));
void ensureTagCounts(activeAccountId, keywordDefs.map((k) => k.id), accounts, excluded);
}, [visible, tagsExpanded, keywordDefs, tagAccountsKey, mailboxes, activeAccountId, tagCountsGeneration, ensureTagCounts]);
// ── Folder actions (long-press) ───────────────────────────────────────
const refFor = (mb: Mailbox) => ({
+2
View File
@@ -13,6 +13,7 @@ import { getEmailTagIds } from '../lib/thread-utils';
import type { Email } from '../api/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
import { NewTagRow } from './NewTagRow';
interface TagSheetProps {
visible: boolean;
@@ -132,6 +133,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
</Pressable>
);
})}
<NewTagRow keywords={keywords} onCreated={(token) => onToggle(token, true)} />
</ScrollView>
</Animated.View>
</Modal>
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// Selecting mail needed a long press; native #91 asks for a tap on the
// left side, as the webmail's avatar does.
describe('list row avatar (#91)', () => {
it('toggles the row into the selection on a tap', () => {
const src = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8');
const avatar = src.slice(src.indexOf('{density.showAvatar && ('), src.indexOf('{/* Content */}'));
expect(avatar).toMatch(/<Pressable\s+onPress=\{handleLongPress\}/);
expect(avatar).toMatch(/accessibilityRole="checkbox"/);
expect(avatar).toMatch(/accessibilityState=\{\{ checked: selected \}\}/);
expect(avatar).toMatch(/<SenderAvatar/);
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// "Plain Text Only" reads "Compose and read in plain text only", but only the
// composer honoured it: the reader always showed the HTML. The webmail reads
// HTML mail as plain text by default or per message (1e929b0a9). The
// conversion itself is tested in lib/__tests__/html-to-readable-text.test.ts;
// this checks the reader is wired to it.
const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', p), 'utf8');
describe('plain-text reading', () => {
it('renders the plain-text reading when the setting or the per-message switch is on', () => {
const body = src('components/EmailBodyView.tsx');
expect(body).toMatch(/useSettingsStore\(\(s\) => s\.plainTextMode\)/);
expect(body).toMatch(/const plainView = plainViewOverride \?\? plainTextMode;/);
expect(body).toMatch(/plainTextReading\(\{ html: formattedHtml, text: picked\.text \}\)/);
// The document is built from the text, not the HTML, while it is on.
expect(body).toMatch(/const rawHtml = plainText !== null \? null : formattedHtml;/);
expect(body).toMatch(/email_viewer\.shown_as_plain_text/);
});
it('offers "Show as plain text" per message in the More sheet and passes it to every card', () => {
const screen = src('screens/EmailThreadScreen.tsx');
expect(screen).toMatch(/email_viewer\.show_as_plain_text/);
expect(screen).toMatch(/plainView=\{plainOverrides\[m\.id\] \?\? null\}/);
expect(src('components/email/MessageContent.tsx')).toMatch(/plainView=\{plainView\}/);
});
});
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// List chips showed a typed, coloured icon per file; the reader's chips a
// grey paperclip for everything (webmail a78d03a18 uses the list's icons in
// the reader too).
const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '..', p), 'utf8');
describe('reader attachment chip icons', () => {
it('draws the same typed icon and colour as the list chips', () => {
const reader = src('email/AttachmentChips.tsx');
expect(reader).not.toMatch(/<Paperclip /);
expect(reader).toMatch(/const kind = attachmentKind\(item\.type, item\.name\);/);
expect(reader).toMatch(/<Icon size=\{14\} color=\{attachmentIconColor\(kind, c\)\} \/>/);
const list = src('email/ListAttachmentChips.tsx');
expect(list).toMatch(/ATTACHMENT_ICONS\[kind\]/);
expect(list).toMatch(/attachmentIconColor\(kind, c\)/);
});
});
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
vi.mock('../../api/jmap-client', () => ({
jmapClient: { accountId: 'acc-1', request: vi.fn(), getMaxObjectsInGet: vi.fn(() => 50) },
}));
import { avatarContent } from '../../lib/avatar-utils';
import { LIST_AUTH_RESULTS_PROPERTY, senderWarningOf } from '../../lib/email-headers';
import { EMAIL_FULL_PROPERTIES, EMAIL_LIST_PROPERTIES } from '../../api/email';
import type { Email } from '../../api/types';
// A forged From kept the real sender's favicon or contact photo: the reader
// worked out that the sender check failed, but the avatar beside it still
// showed the claimed sender's logo, and list rows had no auth data at all
// (webmail 605a3983b, #1439). Now the avatar shows a warning shield.
const FORGED = 'mx.example.org; spf=fail smtp.mailfrom=evil.example; dkim=none; dmarc=fail header.from=bank.example';
const GOOD = 'mx.example.org; spf=pass smtp.mailfrom=bank.example; dkim=pass header.d=bank.example; dmarc=pass header.from=bank.example';
function row(auth: string[] | undefined): Email {
return {
id: 'm1', threadId: 't1', mailboxIds: {}, keywords: {}, size: 1, receivedAt: '2026-10-31T10:00:00Z',
hasAttachment: false, from: [{ name: 'Bank', email: 'ceo@bank.example' }],
...(auth ? { [LIST_AUTH_RESULTS_PROPERTY]: auth } : {}),
} as Email;
}
describe('sender warning in avatars (bulwarkmail/webmail#1439)', () => {
it('asks for the Authentication-Results headers in the list fetch, not in the full one', () => {
expect(EMAIL_LIST_PROPERTIES).toContain('header:Authentication-Results:all');
// The full fetch has `headers`, which carry the same header.
expect(EMAIL_FULL_PROPERTIES).not.toContain('header:Authentication-Results:all');
expect(EMAIL_FULL_PROPERTIES).toContain('headers');
});
it('judges a list row from the listed header and a full message from its headers', () => {
expect(senderWarningOf(row([FORGED]))?.status).toBe('failed');
expect(senderWarningOf(row([GOOD]))).toBeNull();
expect(senderWarningOf(row(undefined))).toBeNull();
expect(senderWarningOf(row([]))).toBeNull();
const full = { ...row(undefined), headers: [{ name: 'Authentication-Results', value: ` ${FORGED}` }] };
expect(senderWarningOf(full)?.status).toBe('failed');
});
it('shows the shield before a contact photo or favicon', () => {
expect(avatarContent({ senderWarning: 'Sender check failed', hasPhoto: true, showFavicon: true })).toBe('warning');
expect(avatarContent({ senderWarning: undefined, hasPhoto: true, showFavicon: true })).toBe('photo');
expect(avatarContent({ senderWarning: null, hasPhoto: false, showFavicon: true })).toBe('favicon');
expect(avatarContent({ hasPhoto: false, showFavicon: false })).toBe('initials');
});
it('passes the warning to the avatar in the reader, collapsed conversation cards and list rows', () => {
const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', p), 'utf8');
expect(src('components/email/MessageHeader.tsx')).toMatch(/senderWarning=\{verificationText\?\.label\}/);
expect(src('components/email/ThreadMessageCard.tsx')).toMatch(/senderWarning=\{senderWarning\}/);
expect(src('screens/EmailListScreen.tsx')).toMatch(/senderWarning=\{senderWarning\}/);
expect(src('screens/UnifiedInboxScreen.tsx')).toMatch(/senderWarning=\{verification \?/);
});
});
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// Screen readers couldn't tell which folder was open, and the unread count
// was a button read as "Show unread" with no number (webmail 7c282f1f4).
describe('folder list names', () => {
const src = fs.readFileSync(path.resolve(__dirname, '../SidebarDrawer.tsx'), 'utf8');
it('marks the open folder as selected and says its unread count with its name', () => {
expect(src).toMatch(/accessibilityState=\{isAccountHeader \? undefined : \{ selected: isSelected \}\}/);
expect(src).toMatch(/t\('shell\.name_unread', '\{name\}, \{count\} unread', \{ name: label, count: unread \}\)/);
expect(src).toMatch(/accessibilityLabel=\{accessibleName\}/);
});
it('names the unread filter button with its count', () => {
expect(src).not.toMatch(/sidebar\.show_unread/);
expect(src).toMatch(/t\('sidebar\.unread_filter', 'Show unread only \(\{count\}\)', \{ count: unread \}\)/);
});
});
+7 -2
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive, FolderOpen } from '../icons';
import { Download, Eye, ExternalLink, Save, Share2, FolderArchive, FolderOpen } from '../icons';
import type { Attachment, Email } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
@@ -13,6 +13,8 @@ import {
getAttachmentDisplayName, visibleAttachments, formatSize,
} from '../../lib/attachment-display';
import { emailExportFilename } from '../../lib/download-filename';
import { attachmentKind } from '../../lib/list-attachments';
import { ATTACHMENT_ICONS, attachmentIconColor } from './attachment-icons';
import { ActionSheet, type ActionSheetItem } from './ActionSheet';
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
import type { ExtractedAttachment } from './use-body-override';
@@ -232,6 +234,9 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
<View style={styles.row}>
{visible.map((item) => {
const busy = busyKey === item.key;
// Typed and coloured like the list's chips (webmail a78d03a18).
const kind = attachmentKind(item.type, item.name);
const Icon = ATTACHMENT_ICONS[kind];
return (
<Pressable
key={item.key}
@@ -241,7 +246,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
disabled={!!busyKey}
accessibilityLabel={item.name}
>
{busy ? <ActivityIndicator size="small" color={c.textMuted} /> : <Paperclip size={14} color={c.textMuted} />}
{busy ? <ActivityIndicator size="small" color={c.textMuted} /> : <Icon size={14} color={attachmentIconColor(kind, c)} />}
<Text style={styles.name} numberOfLines={1}>{item.name}</Text>
<Text style={styles.size}>{formatSize(item.size)}</Text>
<Download size={14} color={c.textMuted} />
+23 -28
View File
@@ -1,6 +1,5 @@
import React from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { File as FileIcon, FileArchive, FileImage, FileSpreadsheet, FileText } from '../icons';
import type { Attachment, Email, Mailbox } from '../../api/types';
import { getEmailAttachments } from '../../api/email';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
@@ -13,8 +12,9 @@ import { accountIdOfRow } from '../../stores/email-store';
import { accountScopedId } from '../../lib/thread-utils';
import {
attachmentKind, realAttachments, requestListAttachments, shortAttachmentName,
type AttachmentKind, type LoadListAttachments,
type LoadListAttachments,
} from '../../lib/list-attachments';
import { ATTACHMENT_ICONS, attachmentIconColor } from './attachment-icons';
import { getAttachmentDisplayName } from '../../lib/attachment-display';
import { attachmentSaveName, downloadAttachment } from '../../lib/email-export';
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
@@ -23,31 +23,11 @@ import { useFilePreview } from './use-file-preview';
/** How many chips a row shows before collapsing the rest into a count. */
const MAX_CHIPS = 2;
const ICONS: Record<AttachmentKind, typeof FileIcon> = {
image: FileImage,
pdf: FileText,
sheet: FileSpreadsheet,
archive: FileArchive,
document: FileText,
other: FileIcon,
};
function iconColor(kind: AttachmentKind, c: ThemePalette): string {
switch (kind) {
case 'image': return c.tags.purple.dot;
case 'pdf': return c.tags.red.dot;
case 'sheet': return c.tags.green.dot;
case 'archive': return c.tags.amber.dot;
case 'document': return c.tags.blue.dot;
default: return c.textMuted;
}
}
/**
* The row's attachment parts: the ones the email already carries (a message
* opened before), else loaded lazily when it has a paperclip.
*/
function useListAttachments(email: Email, load?: LoadListAttachments): Attachment[] | undefined {
export function useListAttachments(email: Email, load?: LoadListAttachments): Attachment[] | undefined {
const [loaded, setLoaded] = React.useState<{ key: string; attachments: Attachment[] } | null>(null);
const needsLoad = !!load && !!email.hasAttachment && !email.attachments;
// Rows of a list spanning accounts can share an id (#1082).
@@ -67,7 +47,10 @@ function useListAttachments(email: Email, load?: LoadListAttachments): Attachmen
interface ChipsProps {
email: Email;
load?: LoadListAttachments;
/** The row's parts (useListAttachments); undefined while they load. */
attachments: Attachment[] | undefined;
/** The parts are being loaded: hold the chips' line so the row doesn't grow. */
pending?: boolean;
onOpen: (email: Email, attachment: Attachment) => void;
/** Selection mode: taps belong to the row. */
disabled?: boolean;
@@ -79,13 +62,23 @@ interface ChipsProps {
* that file directly.
*/
export const ListAttachmentChips = React.memo(function ListAttachmentChips({
email, load, onOpen, disabled,
email, attachments, pending, onOpen, disabled,
}: ChipsProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const dyn = useTypography();
const attachments = useListAttachments(email, load);
const real = React.useMemo(() => realAttachments(attachments), [attachments]);
// The line the chips will take, held while they load (webmail 7b25d3a95):
// the row grew under the finger when they arrived.
if (pending && attachments === undefined) {
return (
<View style={styles.row} accessibilityElementsHidden importantForAccessibility="no-hide-descendants" testID="list-chips-placeholder">
<View style={[styles.chip, styles.placeholderChip]}>
<Text style={[styles.name, dyn.caption]}> </Text>
</View>
</View>
);
}
if (real.length === 0) return null;
const shown = real.slice(0, MAX_CHIPS);
@@ -94,7 +87,7 @@ export const ListAttachmentChips = React.memo(function ListAttachmentChips({
<View style={styles.row}>
{shown.map((a, i) => {
const kind = attachmentKind(a.type, a.name);
const Icon = ICONS[kind];
const Icon = ATTACHMENT_ICONS[kind];
return (
<Pressable
key={`${a.blobId}-${i}`}
@@ -106,7 +99,7 @@ export const ListAttachmentChips = React.memo(function ListAttachmentChips({
accessibilityRole="button"
accessibilityLabel={a.name}
>
<Icon size={12} color={iconColor(kind, c)} />
<Icon size={12} color={attachmentIconColor(kind, c)} />
<Text style={[styles.name, dyn.caption]} numberOfLines={1}>{shortAttachmentName(a.name ?? '')}</Text>
</Pressable>
);
@@ -234,6 +227,8 @@ function makeStyles(c: ThemePalette) {
backgroundColor: c.surface,
},
chipPressed: { backgroundColor: c.surfaceHover },
// Empty space of a chip's height; a failed load leaves no grey stub behind.
placeholderChip: { width: 96, opacity: 0 },
name: { ...typography.caption, color: c.textSecondary, flexShrink: 1 },
more: {
paddingHorizontal: 6,
+8 -2
View File
@@ -22,6 +22,10 @@ export interface MessageContentProps {
/** On screen rather than pre-rendered by the pager (gates auto-sent read receipts). */
active: boolean;
themeOverride?: 'light' | 'dark' | null;
/** Per-message "Show as plain text"; null follows the "Plain Text Only" setting. */
plainView?: boolean | null;
/** Turn the per-message plain-text view off ("Show formatted"). */
onShowFormatted?: (email: Email) => void;
onSwipe?: (direction: 'prev' | 'next') => void;
onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void;
onToggleStar?: (email: Email) => void;
@@ -52,8 +56,8 @@ export interface MessageContentProps {
* cards.
*/
export function MessageContent({
email, jmapAccountId, identities, currentMailboxRole, active, themeOverride, onSwipe, onZoomChange,
onToggleStar, onAddressPress, onEmailPatched, compact, deferBody, onBodySettled, fill,
email, jmapAccountId, identities, currentMailboxRole, active, themeOverride, plainView, onShowFormatted,
onSwipe, onZoomChange, onToggleStar, onAddressPress, onEmailPatched, compact, deferBody, onBodySettled, fill,
}: MessageContentProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -124,6 +128,8 @@ export function MessageContent({
onSwipe={onSwipe}
onZoomChange={onZoomChange}
themeOverride={themeOverride}
plainView={plainView}
onShowFormatted={onShowFormatted ? () => onShowFormatted(email) : undefined}
bodyOverride={unwrap.override}
onSettled={onBodySettled}
fill={fill}
+9 -4
View File
@@ -12,7 +12,7 @@ import { useLocaleStore } from '../../stores/locale-store';
import { useAccountKeywordDefs } from '../../stores/keywords-store';
import { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date';
import {
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity,
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity, viaIdentityNameShown,
getSenderVerification, describeSenderVerification,
type AuthenticationResults, type EmailHeaderInfo,
} from '../../lib/email-headers';
@@ -138,7 +138,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
if (spoofed || identities.length === 0) return null;
const fromEmail = from?.email?.toLowerCase();
const sentAs = fromEmail ? identities.find((i) => i.email?.toLowerCase() === fromEmail) : undefined;
if (sentAs) return { identity: sentAs, direction: 'from' as const };
if (sentAs) return viaIdentityNameShown(sentAs, from) ? { identity: sentAs, direction: 'from' as const } : null;
const received = findReceivingIdentity(identities, email);
if (received && identities.length > 1 && received.id !== identities[0].id) {
return { identity: received, direction: 'to' as const };
@@ -172,7 +172,12 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
accessibilityRole="button"
accessibilityLabel={from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')}
>
<SenderAvatar name={from?.name} email={from?.email} size={compact ? componentSizes.avatarSm : componentSizes.avatarMd} />
<SenderAvatar
name={from?.name}
email={from?.email}
size={compact ? componentSizes.avatarSm : componentSizes.avatarMd}
senderWarning={verificationText?.label}
/>
</Pressable>
<View style={styles.info}>
<Pressable onPress={from ? () => onAddressPress(from) : undefined}>
@@ -253,7 +258,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
<View style={[styles.badge, { backgroundColor: c.primaryBg }]}>
<Text style={[styles.badgeText, { color: c.primary }]}>
{viaIdentity.direction === 'from'
? t('identities.badge.identity_short', 'via {name}', { name: viaIdentity.identity.email })
? t('identities.badge.identity_short', 'via {name}', { name: viaIdentity.identity.name })
: t('email_viewer.received_via', 'to {name}', { name: viaIdentity.identity.email })}
</Text>
</View>
+5 -2
View File
@@ -10,6 +10,8 @@ import SenderAvatar from '../SenderAvatar';
import { MessageContent, type MessageContentProps } from './MessageContent';
import { emailDisplayDate, formatHeaderDate, formatHeaderTime } from '../../lib/email-date';
import { singleLine } from '../../lib/single-line';
import { cleanPreview } from '../../lib/preview-text';
import { useSenderWarning } from './use-sender-warning';
interface Props extends MessageContentProps {
expanded: boolean;
@@ -43,11 +45,12 @@ export function ThreadMessageCard({
const unread = !email.keywords?.$seen;
const starred = !!email.keywords?.$flagged;
const date = emailDisplayDate(email);
const senderWarning = useSenderWarning(expanded ? undefined : email);
if (!expanded) {
return (
<Pressable onPress={onToggleExpanded} style={({ pressed }) => [styles.collapsed, pressed && styles.pressed]}>
<SenderAvatar name={from?.name} email={from?.email} size={componentSizes.avatarSm} />
<SenderAvatar name={from?.name} email={from?.email} size={componentSizes.avatarSm} senderWarning={senderWarning} />
<View style={styles.collapsedInfo}>
<View style={styles.collapsedTop}>
<Text style={[styles.collapsedName, unread && styles.unread]} numberOfLines={1}>
@@ -56,7 +59,7 @@ export function ThreadMessageCard({
{email.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
<Text style={styles.collapsedDate}>{formatHeaderDate(date, locale)} {formatHeaderTime(date, timeFormat, locale)}</Text>
</View>
<Text style={styles.collapsedPreview} numberOfLines={1}>{singleLine(email.preview)}</Text>
<Text style={styles.collapsedPreview} numberOfLines={1}>{singleLine(cleanPreview(email.preview))}</Text>
</View>
{starred && <Star size={14} color={c.starred} fill={c.starred} />}
</Pressable>
+28
View File
@@ -0,0 +1,28 @@
import { File as FileIcon, FileArchive, FileImage, FileSpreadsheet, FileText } from '../icons';
import type { ThemePalette } from '../../theme/tokens';
import type { AttachmentKind } from '../../lib/list-attachments';
/**
* The icon of an attachment chip by file type, the same in the list and the
* reader (webmail components/email/attachment-chips.tsx, a78d03a18).
*/
export const ATTACHMENT_ICONS: Record<AttachmentKind, typeof FileIcon> = {
image: FileImage,
pdf: FileText,
sheet: FileSpreadsheet,
archive: FileArchive,
document: FileText,
other: FileIcon,
};
/** The icon's colour by file type. */
export function attachmentIconColor(kind: AttachmentKind, c: ThemePalette): string {
switch (kind) {
case 'image': return c.tags.purple.dot;
case 'pdf': return c.tags.red.dot;
case 'sheet': return c.tags.green.dot;
case 'archive': return c.tags.amber.dot;
case 'document': return c.tags.blue.dot;
default: return c.textMuted;
}
}
@@ -0,0 +1,18 @@
import React from 'react';
import type { Email } from '../../api/types';
import { describeSenderVerification, senderWarningOf } from '../../lib/email-headers';
import { useLocaleStore } from '../../stores/locale-store';
/**
* The sender-check label of a message whose From the receiving server's
* checks don't back, the same verdict as the notice in the reader. Avatars
* show it instead of the claimed sender's favicon or photo (webmail
* use-sender-warning.ts, #1439). Undefined when the check passed, there are
* no results to judge by, or no message is given (Sent and Drafts rows show
* the recipient, whose avatar the check says nothing about).
*/
export function useSenderWarning(email: Pick<Email, 'headers' | 'from'> | undefined): string | undefined {
const t = useLocaleStore((s) => s.t);
const verification = React.useMemo(() => (email ? senderWarningOf(email) : null), [email]);
return verification ? describeSenderVerification(verification, t).label : undefined;
}