diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 22143eab..c70269d5 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -151,6 +151,7 @@ export class JMAPClient { private async persistRefreshedTokens(next: OAuthTokens): Promise { if (!this.credentials) return; + if (this.credentials.accessToken === next.accessToken) return; this.credentials = { ...this.credentials, accessToken: next.accessToken, diff --git a/src/api/unified-inbox.ts b/src/api/unified-inbox.ts index 75b441fd..1e5619f9 100644 --- a/src/api/unified-inbox.ts +++ b/src/api/unified-inbox.ts @@ -101,7 +101,10 @@ async function ensureFreshCredentials( tokenEndpoint: next.tokenEndpoint, clientId: next.clientId, }; - await jmapClient.setStoredCredentials(accountId, updated); + const current = await jmapClient.getStoredCredentials(accountId); + if (!current || current.accessToken !== updated.accessToken) { + await jmapClient.setStoredCredentials(accountId, updated); + } return updated; } catch { // Fall back to the existing (possibly expired) token; the request will diff --git a/src/components/IdentitySheet.tsx b/src/components/IdentitySheet.tsx new file mode 100644 index 00000000..081e53de --- /dev/null +++ b/src/components/IdentitySheet.tsx @@ -0,0 +1,205 @@ +import React from 'react'; +import { + View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { X, Check, Mail } from 'lucide-react-native'; +import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; +import { useColors } from '../theme/colors'; +import { useSheetDrag } from '../lib/use-sheet-drag'; +import type { Identity } from '../api/types'; +import { useLocaleStore } from '../stores/locale-store'; + +interface IdentitySheetProps { + visible: boolean; + onClose: () => void; + identities: Identity[]; + selectedIdentityId: string | null; + onPick: (identity: Identity) => void; +} + +export function IdentitySheet({ + visible, onClose, identities, selectedIdentityId, onPick, +}: IdentitySheetProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const insets = useSafeAreaInsets(); + const slideY = React.useRef(new Animated.Value(500)).current; + const overlayOpacity = React.useRef(new Animated.Value(0)).current; + const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose }); + const { t } = useLocaleStore(); + + React.useEffect(() => { + if (visible) { + Animated.parallel([ + Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }), + ]).start(); + } else { + Animated.parallel([ + Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }), + ]).start(); + } + }, [visible, slideY, overlayOpacity]); + + return ( + + + + + + + + + + + {t('email_composer.from', 'From')} + + + + + + + {identities.map((identity) => { + const isSelected = identity.id === selectedIdentityId; + return ( + { + onPick(identity); + onClose(); + }} + style={({ pressed }) => [ + styles.identityRow, + pressed && styles.identityRowPressed, + ]} + > + + + + + {!!identity.name && ( + + {identity.name} + + )} + + {identity.email} + + + {isSelected && ( + + + + )} + + ); + })} + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + sheetOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.5)', + }, + sheetOverlayPress: { flex: 1 }, + sheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + maxHeight: '60%', + backgroundColor: c.popover, + borderTopLeftRadius: radius.lg, + borderTopRightRadius: radius.lg, + borderTopWidth: 1, + borderColor: c.border, + paddingTop: spacing.sm, + }, + sheetHandleHit: { + alignItems: 'center', + paddingTop: spacing.xs, + paddingBottom: spacing.sm, + }, + sheetHandle: { + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: c.border, + }, + sheetHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingBottom: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: c.border, + }, + sheetTitle: { + ...typography.bodySemibold, + color: c.text, + }, + sheetClose: { + width: 28, + height: 28, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radius.xs, + }, + scrollList: { + paddingVertical: spacing.xs, + }, + identityRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + minHeight: 52, + }, + identityRowPressed: { + backgroundColor: c.surfaceHover, + }, + iconWrap: { + marginRight: spacing.md, + width: 28, + height: 28, + borderRadius: radius.xs, + backgroundColor: c.surface, + alignItems: 'center', + justifyContent: 'center', + }, + identityDetails: { + flex: 1, + justifyContent: 'center', + }, + identityName: { + ...typography.bodyMedium, + color: c.text, + }, + identityEmail: { + ...typography.caption, + color: c.textSecondary, + marginTop: 2, + }, + identityEmailOnly: { + ...typography.bodyMedium, + color: c.text, + marginTop: 0, + }, + checkWrap: { + marginLeft: spacing.md, + }, + }); +} diff --git a/src/components/SwipeableRow.tsx b/src/components/SwipeableRow.tsx index f5e76c88..8360d7bc 100644 --- a/src/components/SwipeableRow.tsx +++ b/src/components/SwipeableRow.tsx @@ -172,9 +172,9 @@ export function SwipeableRow({ return; } // Instant mode: fire on release-past-threshold, no second tap. - if (g.dx >= COMMIT_THRESHOLD && rightAction !== 'none') { + if ((g.dx >= COMMIT_THRESHOLD || (g.dx > 40 && g.vx >= 0.5)) && rightAction !== 'none') { fly(widthRef.current || EXIT_DISTANCE, rightAction); - } else if (g.dx <= -COMMIT_THRESHOLD && leftAction !== 'none') { + } else if ((g.dx <= -COMMIT_THRESHOLD || (g.dx < -40 && g.vx <= -0.5)) && leftAction !== 'none') { fly(-(widthRef.current || EXIT_DISTANCE), leftAction); } else { Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start(); diff --git a/src/components/UndoSnackbar.tsx b/src/components/UndoSnackbar.tsx index 5a494e68..faf02b7a 100644 --- a/src/components/UndoSnackbar.tsx +++ b/src/components/UndoSnackbar.tsx @@ -83,6 +83,7 @@ function makeStyles(c: ThemePalette) { position: 'absolute', left: spacing.md, right: spacing.md, + zIndex: 50, }, bar: { flexDirection: 'row', diff --git a/src/components/index.ts b/src/components/index.ts index d55f3b40..d033bcb7 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,3 +5,5 @@ export { default as Badge } from './Badge'; export { Card, SectionHeader, SettingItemRow } from './Card'; export { default as RadioGroup } from './RadioGroup'; export { default as Dialog } from './Dialog'; +export { IdentitySheet } from './IdentitySheet'; + diff --git a/src/lib/__tests__/oauth.test.ts b/src/lib/__tests__/oauth.test.ts new file mode 100644 index 00000000..d31dd751 --- /dev/null +++ b/src/lib/__tests__/oauth.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockSecureFetch } = vi.hoisted(() => ({ + mockSecureFetch: vi.fn(), +})); + +vi.mock('../client-cert', () => ({ + secureFetch: mockSecureFetch, +})); + +import { refreshOAuthAccessToken, type OAuthTokens } from '../oauth'; + +describe('oauth token refresh deduplication', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should only send a single HTTP request when refreshOAuthAccessToken is called concurrently with the same refresh token', async () => { + const tokens: OAuthTokens = { + accessToken: 'old-access', + refreshToken: 'same-refresh-token', + expiresAt: 123456, + tokenEndpoint: 'https://auth.example.com/token', + clientId: 'client-id-123', + }; + + // Set up a mock response that takes a brief moment to resolve + mockSecureFetch.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return { + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-access-token', + refresh_token: 'rotated-refresh-token', + expires_in: 3600, + }), + } as Response; + }); + + // Fire two refreshes concurrently + const [res1, res2] = await Promise.all([ + refreshOAuthAccessToken(tokens), + refreshOAuthAccessToken(tokens), + ]); + + // Check that secureFetch was called exactly once + expect(mockSecureFetch).toHaveBeenCalledTimes(1); + + // Verify both calls received the exact same updated token bundle + expect(res1.accessToken).toBe('new-access-token'); + expect(res1.refreshToken).toBe('rotated-refresh-token'); + expect(res2.accessToken).toBe('new-access-token'); + expect(res2.refreshToken).toBe('rotated-refresh-token'); + + // Fire another refresh after the first completes (now the map should be empty for this token) + mockSecureFetch.mockClear(); + const res3 = await refreshOAuthAccessToken(tokens); + expect(mockSecureFetch).toHaveBeenCalledTimes(1); + expect(res3.accessToken).toBe('new-access-token'); + }); + + it('should propagate errors to all waiters if the HTTP request fails', async () => { + const tokens: OAuthTokens = { + accessToken: 'old-access', + refreshToken: 'failing-refresh-token', + expiresAt: 123456, + tokenEndpoint: 'https://auth.example.com/token', + clientId: 'client-id-123', + }; + + mockSecureFetch.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return { + ok: false, + status: 400, + } as Response; + }); + + // Fire two refreshes concurrently and check that both reject with the same error + const promise1 = refreshOAuthAccessToken(tokens); + const promise2 = refreshOAuthAccessToken(tokens); + + await expect(promise1).rejects.toThrow('Token refresh failed: 400'); + await expect(promise2).rejects.toThrow('Token refresh failed: 400'); + + expect(mockSecureFetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/mailbox-tree.ts b/src/lib/mailbox-tree.ts index d36dcdd7..1d307d11 100644 --- a/src/lib/mailbox-tree.ts +++ b/src/lib/mailbox-tree.ts @@ -96,3 +96,36 @@ export function flattenVisible( walk(nodes); return out; } + +export function findTrashMailbox(mailboxes: Mailbox[]): Mailbox | undefined { + const roleMatch = mailboxes.find((m) => m.role === 'trash'); + if (roleMatch) return roleMatch; + + const names = ['trash', 'bin', 'deleted', 'deleted items', 'corbeille', 'messages supprimés', 'éléments supprimés', 'supprimé', 'supprimés']; + return mailboxes.find((m) => { + const lower = m.name.toLowerCase(); + return names.includes(lower) || names.some((n) => lower.includes(n)); + }); +} + +export function findArchiveMailbox(mailboxes: Mailbox[]): Mailbox | undefined { + const roleMatch = mailboxes.find((m) => m.role === 'archive'); + if (roleMatch) return roleMatch; + + const names = ['archive', 'archives', 'archived']; + return mailboxes.find((m) => { + const lower = m.name.toLowerCase(); + return names.includes(lower) || names.some((n) => lower.includes(n)); + }); +} + +export function findJunkMailbox(mailboxes: Mailbox[]): Mailbox | undefined { + const roleMatch = mailboxes.find((m) => m.role === 'junk' || m.role === 'spam'); + if (roleMatch) return roleMatch; + + const names = ['junk', 'spam', 'indésirables', 'indésirable', 'courrier indésirable']; + return mailboxes.find((m) => { + const lower = m.name.toLowerCase(); + return names.includes(lower) || names.some((n) => lower.includes(n)); + }); +} diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 015027be..fea94ed2 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -207,6 +207,8 @@ export async function redeemPairingCode(webmailUrl: string, code: string): Promi }; } +const activeRefreshes = new Map>(); + // OAuth refresh — exchanges the refresh token at the original token endpoint // for a new access token. Returns the updated bundle so the caller can // persist it. @@ -214,37 +216,52 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise { + try { + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: tokens.refreshToken!, + client_id: tokens.clientId, + }); + const response = await secureFetch(tokens.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: body.toString(), + }); + if (!response.ok) { + throw new HandoffError(`Token refresh failed: ${response.status}`); + } + const data = (await response.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + if (!data.access_token) { + throw new HandoffError('Token refresh response missing access_token'); + } + return { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? tokens.refreshToken!, + expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined, + tokenEndpoint: tokens.tokenEndpoint, + clientId: tokens.clientId, + }; + } finally { + activeRefreshes.delete(cacheKey); + } + })(); + activeRefreshes.set(cacheKey, promise); } - const data = (await response.json()) as { - access_token?: string; - refresh_token?: string; - expires_in?: number; - }; - if (!data.access_token) { - throw new HandoffError('Token refresh response missing access_token'); - } - return { - accessToken: data.access_token, - refreshToken: data.refresh_token ?? tokens.refreshToken, - expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined, - tokenEndpoint: tokens.tokenEndpoint, - clientId: tokens.clientId, - }; + + return promise; } WebBrowser.maybeCompleteAuthSession(); diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 1dfda9ac..5a80fab9 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -17,7 +17,7 @@ import * as FileSystem from 'expo-file-system/legacy'; import { File as FsFile } from 'expo-file-system'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; -import { Button } from '../components'; +import { Button, IdentitySheet } from '../components'; import RichTextEditor, { type RichTextEditorHandle, type RichTextSelectionState, @@ -26,6 +26,7 @@ import { useEmailStore } from '../stores/email-store'; import { useContactsStore } from '../stores/contacts-store'; import { useLocaleStore } from '../stores/locale-store'; import { useSettingsStore } from '../stores/settings-store'; +import { useAccountStore } from '../stores/account-store'; import { isGroup } from '../lib/contact-utils'; import { getContactDisplayName, @@ -69,7 +70,16 @@ function parseRecipients(input: string): Recipient[] { .split(/[,;\n]/) .map((s) => s.trim()) .filter(Boolean) - .map((email) => ({ name: '', email })); + .map((s) => { + const match = s.match(/^(.*?)\s*<([^\s@]+@[^\s@]+\.[^\s@]+)>$/); + if (match) { + return { + name: match[1].replace(/^["']|["']$/g, '').trim(), + email: match[2].trim(), + }; + } + return { name: '', email: s }; + }); } function formatBytes(n: number): string { @@ -113,10 +123,11 @@ function RecipientChip({ recipient, onRemove }: { recipient: Recipient; onRemove } function SuggestionList({ - suggestions, onPick, + suggestions, onPick, onPressIn, }: { suggestions: Array<{ contact: ContactCard; name: string; email: string }>; onPick: (s: { name: string; email: string }) => void; + onPressIn?: () => void; }) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -127,6 +138,7 @@ function SuggestionList({ return ( onPick(s)} style={({ pressed }) => [styles.suggestionRow, pressed && styles.suggestionRowPressed]} > @@ -260,6 +272,7 @@ export default function ComposeScreen({ route, navigation }: Props) { const [identityError, setIdentityError] = React.useState(null); const [sending, setSending] = React.useState(false); const [selectedIdentityId, setSelectedIdentityId] = React.useState(null); + const [identitySheetOpen, setIdentitySheetOpen] = React.useState(false); const [scheduleSheetOpen, setScheduleSheetOpen] = React.useState(false); // Custom date/time picker stage. iOS shows one 'datetime' spinner; Android // can only show one field at a time, so we walk date → time. @@ -343,6 +356,7 @@ export default function ComposeScreen({ route, navigation }: Props) { }); const editorRef = React.useRef(null); + const isPickingSuggestion = React.useRef(false); // Track inline-image placeholders that haven't yet been rewritten to cid: // until send time. Maps cid → blobId/type/name/size. const inlineRegistryRef = React.useRef>(new Map()); @@ -375,6 +389,7 @@ export default function ComposeScreen({ route, navigation }: Props) { }, [suggestionQuery, individuals, alreadySelected]); const pickSuggestion = (s: { name: string; email: string }) => { + isPickingSuggestion.current = true; const recipient: Recipient = { name: s.name, email: s.email }; if (activeField === 'cc') { setCcRecipients((prev) => [...prev, recipient]); @@ -405,34 +420,35 @@ export default function ComposeScreen({ route, navigation }: Props) { // original message (matches webmail's reply-identity behaviour). React.useEffect(() => { if (selectedIdentityId || identities.length === 0) return; + const activeEmail = useAccountStore.getState().getActiveAccount()?.email || jmapClient.username; + const defaultIdentity = identities.find( + (i) => i.email.toLowerCase() === activeEmail?.toLowerCase() + ) ?? identities[0]; + if (autoSelectReplyIdentity && replyTo) { const candidates = [...(replyTo.to ?? []), ...(replyTo.cc ?? [])]; const candidateAddrs = new Set( candidates.map((r) => r.email?.toLowerCase()).filter(Boolean) as string[], ); const matched = identities.find((i) => candidateAddrs.has(i.email.toLowerCase())); - setSelectedIdentityId((matched ?? identities[0]).id); + setSelectedIdentityId((matched ?? defaultIdentity).id); } else { - setSelectedIdentityId(identities[0].id); + setSelectedIdentityId(defaultIdentity.id); } }, [identities, autoSelectReplyIdentity, replyTo, selectedIdentityId]); - const primaryIdentity = - identities.find((i) => i.id === selectedIdentityId) ?? identities[0]; + const primaryIdentity = React.useMemo(() => { + if (identities.length === 0) return null; + const activeEmail = useAccountStore.getState().getActiveAccount()?.email || jmapClient.username; + const defaultIdentity = identities.find( + (i) => i.email.toLowerCase() === activeEmail?.toLowerCase() + ) ?? identities[0]; + return identities.find((i) => i.id === selectedIdentityId) ?? defaultIdentity; + }, [identities, selectedIdentityId]); const openIdentityPicker = () => { if (identities.length <= 1) return; - Alert.alert( - t('email_composer.from', 'From'), - undefined, - [ - ...identities.map((i) => ({ - text: i.name ? `${i.name} <${i.email}>` : i.email, - onPress: () => setSelectedIdentityId(i.id), - })), - { text: t('email_composer.cancel', 'Cancel'), style: 'cancel' as const }, - ], - ); + setIdentitySheetOpen(true); }; const commitTyped = () => { @@ -448,11 +464,33 @@ export default function ComposeScreen({ route, navigation }: Props) { const extraTo = parseRecipients(toInput); const extraCc = parseRecipients(ccInput); if (extraTo.length) { - setToRecipients((prev) => [...prev, ...extraTo]); + setToRecipients((prev) => { + const existing = new Set(prev.map((r) => r.email.toLowerCase())); + const unique: Recipient[] = []; + for (const r of extraTo) { + const emailLower = r.email.toLowerCase(); + if (!existing.has(emailLower)) { + existing.add(emailLower); + unique.push(r); + } + } + return [...prev, ...unique]; + }); setToInput(''); } if (extraCc.length) { - setCcRecipients((prev) => [...prev, ...extraCc]); + setCcRecipients((prev) => { + const existing = new Set(prev.map((r) => r.email.toLowerCase())); + const unique: Recipient[] = []; + for (const r of extraCc) { + const emailLower = r.email.toLowerCase(); + if (!existing.has(emailLower)) { + existing.add(emailLower); + unique.push(r); + } + } + return [...prev, ...unique]; + }); setCcInput(''); } }; @@ -976,7 +1014,7 @@ export default function ComposeScreen({ route, navigation }: Props) { @@ -993,7 +1031,7 @@ export default function ComposeScreen({ route, navigation }: Props) { - + {t('email_composer.to', 'To')} {toRecipients.map((r, i) => ( @@ -1011,8 +1049,13 @@ export default function ComposeScreen({ route, navigation }: Props) { onChangeText={setToInput} onFocus={() => setActiveField('to')} onBlur={() => { - addTyped(); - setTimeout(() => setActiveField((f) => (f === 'to' ? null : f)), 200); + setTimeout(() => { + if (!isPickingSuggestion.current) { + addTyped(); + } + setActiveField((f) => (f === 'to' ? null : f)); + isPickingSuggestion.current = false; + }, 200); }} onSubmitEditing={addTyped} keyboardType="email-address" @@ -1021,7 +1064,13 @@ export default function ComposeScreen({ route, navigation }: Props) { /> {activeField === 'to' && suggestions.length > 0 && ( - + { + isPickingSuggestion.current = true; + }} + /> )} {!ccVisible && ( setCcVisible(true)} style={styles.ccToggle}> @@ -1031,7 +1080,7 @@ export default function ComposeScreen({ route, navigation }: Props) { {ccVisible && ( - + {t('email_composer.cc', 'Cc')} {ccRecipients.map((r, i) => ( @@ -1049,8 +1098,13 @@ export default function ComposeScreen({ route, navigation }: Props) { onChangeText={setCcInput} onFocus={() => setActiveField('cc')} onBlur={() => { - addTyped(); - setTimeout(() => setActiveField((f) => (f === 'cc' ? null : f)), 200); + setTimeout(() => { + if (!isPickingSuggestion.current) { + addTyped(); + } + setActiveField((f) => (f === 'cc' ? null : f)); + isPickingSuggestion.current = false; + }, 200); }} onSubmitEditing={addTyped} keyboardType="email-address" @@ -1059,7 +1113,13 @@ export default function ComposeScreen({ route, navigation }: Props) { /> {activeField === 'cc' && suggestions.length > 0 && ( - + { + isPickingSuggestion.current = true; + }} + /> )} )} @@ -1293,6 +1353,14 @@ export default function ComposeScreen({ route, navigation }: Props) { onChange={onCustomPickerChange} /> )} + + setIdentitySheetOpen(false)} + identities={identities} + selectedIdentityId={selectedIdentityId} + onPick={(identity) => setSelectedIdentityId(identity.id)} + /> ); } @@ -1332,6 +1400,7 @@ function makeStyles(c: ThemePalette) { borderBottomWidth: 1, borderBottomColor: c.borderLight, gap: spacing.md, + position: 'relative', }, fieldLabel: { ...typography.body, @@ -1374,12 +1443,16 @@ function makeStyles(c: ThemePalette) { paddingVertical: 6, }, suggestionBox: { - marginTop: spacing.xs, - marginLeft: 60, + position: 'absolute', + top: '100%', + left: spacing.lg + 56 + spacing.md, + right: spacing.lg, borderWidth: 1, borderColor: c.borderLight, borderRadius: radius.sm, backgroundColor: c.card, + zIndex: 20, + elevation: 5, overflow: 'hidden', }, suggestionRow: { diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 8e6944ea..a713daee 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -24,8 +24,8 @@ import { useSettingsStore, type SwipeAction } from '../stores/settings-store'; import { useKeywordsStore } from '../stores/keywords-store'; import { useLocaleStore } from '../stores/locale-store'; import { formatListDate } from '../lib/date-format'; +import { findTrashMailbox, findArchiveMailbox, findJunkMailbox } from '../lib/mailbox-tree'; import type { Email } from '../api/types'; - function getSenderName(email: Email): string { return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown'; } @@ -164,6 +164,7 @@ interface EmailListScreenProps { export default function EmailListScreen({ onEmailPress, onComposePress }: EmailListScreenProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const { t } = useLocaleStore(); const [drawerOpen, setDrawerOpen] = React.useState(false); const [filterMenuOpen, setFilterMenuOpen] = React.useState(false); const emails = useEmailStore((s) => s.emails); @@ -232,15 +233,15 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL }, [emails, disableThreading]); const archiveMailboxId = React.useMemo( - () => mailboxes.find((m) => m.role === 'archive')?.id ?? null, + () => findArchiveMailbox(mailboxes)?.id ?? null, [mailboxes], ); const trashMailboxId = React.useMemo( - () => mailboxes.find((m) => m.role === 'trash')?.id ?? null, + () => findTrashMailbox(mailboxes)?.id ?? null, [mailboxes], ); const junkMailboxId = React.useMemo( - () => mailboxes.find((m) => m.role === 'junk' || m.role === 'spam')?.id ?? null, + () => findJunkMailbox(mailboxes)?.id ?? null, [mailboxes], ); @@ -336,16 +337,31 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL case 'archive': if (archiveMailboxId && currentMailboxId !== archiveMailboxId) { void archiveEmailAction(id); + } else if (!archiveMailboxId) { + Alert.alert( + t('email_list.error', 'Error'), + t('email_list.no_archive_folder', 'Could not find an Archive folder on the server.'), + ); } break; case 'delete': if (trashMailboxId) { void deleteEmailAction(id, trashMailboxId, currentMailboxId); + } else { + Alert.alert( + t('email_list.error', 'Error'), + t('email_list.no_trash_folder', 'Could not find a Trash folder on the server. Please check your mailbox configuration.'), + ); } break; case 'spam': if (junkMailboxId && currentMailboxId !== junkMailboxId) { void moveToMailboxAction(id, currentMailboxId, junkMailboxId); + } else if (!junkMailboxId) { + Alert.alert( + t('email_list.error', 'Error'), + t('email_list.no_junk_folder', 'Could not find a Spam/Junk folder on the server.'), + ); } break; case 'read': @@ -365,7 +381,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL }, [ currentMailboxId, archiveMailboxId, trashMailboxId, junkMailboxId, moveToMailboxAction, archiveEmailAction, deleteEmailAction, - markRead, markUnread, toggleStar, togglePin, + markRead, markUnread, toggleStar, togglePin, t, ]); const renderEmailRow = React.useCallback( @@ -433,7 +449,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL }; const handleBulkDelete = async () => { - const trash = mailboxes.find((m) => m.role === 'trash'); + const trash = findTrashMailbox(mailboxes); if (!trash || !currentMailboxId) { clearSelection(); return; diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 52ea31ee..14632870 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -29,6 +29,8 @@ import { setEmailKeywords } from '../api/email'; import { shareEmailEml, shareAttachment, downloadAttachment } from '../lib/email-export'; import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store'; import { useSheetDrag } from '../lib/use-sheet-drag'; +import { useLocaleStore } from '../stores/locale-store'; +import { findTrashMailbox } from '../lib/mailbox-tree'; import type { Email, Mailbox } from '../api/types'; import type { RootStackParamList } from '../navigation/types'; @@ -65,6 +67,7 @@ function plainTextBody(email: Email): string { export default function EmailThreadScreen({ route, navigation }: Props) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const { t } = useLocaleStore(); const { jmapAccountId } = route.params; // The displayed email is tracked in local state (not a route param) so that // swiping / Prev-Next can switch messages in place without remounting the @@ -299,8 +302,14 @@ export default function EmailThreadScreen({ route, navigation }: Props) { const onDelete = () => { if (!email || !currentMailboxId) return; - const trash = mailboxes.find((m) => m.role === 'trash'); - if (!trash) return; + const trash = findTrashMailbox(mailboxes); + if (!trash) { + Alert.alert( + t('email_list.error', 'Error'), + t('email_list.no_trash_folder', 'Could not find a Trash folder on the server. Please check your mailbox configuration.'), + ); + return; + } void deleteEmail(email.id, trash.id, currentMailboxId); navigation.goBack(); };