Compare commits

..
22 Commits
Author SHA1 Message Date
Linus Rath 42f18a78b2 chore: update version number to 0.1.54 2026-07-21 19:55:27 +02:00
Linus Rath 406d2c208a Merge origin/main: keep escape-free inline editor over asset-split fix
PR #20 (c5e5092) fixed #11 on Android by moving the editor page to
android/app/src/main/assets/editor.html, but the real bug was never
'webview scripting broken in release builds': the template literal in
buildEditorHtml cooked the insertLink scheme-regex escapes into an
invalid regex, and that SyntaxError killed the whole inline script.
The PR's buildEditorScript (injectedJavaScript) still contains the
cooked regex, so its theme/init injection dies on Android and the
editor stays completely dead on iOS, whose page no longer has any
inline script.

Resolve by keeping the lib-based editor from the local fix (valid,
escape-free inline script for both platforms, regression-tested in
src/lib/__tests__/editor-html.test.ts, verified on a Pixel 7 API 34
release build) and dropping the now-unused editor.html asset. All
other upstream fixes are kept.
2026-07-21 19:46:55 +02:00
Linus Rath 6f712bf43f fix: compose editor inline script died on parse, keeping Send disabled #11
The template literal in buildEditorHtml cooked the backslash escapes in
the insertLink scheme regex (\/ and \? became bare / and ?), so the page
received /^(https?:|mailto:|tel:|sms:|/|?|#)/i — an invalid regex. The
resulting SyntaxError killed the entire inline editor script: no change
messages ever reached RN, bodyHtml stayed empty, canSend never enabled
without an attachment, and mails sent with one had empty bodies.
Regressed in 29b06a3.

Rewrite the scheme check without backslash escapes and move the page
builder into src/lib/editor-html.ts so a unit test can assert the emitted
script parses and stays backslash-free.

Verified on a Pixel 7 API 34 emulator (release build): typing now posts
change messages and the plain-text body extraction sees the content.
2026-07-21 19:34:51 +02:00
Linus Rath 844cb703c8 Merge pull request #20 from arnauda-gh/fix/android-webview-release-editor
fix(editor): resolve disabled send button and webview scripting on android release builds
2026-07-19 10:12:31 +02:00
Linus Rath 11e5963149 Merge pull request #26 from arnauda-gh/fix/contact-suggestions-view
fix(composer): improve contact suggestion layout and prevent focus/duplicate issues
2026-07-16 19:32:33 +02:00
arnauda-gh 07438e010e fix(composer): improve contact suggestion layout and prevent focus loss & duplicate recipients 2026-07-16 19:05:47 +02:00
Linus Rath 3e33efb71f Merge pull request #17 from arnauda-gh/fix/snackbar-zindex
fix: ensure UndoSnackbar renders on top of compose FAB
2026-07-16 17:57:55 +02:00
Linus Rath 376ee3d678 Merge pull request #18 from arnauda-gh/fix/sender-addresses-scroll
fix: replace identity alert picker with scrollable bottom sheet modal
2026-07-16 17:57:31 +02:00
Linus Rath 6f19cdbd17 Merge pull request #22 from arnauda-gh/fix/duplicate-recipients
fix(composer): prevent duplicate recipients from being added on done/blur action
2026-07-16 17:55:18 +02:00
Linus Rath 6985a7d5f6 Merge pull request #24 from arnauda-gh/fix/swipe-actions-notification
fix(mailboxes): prevent silent swipe failures by notifying user of missing folders
2026-07-16 17:55:05 +02:00
arnauda-gh 8231a55acb fix(mailboxes): show error notifications when special folders are missing on JMAP server 2026-07-16 10:27:30 +02:00
arnauda-gh 1b0740eab4 fix(composer): prevent duplicate recipients from being added on done/blur action 2026-07-16 10:15:04 +02:00
arnauda-gh c5e509274d fix(editor): resolve disabled send button and webview scripting on android release builds 2026-07-16 10:08:17 +02:00
arnauda-gh 1bf5920b04 fix: pre-select primary sender identity matching active account email 2026-07-15 19:43:34 +02:00
arnauda-gh 2547c6f199 fix: replace identity alert picker with scrollable bottom sheet modal 2026-07-15 19:31:54 +02:00
arnauda-gh 69e27a386c fix: ensure UndoSnackbar renders on top of compose FAB 2026-07-15 18:49:52 +02:00
Linus Rath 7390096567 Merge pull request #15 from arnauda-gh/fix/coalesce-token-refresh
fix(auth): coalesce concurrent token refresh requests
2026-07-13 21:18:13 +02:00
arnauda-gh 58c642f27b 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
2026-07-13 09:05:45 +02:00
Linus Rath b1d1e84666 chore: update version number to 0.1.53 2026-06-17 09:40:10 +02:00
Linus Rath 55484453a1 fix: coalesce concurrent push setups to prevent subscription swarm 2026-06-17 09:39:20 +02:00
Linus Rath b525c52870 chore: update version number to 0.1.52 2026-06-17 09:07:55 +02:00
Linus Rath b64afb462f fix: reap dead push subscriptions from other devices via relay liveness check 2026-06-17 09:07:42 +02:00
20 changed files with 1089 additions and 364 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.51
0.1.54
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.51",
"version": "0.1.54",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.51",
"version": "0.1.54",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.51",
"version": "0.1.54",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+1
View File
@@ -151,6 +151,7 @@ export class JMAPClient {
private async persistRefreshedTokens(next: OAuthTokens): Promise<void> {
if (!this.credentials) return;
if (this.credentials.accessToken === next.accessToken) return;
this.credentials = {
...this.credentials,
accessToken: next.accessToken,
+4 -1
View File
@@ -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
+205
View File
@@ -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 (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.sheetHandleHit}>
<View style={styles.sheetHandle} />
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('email_composer.from', 'From')}</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView style={styles.scrollList}>
{identities.map((identity) => {
const isSelected = identity.id === selectedIdentityId;
return (
<Pressable
key={identity.id}
onPress={() => {
onPick(identity);
onClose();
}}
style={({ pressed }) => [
styles.identityRow,
pressed && styles.identityRowPressed,
]}
>
<View style={styles.iconWrap}>
<Mail size={16} color={isSelected ? c.primary : c.textSecondary} />
</View>
<View style={styles.identityDetails}>
{!!identity.name && (
<Text style={styles.identityName} numberOfLines={1}>
{identity.name}
</Text>
)}
<Text style={[styles.identityEmail, !identity.name && styles.identityEmailOnly]} numberOfLines={1}>
{identity.email}
</Text>
</View>
{isSelected && (
<View style={styles.checkWrap}>
<Check size={16} color={c.primary} />
</View>
)}
</Pressable>
);
})}
</ScrollView>
</Animated.View>
</Modal>
);
}
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,
},
});
}
+3 -273
View File
@@ -3,10 +3,7 @@ import { StyleSheet, View, Platform } from 'react-native';
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { useColors } from '../theme/colors';
import type { ThemePalette } from '../theme/tokens';
// Minimum visible editor height (px). The editor auto-grows beyond this as the
// user types, and the parent ScrollView handles overflow.
const MIN_HEIGHT = 220;
import { buildEditorHtml, MIN_EDITOR_HEIGHT } from '../lib/editor-html';
export type RichTextCommand =
| 'bold'
@@ -66,273 +63,6 @@ interface Props {
onBlur?: () => void;
}
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);
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<style>
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; padding: 0; background: ${c.background}; color: ${c.text}; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
overflow-y: hidden;
-webkit-text-size-adjust: 100%;
}
#editor {
outline: none;
padding: 12px 16px 24px;
min-height: ${MIN_HEIGHT}px;
word-wrap: break-word;
overflow-wrap: anywhere;
}
#editor[data-empty="true"]::before {
content: attr(data-placeholder);
color: ${c.textMuted};
pointer-events: none;
position: absolute;
}
#editor p { margin: 0 0 12px 0; }
#editor p:last-child { margin-bottom: 0; }
#editor h1 { font-size: 1.4em; font-weight: 700; margin: 0 0 12px 0; line-height: 1.2; }
#editor h2 { font-size: 1.2em; font-weight: 700; margin: 0 0 10px 0; line-height: 1.3; }
#editor ul, #editor ol { margin: 0 0 12px 0; padding-left: 24px; }
#editor li { margin-bottom: 4px; }
#editor blockquote {
margin: 0 0 12px 0;
padding: 4px 0 4px 12px;
border-left: 3px solid ${c.border};
color: ${c.textSecondary};
}
#editor a { color: ${c.primary}; text-decoration: underline; }
#editor img { max-width: 100%; height: auto; border-radius: 4px; }
#editor pre, #editor code {
background: ${c.surfaceActive};
border-radius: 4px;
padding: 2px 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.92em;
}
#editor pre { padding: 8px 12px; overflow-x: auto; }
::selection { background: ${c.primary}33; }
</style>
</head>
<body>
<div id="editor" contenteditable="true" data-placeholder=${placeholderJson}></div>
<script>
(function () {
var editor = document.getElementById('editor');
var lastHtml = null;
var lastHeight = 0;
function refreshEmpty() {
var html = editor.innerHTML;
var isEmpty =
html === '' ||
html === '<br>' ||
html === '<p></p>' ||
html === '<p><br></p>' ||
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(${MIN_HEIGHT}, editor.scrollHeight + 8);
if (Math.abs(h - lastHeight) < 2) return;
lastHeight = h;
post('height', h);
}
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);
}
// ── Initial content ────────────────────────────────────────────────
var initial = ${initialJson};
if (initial && typeof initial === 'string') editor.innerHTML = initial;
refreshEmpty();
lastHtml = editor.innerHTML;
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();
});
// ── Bridge: receive commands from RN ───────────────────────────────
window.__rne = {
exec: function (command, value) {
editor.focus();
try {
if (command === 'formatBlock' && value) {
// Toggle: if we're already in that block, switch to <p>.
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, '<p>');
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();
// Reject anything that isn't a safe scheme or an absolute path, so the
// user can't accidentally (or via a malicious paste) ship a
// javascript:/data: link to their recipient.
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) {}
// execCommand doesn't set rel; tag every anchor that points at this URL.
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var safeUrl = trimmed
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
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, '&quot;');
var safeAlt = (alt || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;');
var safeCid = cid ? cid.replace(/"/g, '&quot;') : '';
var html = '<img src="' + safeSrc + '" alt="' + safeAlt + '"' +
(safeCid ? ' data-cid="' + safeCid + '"' : '') + ' />';
try { document.execCommand('insertHTML', false, html); } catch (e) {}
reportChange();
reportHeight();
},
focus: function () { editor.focus(); },
};
reportHeight();
reportChange();
// Re-measure after async layout (image loads, font swap).
window.addEventListener('load', function () {
setTimeout(reportHeight, 50);
setTimeout(reportHeight, 200);
setTimeout(reportHeight, 600);
});
if (window.ResizeObserver) {
new ResizeObserver(reportHeight).observe(editor);
}
})();
true;
</script>
</body>
</html>`;
}
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
{ initialHtml = '', placeholder = '', onChange, onSelectionChange, onFocus, onBlur },
ref,
@@ -340,7 +70,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const webViewRef = React.useRef<WebView>(null);
const [height, setHeight] = React.useState(MIN_HEIGHT);
const [height, setHeight] = React.useState(MIN_EDITOR_HEIGHT);
const onChangeRef = React.useRef(onChange);
const onSelectionRef = React.useRef(onSelectionChange);
const onFocusRef = React.useRef(onFocus);
@@ -400,7 +130,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
break;
case 'height': {
const h = typeof data.payload === 'number' ? data.payload : Number(data.payload);
if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_HEIGHT, h));
if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_EDITOR_HEIGHT, h));
break;
}
case 'selection':
+2 -2
View File
@@ -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();
+1
View File
@@ -83,6 +83,7 @@ function makeStyles(c: ThemePalette) {
position: 'absolute',
left: spacing.md,
right: spacing.md,
zIndex: 50,
},
bar: {
flexDirection: 'row',
+2
View File
@@ -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';
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { buildEditorHtml } from '../editor-html';
import { LIGHT_COLORS, DARK_COLORS } from '../../theme/tokens';
function extractScript(html: string): string {
const m = html.match(/<script>([\s\S]*?)<\/script>/);
expect(m, 'page must contain an inline <script>').toBeTruthy();
return m![1];
}
describe('buildEditorHtml', () => {
const base = { initialHtml: '<p><br></p>', placeholder: 'Write…', c: LIGHT_COLORS };
// Regression for issue #11: backslash escapes inside the template literal
// are cooked away (`\/` → `/`), which once emitted an invalid regex. That
// SyntaxError killed the entire inline script, so the editor never posted
// `change` messages and Send stayed disabled on Android/iOS.
it('emits an inline script that parses as valid JavaScript', () => {
for (const c of [LIGHT_COLORS, DARK_COLORS]) {
const script = extractScript(buildEditorHtml({ ...base, c }));
// new Function() parses without executing (script needs a DOM to run).
expect(() => new Function(script)).not.toThrow();
}
});
it('emits no cooked-away backslashes in the script', () => {
// The page script is written escape-free on purpose; a backslash-free
// source cannot silently lose escapes to template-literal cooking.
const script = extractScript(buildEditorHtml(base));
expect(script).not.toContain('\\');
});
it('embeds initial content and placeholder as JSON strings', () => {
const html = buildEditorHtml({
...base,
initialHtml: '<p>He said "hi" & left</p>',
placeholder: 'Say "something"…',
});
expect(html).toContain(JSON.stringify('<p>He said "hi" & left</p>'));
expect(html).toContain(`data-placeholder=${JSON.stringify('Say "something"…')}`);
});
it('keeps the editor contenteditable and the bridge object wiring', () => {
const html = buildEditorHtml(base);
expect(html).toContain('<div id="editor" contenteditable="true"');
const script = extractScript(html);
expect(script).toContain('window.__rne');
expect(script).toContain('ReactNativeWebView.postMessage');
});
});
+89
View File
@@ -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);
});
});
@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Provide the Android native FCM surface setupPushNotifications needs. The
// global test-setup mocks react-native with an empty NativeModules, so override
// it here with a BulwarkFcm module and a pre-33 Platform.Version (which skips
// the runtime permission request).
vi.mock('react-native', () => {
class NativeEventEmitter {
addListener() {
return { remove: () => undefined };
}
}
return {
Platform: { OS: 'android', Version: 30, select: <T,>(s: { default?: T; android?: T }) => s.android ?? s.default },
NativeModules: {
BulwarkFcm: {
getToken: vi.fn(async () => 'fcm-token-xyz'),
deleteToken: vi.fn(async () => undefined),
},
},
NativeEventEmitter,
PermissionsAndroid: { RESULTS: { GRANTED: 'granted' }, request: vi.fn(async () => 'granted') },
};
});
vi.mock('../../api/jmap-client', () => ({
jmapClient: { username: 'user@example.com', serverUrl: 'https://mail.example.com' },
}));
vi.mock('../../api/push', () => ({
listPushSubscriptions: vi.fn(async () => []),
createPushSubscription: vi.fn(async () => 'new-server-id'),
verifyPushSubscription: vi.fn(async () => undefined),
destroyPushSubscription: vi.fn(async () => undefined),
updatePushSubscription: vi.fn(async () => true),
}));
import AsyncStorage from '@react-native-async-storage/async-storage';
import { setupPushNotifications, deviceClientIdKey } from '../push-notifications';
import {
listPushSubscriptions,
createPushSubscription,
destroyPushSubscription,
} from '../../api/push';
import { generateAccountId } from '../account-utils';
const OUR_DCID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const ACCOUNT_ID = generateAccountId('user@example.com', 'https://mail.example.com');
const RELAY = 'https://relay.example.com';
// State the fake relay reports for each foreign deviceClientId's /active probe.
type RelayState = 'dead' | 'live' | 'unknown';
function installFetch(states: Record<string, RelayState>): void {
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/api/push/register')) {
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
}
if (url.includes('/api/push/verify/')) {
return { ok: true, status: 200, json: async () => ({ verificationCode: 'CODE' }) } as Response;
}
const active = url.match(/\/api\/push\/active\/([^/?]+)$/);
if (active) {
const dcid = decodeURIComponent(active[1]);
const state = states[dcid] ?? 'unknown';
if (state === 'unknown') {
return { ok: false, status: 404, json: async () => ({ error: 'Unknown subscription' }) } as Response;
}
return { ok: true, status: 200, json: async () => ({ active: state === 'live' }) } as Response;
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
}
const destroyMock = destroyPushSubscription as ReturnType<typeof vi.fn>;
const listMock = listPushSubscriptions as ReturnType<typeof vi.fn>;
const createMock = createPushSubscription as ReturnType<typeof vi.fn>;
function sub(id: string, deviceClientId: string) {
return { id, deviceClientId, expires: new Date(Date.now() + 86400000).toISOString(), types: ['Email'] };
}
describe('setupPushNotifications leftover reaping', () => {
beforeEach(async () => {
vi.clearAllMocks();
await AsyncStorage.clear();
// Pin our deviceClientId so we control which leftovers are "ours".
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
});
it('reaps our own and relay-confirmed-dead leftovers, keeps live and unverifiable ones', async () => {
listMock.mockResolvedValue([
sub('own-old', OUR_DCID), // our own previous attempt -> reap
sub('foreign-dead', 'deaddeaddeaddeaddeaddeaddeaddead'), // relay: dead -> reap
sub('foreign-live', 'livelivelivelivelivelivelivelive'), // relay: live -> keep
sub('foreign-unknown', 'unknwunknwunknwunknwunknwunknwun'), // relay: 404 -> keep
]);
installFetch({
deaddeaddeaddeaddeaddeaddeaddead: 'dead',
livelivelivelivelivelivelivelive: 'live',
unknwunknwunknwunknwunknwunknwun: 'unknown',
});
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
expect(result.verified).toBe(true);
const reaped = destroyMock.mock.calls.map((c) => c[0]);
expect(reaped).toContain('own-old');
expect(reaped).toContain('foreign-dead');
expect(reaped).not.toContain('foreign-live');
expect(reaped).not.toContain('foreign-unknown');
});
it('keeps foreign subs when the relay probe fails (network error)', async () => {
listMock.mockResolvedValue([sub('foreign-x', 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')]);
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/api/push/register')) {
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
}
if (url.includes('/api/push/verify/')) {
return { ok: true, status: 200, json: async () => ({ verificationCode: 'CODE' }) } as Response;
}
if (url.includes('/api/push/active/')) throw new Error('network down');
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
await setupPushNotifications({ relayBaseUrl: RELAY });
expect(destroyMock.mock.calls.map((c) => c[0])).not.toContain('foreign-x');
});
it('coalesces concurrent setups into a single flow (no subscription swarm)', async () => {
listMock.mockResolvedValue([]);
installFetch({});
// Fire several overlapping setups, as App.tsx does while auth settles and
// on FCM token refresh. Only one underlying JMAP subscription must be made.
const results = await Promise.all([
setupPushNotifications({ relayBaseUrl: RELAY }),
setupPushNotifications({ relayBaseUrl: RELAY }),
setupPushNotifications({ relayBaseUrl: RELAY }),
]);
expect(createMock).toHaveBeenCalledTimes(1);
expect(new Set(results.map((r) => r.subscriptionId)).size).toBe(1);
});
});
+286
View File
@@ -0,0 +1,286 @@
// Builds the self-contained HTML document for the RichTextEditor WebView.
// Kept as a pure module (no react-native imports) so tests can assert the
// embedded page script stays syntactically valid JavaScript.
//
// IMPORTANT: the page script lives inside a JS template literal. Backslash
// escapes are "cooked" by the template (`\/` becomes `/`, `\s` becomes `s`),
// which once shipped an invalid regex that killed the whole inline script
// with a SyntaxError (issue #11 — Send stayed disabled because the editor
// never posted `change` messages). Never rely on a backslash reaching the
// page verbatim; write escape-free code or double the backslash.
import type { ThemePalette } from '../theme/tokens';
// Minimum visible editor height (px). The editor auto-grows beyond this as the
// user types, and the parent ScrollView handles overflow.
export const MIN_EDITOR_HEIGHT = 220;
export 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);
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<style>
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; padding: 0; background: ${c.background}; color: ${c.text}; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
overflow-y: hidden;
-webkit-text-size-adjust: 100%;
}
#editor {
outline: none;
padding: 12px 16px 24px;
min-height: ${MIN_EDITOR_HEIGHT}px;
word-wrap: break-word;
overflow-wrap: anywhere;
}
#editor[data-empty="true"]::before {
content: attr(data-placeholder);
color: ${c.textMuted};
pointer-events: none;
position: absolute;
}
#editor p { margin: 0 0 12px 0; }
#editor p:last-child { margin-bottom: 0; }
#editor h1 { font-size: 1.4em; font-weight: 700; margin: 0 0 12px 0; line-height: 1.2; }
#editor h2 { font-size: 1.2em; font-weight: 700; margin: 0 0 10px 0; line-height: 1.3; }
#editor ul, #editor ol { margin: 0 0 12px 0; padding-left: 24px; }
#editor li { margin-bottom: 4px; }
#editor blockquote {
margin: 0 0 12px 0;
padding: 4px 0 4px 12px;
border-left: 3px solid ${c.border};
color: ${c.textSecondary};
}
#editor a { color: ${c.primary}; text-decoration: underline; }
#editor img { max-width: 100%; height: auto; border-radius: 4px; }
#editor pre, #editor code {
background: ${c.surfaceActive};
border-radius: 4px;
padding: 2px 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.92em;
}
#editor pre { padding: 8px 12px; overflow-x: auto; }
::selection { background: ${c.primary}33; }
</style>
</head>
<body>
<div id="editor" contenteditable="true" data-placeholder=${placeholderJson}></div>
<script>
(function () {
var editor = document.getElementById('editor');
var lastHtml = null;
var lastHeight = 0;
function refreshEmpty() {
var html = editor.innerHTML;
var isEmpty =
html === '' ||
html === '<br>' ||
html === '<p></p>' ||
html === '<p><br></p>' ||
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(${MIN_EDITOR_HEIGHT}, editor.scrollHeight + 8);
if (Math.abs(h - lastHeight) < 2) return;
lastHeight = h;
post('height', h);
}
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);
}
// ── Initial content ────────────────────────────────────────────────
var initial = ${initialJson};
if (initial && typeof initial === 'string') editor.innerHTML = initial;
refreshEmpty();
lastHtml = editor.innerHTML;
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();
});
// ── Bridge: receive commands from RN ───────────────────────────────
window.__rne = {
exec: function (command, value) {
editor.focus();
try {
if (command === 'formatBlock' && value) {
// Toggle: if we're already in that block, switch to <p>.
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, '<p>');
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();
// Reject anything that isn't a safe scheme or an absolute path, so the
// user can't accidentally (or via a malicious paste) ship a
// javascript:/data: link to their recipient. Written without regex
// backslash escapes — see the template-cooking warning at the top.
var trimmed = (url || '').trim();
var first = trimmed.charAt(0);
var safeScheme = /^(https?:|mailto:|tel:|sms:)/i.test(trimmed)
|| first === '/' || first === '?' || first === '#';
if (!trimmed || !safeScheme) return;
var sel = window.getSelection();
var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
if (hasSelection) {
try { document.execCommand('createLink', false, trimmed); } catch (e) {}
// execCommand doesn't set rel; tag every anchor that points at this URL.
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
var safeUrl = trimmed
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
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, '&quot;');
var safeAlt = (alt || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;');
var safeCid = cid ? cid.replace(/"/g, '&quot;') : '';
var html = '<img src="' + safeSrc + '" alt="' + safeAlt + '"' +
(safeCid ? ' data-cid="' + safeCid + '"' : '') + ' />';
try { document.execCommand('insertHTML', false, html); } catch (e) {}
reportChange();
reportHeight();
},
focus: function () { editor.focus(); },
};
reportHeight();
reportChange();
// Re-measure after async layout (image loads, font swap).
window.addEventListener('load', function () {
setTimeout(reportHeight, 50);
setTimeout(reportHeight, 200);
setTimeout(reportHeight, 600);
});
if (window.ResizeObserver) {
new ResizeObserver(reportHeight).observe(editor);
}
})();
true;
</script>
</body>
</html>`;
}
+33
View File
@@ -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));
});
}
+47 -30
View File
@@ -207,6 +207,8 @@ export async function redeemPairingCode(webmailUrl: string, code: string): Promi
};
}
const activeRefreshes = new Map<string, Promise<OAuthTokens>>();
// 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<OAut
if (!tokens.refreshToken) {
throw new HandoffError('No refresh token available');
}
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 cacheKey = tokens.refreshToken;
let promise = activeRefreshes.get(cacheKey);
if (!promise) {
promise = (async () => {
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();
+76 -15
View File
@@ -217,6 +217,33 @@ async function registerWithRelay(params: {
}
}
/**
* Ask the relay whether a leftover subscription is dead. Returns true ONLY when
* the relay positively reports it inactive - a record it knows about that has
* never forwarded a push and isn't freshly registered. Every other outcome (the
* relay doesn't recognise the id, an older relay without this endpoint, a
* network blip, or a live subscription) returns false, so we never reap
* anything we can't confirm is dead. This is what lets setup clear its own
* abandoned attempts - and dead siblings left by reinstalls that regenerated
* the deviceClientId - without disturbing another live device or the PWA that
* shares the account.
*/
async function relayReportsDead(
relayBaseUrl: string,
subscriptionId: string,
): Promise<boolean> {
try {
const res = await fetch(
buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`),
);
if (!res.ok) return false;
const body = (await res.json()) as { active?: unknown };
return body.active === false;
} catch {
return false;
}
}
async function pollVerificationCode(
relayBaseUrl: string,
subscriptionId: string,
@@ -241,12 +268,36 @@ async function pollVerificationCode(
throw new Error('Timed out waiting for PushVerification from JMAP server');
}
// Coalesce concurrent setup attempts per account. App.tsx re-runs its push
// effect as auth state settles during startup and again on every FCM token
// refresh; because a failing attempt blocks for up to 75s polling for the
// verification code, those re-fires overlap. Without this guard each
// overlapping run mints its OWN deviceClientId and JMAP subscription, and the
// resulting swarm starves Stalwart's one-PushVerification-per-60s slot so none
// of them ever verifies (the symptom is a perpetual "Timed out waiting for
// PushVerification"). Callers share the first in-flight run instead.
const inFlightSetups = new Map<string, Promise<PushSetupResult>>();
/**
* Full setup flow: ask permission, fetch the device's FCM token, register
* with the relay, create a JMAP PushSubscription, poll for the verification
* code, and finalise the subscription.
* code, and finalise the subscription. Concurrent calls for the same account
* are coalesced onto a single in-flight run.
*/
export async function setupPushNotifications(
export function setupPushNotifications(
params: PushSetupParams,
): Promise<PushSetupResult> {
const key = `${jmapClient.username ?? ''}@${jmapClient.serverUrl ?? ''}`;
const existing = inFlightSetups.get(key);
if (existing) return existing;
const run = setupPushNotificationsInner(params).finally(() => {
inFlightSetups.delete(key);
});
inFlightSetups.set(key, run);
return run;
}
async function setupPushNotificationsInner(
params: PushSetupParams,
): Promise<PushSetupResult> {
const native = getNative();
@@ -306,19 +357,29 @@ export async function setupPushNotifications(
await AsyncStorage.removeItem(subKey);
}
// Reap any leftover Stalwart subscriptions still bound to this account's
// deviceClientId. These pile up when a previous enable attempt failed
// mid-flow (verify race, network blip, app killed). Stalwart per-account
// rate-limits PushVerification posts, so leaving stragglers around blocks
// the new one's verification - the symptom is a confusing "code does not
// match". Only reap matches for THIS account's deviceClientId; other
// accounts' subscriptions on this server (rare but possible if the same
// JMAP server backs multiple accounts) must be left alone.
const stragglers = existingSubs.filter(
(s) => s.deviceClientId === deviceClientId && s.id !== storedServerId,
);
for (const s of stragglers) {
await destroyPushSubscription(s.id).catch(() => undefined);
// Reap leftover Stalwart subscriptions that would otherwise starve the new
// one's verification. Stalwart emits only one PushVerification per account
// per ~60s and picks the oldest unverified subscription, so a single stale
// straggler blocks every fresh attempt - the symptom is the confusing
// "Timed out waiting for PushVerification" error. We can't read a
// subscription's verified state or URL over JMAP (Stalwart hides both), only
// its deviceClientId, so we decide what's safe to remove like this:
// - same deviceClientId as ours: a previous attempt from THIS device,
// always safe to reap.
// - a different deviceClientId: could be another live device or the PWA on
// this account. Ask the relay whether it's still alive and only reap the
// ones it confirms are dead. Anything live - or anything the relay can't
// vouch for (a different relay, a non-Bulwark client, a network blip) -
// is left untouched.
for (const s of existingSubs) {
if (s.id === storedServerId) continue;
if (s.deviceClientId === deviceClientId) {
await destroyPushSubscription(s.id).catch(() => undefined);
continue;
}
if (await relayReportsDead(relayBaseUrl, s.deviceClientId)) {
await destroyPushSubscription(s.id).catch(() => undefined);
}
}
const serverAssignedId = await createPushSubscription({
+104 -31
View File
@@ -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 (
<Pressable
key={`${s.contact.id}-${s.email}-${i}`}
onPressIn={onPressIn}
onPress={() => 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<string | null>(null);
const [sending, setSending] = React.useState(false);
const [selectedIdentityId, setSelectedIdentityId] = React.useState<string | null>(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<RichTextEditorHandle>(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<Map<string, AttachmentEntry>>(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) {
<View style={[styles.flex, { paddingBottom: bottomPad }]}>
<ScrollView
style={styles.flex}
keyboardShouldPersistTaps="handled"
keyboardShouldPersistTaps="always"
contentContainerStyle={styles.scrollContent}
>
<View style={styles.fieldRow}>
@@ -993,7 +1031,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
</Pressable>
</View>
<View style={styles.fieldRow}>
<View style={[styles.fieldRow, { zIndex: 5 }]}>
<Text style={styles.fieldLabel}>{t('email_composer.to', 'To')}</Text>
<View style={styles.recipientField}>
{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) {
/>
</View>
{activeField === 'to' && suggestions.length > 0 && (
<SuggestionList suggestions={suggestions} onPick={pickSuggestion} />
<SuggestionList
suggestions={suggestions}
onPick={pickSuggestion}
onPressIn={() => {
isPickingSuggestion.current = true;
}}
/>
)}
{!ccVisible && (
<Pressable onPress={() => setCcVisible(true)} style={styles.ccToggle}>
@@ -1031,7 +1080,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
</View>
{ccVisible && (
<View style={styles.fieldRow}>
<View style={[styles.fieldRow, { zIndex: 4 }]}>
<Text style={styles.fieldLabel}>{t('email_composer.cc', 'Cc')}</Text>
<View style={styles.recipientField}>
{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) {
/>
</View>
{activeField === 'cc' && suggestions.length > 0 && (
<SuggestionList suggestions={suggestions} onPick={pickSuggestion} />
<SuggestionList
suggestions={suggestions}
onPick={pickSuggestion}
onPressIn={() => {
isPickingSuggestion.current = true;
}}
/>
)}
</View>
)}
@@ -1293,6 +1353,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
onChange={onCustomPickerChange}
/>
)}
<IdentitySheet
visible={identitySheetOpen}
onClose={() => setIdentitySheetOpen(false)}
identities={identities}
selectedIdentityId={selectedIdentityId}
onPick={(identity) => setSelectedIdentityId(identity.id)}
/>
</SafeAreaView>
);
}
@@ -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: {
+22 -6
View File
@@ -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;
+11 -2
View File
@@ -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();
};