feat: add EmailSourceScreen for viewing and sharing email source, update navigation and types

This commit is contained in:
Linus Rath
2026-04-26 21:38:07 +02:00
parent 02c590beb3
commit 7e42cb85ea
7 changed files with 244 additions and 6 deletions
+2
View File
@@ -19,6 +19,7 @@ import {
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
import ComposeScreen from './src/screens/ComposeScreen';
import EmailThreadScreen from './src/screens/EmailThreadScreen';
import EmailSourceScreen from './src/screens/EmailSourceScreen';
import LoginScreen from './src/screens/LoginScreen';
import EmailListScreen from './src/screens/EmailListScreen';
import FilesScreen from './src/screens/FilesScreen';
@@ -325,6 +326,7 @@ export default function App() {
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="MainTabs" component={MainTabsNavigator} />
<Stack.Screen name="EmailThread" component={EmailThreadScreen} />
<Stack.Screen name="EmailSource" component={EmailSourceScreen} />
<Stack.Screen
name="Compose"
component={ComposeScreen}
+2 -1
View File
@@ -9,7 +9,8 @@
"ios": "expo run:ios",
"web": "expo start --web",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"bump": "node scripts/bump-version.mjs"
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const versionFile = path.join(root, 'VERSION');
const pkgFile = path.join(root, 'package.json');
const lockFile = path.join(root, 'package-lock.json');
const git = (...args) =>
execFileSync('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'inherit'] }).toString().trim();
const gitInherit = (...args) =>
execFileSync('git', args, { cwd: root, stdio: 'inherit' });
const dirty = git('status', '--porcelain');
if (dirty) {
console.error('Working tree is not clean. Commit or stash changes before bumping.');
console.error(dirty);
process.exit(1);
}
const current = fs.readFileSync(versionFile, 'utf8').trim();
const parts = current.split('.').map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) {
console.error(`Invalid version in VERSION: "${current}" (expected MAJOR.MINOR.PATCH)`);
process.exit(1);
}
parts[2] += 1;
const next = parts.join('.');
const existingTags = new Set(git('tag', '--list').split(/\r?\n/).filter(Boolean));
if (existingTags.has(next)) {
console.error(`Tag ${next} already exists.`);
process.exit(1);
}
fs.writeFileSync(versionFile, `${next}\n`);
const pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf8'));
pkg.version = next;
fs.writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`);
const lock = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
lock.version = next;
if (lock.packages?.['']) lock.packages[''].version = next;
fs.writeFileSync(lockFile, `${JSON.stringify(lock, null, 2)}\n`);
console.log(`${current} -> ${next}`);
gitInherit('add', 'VERSION', 'package.json', 'package-lock.json');
gitInherit('commit', '-m', `chore: update version number to ${next}`);
gitInherit('tag', '-a', next, '-m', next);
gitInherit('push', '--follow-tags');
console.log(`Released ${next}`);
+41
View File
@@ -0,0 +1,41 @@
import { File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import { jmapClient } from '../api/jmap-client';
import { getDownloadUrl } from '../api/blob';
const RFC822 = 'message/rfc822';
function authHeader(): string {
const h = (jmapClient as { authHeader?: string }).authHeader;
if (!h) throw new Error('Not connected');
return h;
}
export async function fetchRawEmail(blobId: string): Promise<string> {
const url = getDownloadUrl(blobId, 'email.eml', RFC822);
const r = await fetch(url, { headers: { Authorization: authHeader() } });
if (!r.ok) throw new Error(`Download failed: ${r.status}`);
return r.text();
}
function safeFilename(subject: string | undefined): string {
const base = (subject ?? 'email').replace(/[^\w\s-]/g, '').trim().slice(0, 60) || 'email';
return `${base}.eml`;
}
export async function shareEmailEml(blobId: string, subject?: string): Promise<void> {
const dest = new File(Paths.cache, safeFilename(subject));
if (dest.exists) dest.delete();
const url = getDownloadUrl(blobId, dest.name, RFC822);
const downloaded = await File.downloadFileAsync(url, dest, {
headers: { Authorization: authHeader() },
});
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',
});
}
+1
View File
@@ -3,6 +3,7 @@ import type { EmailAddress } from '../api/types';
export type RootStackParamList = {
MainTabs: undefined;
EmailThread: { emailId: string; threadId: string; subject?: string };
EmailSource: { emailId: string; blobId: string; subject?: string };
Compose:
| {
mode?: 'reply' | 'replyAll' | 'forward';
+95
View File
@@ -0,0 +1,95 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, ActivityIndicator, Pressable, Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ArrowLeft, Share2 } from 'lucide-react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../navigation/types';
import { fetchRawEmail, shareEmailEml } from '../lib/email-export';
import { colors, spacing, typography, componentSizes, radius } from '../theme/tokens';
type Props = NativeStackScreenProps<RootStackParamList, 'EmailSource'>;
export default function EmailSourceScreen({ route, navigation }: Props) {
const { blobId, subject } = route.params;
const [raw, setRaw] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let cancelled = false;
void (async () => {
try {
const text = await fetchRawEmail(blobId);
if (!cancelled) setRaw(text);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load email source');
}
})();
return () => { cancelled = true; };
}, [blobId]);
const onShare = async () => {
try {
await shareEmailEml(blobId, subject);
} catch (e) {
Alert.alert('Share failed', e instanceof Error ? e.message : String(e));
}
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
<ArrowLeft size={22} color={colors.text} />
</Pressable>
<Text style={styles.headerTitle} numberOfLines={1}>
{subject || 'Email source'}
</Text>
<Pressable onPress={onShare} style={styles.headerBtn} hitSlop={8} disabled={!raw}>
<Share2 size={20} color={raw ? colors.text : colors.textMuted} />
</Pressable>
</View>
{!raw && !error ? (
<View style={styles.loading}>
<ActivityIndicator color={colors.primary} />
</View>
) : error ? (
<View style={styles.loading}>
<Text style={styles.error}>{error}</Text>
</View>
) : (
<ScrollView contentContainerStyle={styles.body}>
<Text selectable style={styles.source}>{raw}</Text>
</ScrollView>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.background },
header: {
flexDirection: 'row',
alignItems: 'center',
height: componentSizes.headerHeight,
paddingHorizontal: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: colors.border,
gap: spacing.sm,
},
headerBtn: {
width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
},
headerTitle: { ...typography.h3, color: colors.text, flex: 1 },
loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
error: { ...typography.body, color: colors.error, paddingHorizontal: spacing.lg, textAlign: 'center' },
body: { padding: spacing.lg },
source: {
fontFamily: 'monospace',
fontSize: 11,
lineHeight: 16,
color: colors.text,
},
});
+46 -5
View File
@@ -1,7 +1,7 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, ActivityIndicator,
Modal, useWindowDimensions, Animated, Easing,
Modal, useWindowDimensions, Animated, Easing, Alert,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
@@ -9,7 +9,7 @@ import {
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
ChevronLeft, ChevronRight, Paperclip, Archive, Mail, MailOpen,
FolderInput, ShieldAlert, ShieldCheck, X, Check,
Inbox, Send, File as FileIcon, Ban, Folder,
Inbox, Send, File as FileIcon, Ban, Folder, Code, Download,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens';
@@ -17,6 +17,7 @@ import EmailBodyView from '../components/EmailBodyView';
import SenderAvatar from '../components/SenderAvatar';
import { useEmailStore } from '../stores/email-store';
import { setEmailKeywords } from '../api/email';
import { shareEmailEml } from '../lib/email-export';
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
import type { Email, Mailbox } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
@@ -386,15 +387,36 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
visible={moreMenuOpen}
onClose={() => setMoreMenuOpen(false)}
unread={unread}
canArchive={!!archiveMailbox && !showArchive && currentMailboxId !== archiveMailbox?.id}
canMarkUnread={!showMarkUnread}
canArchive={!!archiveMailbox && currentMailboxId !== archiveMailbox?.id}
canMarkUnread={true}
canMove={mailboxes.length > 0}
showSpam={!!junkMailbox || isInJunk}
isInJunk={isInJunk}
canViewSource={!!email?.blobId}
canExport={!!email?.blobId}
onArchive={() => { setMoreMenuOpen(false); onArchive(); }}
onToggleUnread={() => { setMoreMenuOpen(false); onToggleUnread(); }}
onMove={() => { setMoreMenuOpen(false); setMoveMenuOpen(true); }}
onToggleSpam={onToggleSpam}
onViewSource={() => {
setMoreMenuOpen(false);
if (email?.blobId) {
navigation.navigate('EmailSource', {
emailId: email.id,
blobId: email.blobId,
subject: email.subject,
});
}
}}
onExport={async () => {
setMoreMenuOpen(false);
if (!email?.blobId) return;
try {
await shareEmailEml(email.blobId, email.subject);
} catch (e) {
Alert.alert('Export failed', e instanceof Error ? e.message : String(e));
}
}}
/>
<MoveMenuSheet
@@ -417,15 +439,20 @@ interface MoreMenuSheetProps {
canMove: boolean;
showSpam: boolean;
isInJunk: boolean;
canViewSource: boolean;
canExport: boolean;
onArchive: () => void;
onToggleUnread: () => void;
onMove: () => void;
onToggleSpam: () => void;
onViewSource: () => void;
onExport: () => void;
}
function MoreMenuSheet({
visible, onClose, unread, canArchive, canMarkUnread, canMove,
showSpam, isInJunk, onArchive, onToggleUnread, onMove, onToggleSpam,
showSpam, isInJunk, canViewSource, canExport,
onArchive, onToggleUnread, onMove, onToggleSpam, onViewSource, onExport,
}: MoreMenuSheetProps) {
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(400)).current;
@@ -504,6 +531,20 @@ function MoreMenuSheet({
onPress={onToggleSpam}
/>
)}
{canViewSource && (
<MoreMenuItem
icon={<Code size={18} color={colors.textSecondary} />}
label="View source"
onPress={onViewSource}
/>
)}
{canExport && (
<MoreMenuItem
icon={<Download size={18} color={colors.textSecondary} />}
label="Export email (.eml)"
onPress={onExport}
/>
)}
</Animated.View>
</Modal>
);