From 7e42cb85ea7de6973d895ce14f0c21f5df3decdd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:38:07 +0200 Subject: [PATCH] feat: add EmailSourceScreen for viewing and sharing email source, update navigation and types --- App.tsx | 2 + package.json | 3 +- scripts/bump-version.mjs | 57 +++++++++++++++++++ src/lib/email-export.ts | 41 +++++++++++++ src/navigation/types.ts | 1 + src/screens/EmailSourceScreen.tsx | 95 +++++++++++++++++++++++++++++++ src/screens/EmailThreadScreen.tsx | 51 +++++++++++++++-- 7 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 scripts/bump-version.mjs create mode 100644 src/lib/email-export.ts create mode 100644 src/screens/EmailSourceScreen.tsx diff --git a/App.tsx b/App.tsx index 855921f1..7c64b2cd 100644 --- a/App.tsx +++ b/App.tsx @@ -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() { + + 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}`); diff --git a/src/lib/email-export.ts b/src/lib/email-export.ts new file mode 100644 index 00000000..87364b33 --- /dev/null +++ b/src/lib/email-export.ts @@ -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 { + 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 { + 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', + }); +} diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 59aabceb..8a99fd3e 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -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'; diff --git a/src/screens/EmailSourceScreen.tsx b/src/screens/EmailSourceScreen.tsx new file mode 100644 index 00000000..b836f088 --- /dev/null +++ b/src/screens/EmailSourceScreen.tsx @@ -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; + +export default function EmailSourceScreen({ route, navigation }: Props) { + const { blobId, subject } = route.params; + const [raw, setRaw] = React.useState(null); + const [error, setError] = React.useState(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 ( + + + navigation.goBack()} style={styles.headerBtn} hitSlop={8}> + + + + {subject || 'Email source'} + + + + + + + {!raw && !error ? ( + + + + ) : error ? ( + + {error} + + ) : ( + + {raw} + + )} + + ); +} + +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, + }, +}); diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index f8845aa9..1ebfb85b 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -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)); + } + }} /> 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 && ( + } + label="View source" + onPress={onViewSource} + /> + )} + {canExport && ( + } + label="Export email (.eml)" + onPress={onExport} + /> + )} );