From 58c642f27bc535521025eeefcceccd215a748f8f Mon Sep 17 00:00:00 2001 From: arnauda-gh <59512940+arnauda-gh@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:05:45 +0200 Subject: [PATCH 1/8] fix(auth): coalesce concurrent token refresh requests Coalesces concurrent OAuth token refreshes using a Promise-based lock map keyed by the refresh token. This prevents the app from firing duplicate refresh requests with the same expired refresh token, which causes replay revocations on OIDC providers (e.g. Authelia/Hydra) enforcing strict rotation. Also optimizes persistRefreshedTokens and ensureFreshCredentials to prevent redundant SecureStore/disk writes when concurrent operations complete. Fixes #14 --- src/api/jmap-client.ts | 1 + src/api/unified-inbox.ts | 5 +- src/lib/__tests__/oauth.test.ts | 89 +++++++++++++++++++++++++++++++++ src/lib/oauth.ts | 77 +++++++++++++++++----------- 4 files changed, 141 insertions(+), 31 deletions(-) create mode 100644 src/lib/__tests__/oauth.test.ts 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/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/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(); From 69e27a386cb13a5c87a5193a4882a7de6ef269f6 Mon Sep 17 00:00:00 2001 From: arnauda-gh <59512940+arnauda-gh@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:49:52 +0200 Subject: [PATCH 2/8] fix: ensure UndoSnackbar renders on top of compose FAB --- src/components/UndoSnackbar.tsx | 1 + 1 file changed, 1 insertion(+) 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', From 2547c6f199d6b9cb45eec0be2ff76ae5ca1c607d Mon Sep 17 00:00:00 2001 From: arnauda-gh <59512940+arnauda-gh@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:31:54 +0200 Subject: [PATCH 3/8] fix: replace identity alert picker with scrollable bottom sheet modal --- src/components/IdentitySheet.tsx | 205 +++++++++++++++++++++++++++++++ src/components/index.ts | 2 + src/screens/ComposeScreen.tsx | 23 ++-- 3 files changed, 218 insertions(+), 12 deletions(-) create mode 100644 src/components/IdentitySheet.tsx 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/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/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 1dfda9ac..bc1ac81c 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, @@ -260,6 +260,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. @@ -422,17 +423,7 @@ export default function ComposeScreen({ route, navigation }: Props) { 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 = () => { @@ -1293,6 +1284,14 @@ export default function ComposeScreen({ route, navigation }: Props) { onChange={onCustomPickerChange} /> )} + + setIdentitySheetOpen(false)} + identities={identities} + selectedIdentityId={selectedIdentityId} + onPick={(identity) => setSelectedIdentityId(identity.id)} + /> ); } From 1bf5920b0433e5c3ebc1c66b96616e8d9344384f Mon Sep 17 00:00:00 2001 From: arnauda-gh <59512940+arnauda-gh@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:43:34 +0200 Subject: [PATCH 4/8] fix: pre-select primary sender identity matching active account email --- src/screens/ComposeScreen.tsx | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index bc1ac81c..c747ec53 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -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, @@ -406,20 +407,31 @@ 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; From c5e509274d2b63fc528ac3865b09c34eaf28c324 Mon Sep 17 00:00:00 2001 From: arnauda-gh <59512940+arnauda-gh@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:08:17 +0200 Subject: [PATCH 5/8] fix(editor): resolve disabled send button and webview scripting on android release builds --- android/app/src/main/assets/editor.html | 266 +++++++++++++ src/components/RichTextEditor.tsx | 475 +++++++++++++----------- src/screens/ComposeScreen.tsx | 11 +- 3 files changed, 542 insertions(+), 210 deletions(-) create mode 100644 android/app/src/main/assets/editor.html diff --git a/android/app/src/main/assets/editor.html b/android/app/src/main/assets/editor.html new file mode 100644 index 00000000..76337c71 --- /dev/null +++ b/android/app/src/main/assets/editor.html @@ -0,0 +1,266 @@ + + + + + + + + +
+ + + diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index 1ab257f9..9a0fe5db 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -66,16 +66,251 @@ interface Props { onBlur?: () => void; } +function buildEditorScript(opts: { + initialHtml: string; + placeholder: string; + minHeight: number; + c: ThemePalette; +}): string { + const { initialHtml, placeholder, minHeight, c } = opts; + const initialJson = JSON.stringify(initialHtml); + const placeholderJson = JSON.stringify(placeholder); + return `(function () { + var editor = document.getElementById('editor'); + var lastHtml = null; + var lastHeight = 0; + + function refreshEmpty() { + var html = editor.innerHTML; + var isEmpty = + html === '' || + html === '
' || + html === '

' || + html === '


' || + editor.textContent.trim() === '' && editor.querySelectorAll('img').length === 0; + editor.setAttribute('data-empty', isEmpty ? 'true' : 'false'); + } + + function post(type, payload) { + if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) { + window.ReactNativeWebView.postMessage(JSON.stringify({ type: type, payload: payload })); + } + } + + function reportHeight() { + var h = Math.max(${minHeight}, editor.scrollHeight + 8); + if (Math.abs(h - lastHeight) < 2) return; + lastHeight = h; + post('height', h); + } + + if (!window.__rne) { + window.__rne = { + exec: function (command, value) { + editor.focus(); + try { + if (command === 'formatBlock' && value) { + var sel = window.getSelection(); + if (sel && sel.rangeCount) { + var node = sel.anchorNode; + while (node && node !== editor) { + if (node.nodeType === 1 && node.tagName === value.toUpperCase()) { + document.execCommand('formatBlock', false, '

'); + reportChange(); + reportHeight(); + reportSelection(); + return; + } + node = node.parentNode; + } + } + document.execCommand('formatBlock', false, '<' + value.toLowerCase() + '>'); + } else { + document.execCommand(command, false, value); + } + } catch (e) {} + reportChange(); + reportHeight(); + reportSelection(); + }, + setHtml: function (html) { + editor.innerHTML = html || ''; + lastHtml = editor.innerHTML; + refreshEmpty(); + reportChange(); + reportHeight(); + }, + insertHtml: function (html) { + editor.focus(); + try { document.execCommand('insertHTML', false, html); } catch (e) {} + reportChange(); + reportHeight(); + }, + insertLink: function (url, label) { + editor.focus(); + var safeSchemeRe = /^(https?:|mailto:|tel:|sms:|\/|\?|#)/i; + var trimmed = (url || '').trim(); + if (!trimmed || !safeSchemeRe.test(trimmed)) return; + var sel = window.getSelection(); + var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed; + if (hasSelection) { + try { document.execCommand('createLink', false, trimmed); } catch (e) {} + Array.prototype.forEach.call(editor.getElementsByTagName('a'), function (a) { + if (a.getAttribute('href') === trimmed) { + a.setAttribute('rel', 'noopener noreferrer nofollow'); + } + }); + } else { + var text = label && label.trim() ? label : trimmed; + var safe = text.replace(/&/g, '&').replace(//g, '>'); + var safeUrl = trimmed + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + var html = '' + safe + ''; + try { document.execCommand('insertHTML', false, html); } catch (e) {} + } + reportChange(); + reportHeight(); + reportSelection(); + }, + unsetLink: function () { + editor.focus(); + try { document.execCommand('unlink', false, null); } catch (e) {} + reportChange(); + reportSelection(); + }, + insertImage: function (src, cid, alt) { + editor.focus(); + var safeSrc = (src || '').replace(/"/g, '"'); + var safeAlt = (alt || '').replace(/&/g, '&').replace(/"/g, '"'); + var safeCid = cid ? cid.replace(/"/g, '"') : ''; + var html = '' + safeAlt + ''; + try { document.execCommand('insertHTML', false, html); } catch (e) {} + reportChange(); + reportHeight(); + }, + focus: function () { editor.focus(); }, + }; + } + + function reportChange() { + refreshEmpty(); + var html = editor.innerHTML; + if (html === lastHtml) return; + lastHtml = html; + post('change', html); + } + + function reportSelection() { + function active(name) { + try { return document.queryCommandState(name); } catch (e) { return false; } + } + function inBlock(tag) { + var sel = window.getSelection(); + if (!sel || !sel.rangeCount) return false; + var node = sel.anchorNode; + while (node && node !== editor) { + if (node.nodeType === 1 && node.tagName === tag) return true; + node = node.parentNode; + } + return false; + } + var state = { + bold: active('bold'), + italic: active('italic'), + underline: active('underline'), + strikeThrough: active('strikeThrough'), + ul: active('insertUnorderedList'), + ol: active('insertOrderedList'), + blockquote: inBlock('BLOCKQUOTE'), + h1: inBlock('H1'), + h2: inBlock('H2'), + alignLeft: active('justifyLeft'), + alignCenter: active('justifyCenter'), + alignRight: active('justifyRight'), + link: inBlock('A'), + }; + post('selection', state); + } + + var dbg = document.getElementById('debug-log'); + try { + if (!window.__rneStyleAdded) { + window.__rneStyleAdded = true; + var style = document.createElement('style'); + style.innerHTML = + 'html, body { background: ' + ${JSON.stringify(c.background)} + '; color: ' + ${JSON.stringify(c.text)} + '; }' + + '#editor { min-height: ' + ${minHeight} + 'px; }' + + '#editor[data-empty="true"]::before { color: ' + ${JSON.stringify(c.textMuted)} + '; }' + + '#editor blockquote { border-left: 3px solid ' + ${JSON.stringify(c.border)} + '; color: ' + ${JSON.stringify(c.textSecondary)} + '; }' + + '#editor a { color: ' + ${JSON.stringify(c.primary)} + '; }' + + '#editor pre, #editor code { background: ' + ${JSON.stringify(c.surfaceActive)} + '; }' + + '::selection { background: ' + ${JSON.stringify(c.primary)} + '33; }'; + document.head.appendChild(style); + } + + if (!window.__rneIntervalSet) { + window.__rneIntervalSet = true; + setInterval(function () { + try { + var hasRN = typeof window.ReactNativeWebView !== 'undefined'; + var hasPost = hasRN && typeof window.ReactNativeWebView.postMessage !== 'undefined'; + dbg.innerHTML = "Webview - RN: " + hasRN + " | Post: " + hasPost + " | innerHTML len: " + editor.innerHTML.length; + } catch (e) { + dbg.innerHTML = "Interval Error: " + e.message; + } + }, 300); + } + + editor.setAttribute('data-placeholder', ${placeholderJson}); + + var initial = ${initialJson}; + if (initial && typeof initial === 'string') editor.innerHTML = initial; + refreshEmpty(); + lastHtml = editor.innerHTML; + + if (!window.__rneListenersAdded) { + window.__rneListenersAdded = true; + editor.addEventListener('input', function () { + reportChange(); + reportHeight(); + }); + editor.addEventListener('blur', function () { post('blur', null); }); + editor.addEventListener('focus', function () { post('focus', null); }); + document.addEventListener('selectionchange', function () { + if (document.activeElement === editor) reportSelection(); + }); + } + + reportHeight(); + reportChange(); + + window.addEventListener('load', function () { + setTimeout(reportHeight, 50); + setTimeout(reportHeight, 200); + setTimeout(reportHeight, 600); + }); + if (window.ResizeObserver) { + new ResizeObserver(reportHeight).observe(editor); + } + } catch (err) { + if (dbg) dbg.innerHTML = "Init Error: " + err.message; + } +})(); +true;`; +} + function buildEditorHtml(opts: { initialHtml: string; placeholder: string; c: ThemePalette; }): string { const { initialHtml, placeholder, c } = opts; - // Inject initial content as a JSON-encoded string so any HTML/quotes inside - // are safely embedded (no template literal collision with the script body). - const initialJson = JSON.stringify(initialHtml); const placeholderJson = JSON.stringify(placeholder); + const initialJson = JSON.stringify(initialHtml); return ` @@ -132,203 +367,6 @@ function buildEditorHtml(opts: {

- `; } @@ -350,16 +388,25 @@ const RichTextEditor = React.forwardRef(function Ri onFocusRef.current = onFocus; onBlurRef.current = onBlur; - // Build the source HTML once. Theming changes won't auto-rebuild the page, - // which is fine: themes change rarely and the user can recompose if needed. const html = React.useMemo( - () => buildEditorHtml({ initialHtml, placeholder, c }), - // c reference changes per theme switch but we don't want to lose user - // edits on a theme change. Bake in the initial colors only. + () => buildEditorHtml({ initialHtml: initialHtml ?? '', placeholder: placeholder ?? '', c }), // eslint-disable-next-line react-hooks/exhaustive-deps [], ); + const injectedJs = React.useMemo( + () => buildEditorScript({ initialHtml: initialHtml ?? '', placeholder: placeholder ?? '', minHeight: MIN_HEIGHT, c }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const source = React.useMemo(() => { + if (Platform.OS === 'android') { + return { uri: 'file:///android_asset/editor.html' }; + } + return { html }; + }, [html]); + const call = React.useCallback((script: string) => { webViewRef.current?.injectJavaScript(`${script}; true;`); }, []); @@ -395,6 +442,9 @@ const RichTextEditor = React.forwardRef(function Ri return; } switch (data.type) { + case 'ready': + call(`window.__rne && window.__rne.init(${JSON.stringify(initialHtml)}, ${JSON.stringify(placeholder)}, ${JSON.stringify(c)})`); + break; case 'change': onChangeRef.current?.(typeof data.payload === 'string' ? data.payload : ''); break; @@ -419,10 +469,16 @@ const RichTextEditor = React.forwardRef(function Ri { + setTimeout(() => { + webViewRef.current?.injectJavaScript(injectedJs); + }, 50); + }} + javaScriptEnabled={true} domStorageEnabled={false} scrollEnabled={false} showsVerticalScrollIndicator={false} @@ -436,13 +492,14 @@ const RichTextEditor = React.forwardRef(function Ri allowFileAccessFromFileURLs={false} allowUniversalAccessFromFileURLs={false} allowsBackForwardNavigationGestures={false} - incognito + incognito={true} cacheEnabled={false} mixedContentMode="never" androidLayerType={Platform.OS === 'android' ? 'hardware' : undefined} style={styles.webview} containerStyle={styles.webviewContainer} onShouldStartLoadWithRequest={(request) => { + if (Platform.OS === 'android') return true; // Allow only the initial about:blank doc + data URIs we emit // ourselves. Reject `about:*` (e.g. about:srcdoc) and every other // scheme so a paste-injected anchor can't navigate the editor. diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 1dfda9ac..0572ce35 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -69,7 +69,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 { From 1b0740eab4755b87447ed97e99a97f851807fa00 Mon Sep 17 00:00:00 2001 From: arnauda-gh <59512940+arnauda-gh@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:15:04 +0200 Subject: [PATCH 6/8] fix(composer): prevent duplicate recipients from being added on done/blur action --- src/screens/ComposeScreen.tsx | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 1dfda9ac..01d55f4b 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -448,11 +448,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(''); } }; From 8231a55acb43c5d0d5c824374de184c15dba6612 Mon Sep 17 00:00:00 2001 From: arnauda-gh <59512940+arnauda-gh@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:27:30 +0200 Subject: [PATCH 7/8] fix(mailboxes): show error notifications when special folders are missing on JMAP server --- src/components/SwipeableRow.tsx | 4 ++-- src/lib/mailbox-tree.ts | 33 +++++++++++++++++++++++++++++++ src/screens/EmailListScreen.tsx | 28 ++++++++++++++++++++------ src/screens/EmailThreadScreen.tsx | 13 ++++++++++-- 4 files changed, 68 insertions(+), 10 deletions(-) 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/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/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(); }; From 07438e010e46c6a05b286c703b2fff0c9f5f6511 Mon Sep 17 00:00:00 2001 From: arnauda-gh <59512940+arnauda-gh@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:04:01 +0200 Subject: [PATCH 8/8] fix(composer): improve contact suggestion layout and prevent focus loss & duplicate recipients --- src/screens/ComposeScreen.tsx | 55 +++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 1dfda9ac..80f2a707 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -113,10 +113,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 +128,7 @@ function SuggestionList({ return ( onPick(s)} style={({ pressed }) => [styles.suggestionRow, pressed && styles.suggestionRowPressed]} > @@ -343,6 +345,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 +378,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]); @@ -976,7 +980,7 @@ export default function ComposeScreen({ route, navigation }: Props) { @@ -993,7 +997,7 @@ export default function ComposeScreen({ route, navigation }: Props) { - + {t('email_composer.to', 'To')} {toRecipients.map((r, i) => ( @@ -1011,8 +1015,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 +1030,13 @@ export default function ComposeScreen({ route, navigation }: Props) { /> {activeField === 'to' && suggestions.length > 0 && ( - + { + isPickingSuggestion.current = true; + }} + /> )} {!ccVisible && ( setCcVisible(true)} style={styles.ccToggle}> @@ -1031,7 +1046,7 @@ export default function ComposeScreen({ route, navigation }: Props) { {ccVisible && ( - + {t('email_composer.cc', 'Cc')} {ccRecipients.map((r, i) => ( @@ -1049,8 +1064,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 +1079,13 @@ export default function ComposeScreen({ route, navigation }: Props) { /> {activeField === 'cc' && suggestions.length > 0 && ( - + { + isPickingSuggestion.current = true; + }} + /> )} )} @@ -1332,6 +1358,7 @@ function makeStyles(c: ThemePalette) { borderBottomWidth: 1, borderBottomColor: c.borderLight, gap: spacing.md, + position: 'relative', }, fieldLabel: { ...typography.body, @@ -1374,12 +1401,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: {