Compare commits

...
8 Commits
22 changed files with 1467 additions and 59 deletions
+34
View File
@@ -41,6 +41,9 @@ import { useLocaleStore } from './src/stores/locale-store';
import { useNetworkStore } from './src/stores/network-store';
import { useUpdatesStore } from './src/stores/updates-store';
import { UpdateBanner } from './src/components/UpdateBanner';
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
import { runOfflineSync } from './src/lib/offline-sync';
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
import { useColors } from './src/theme/colors';
@@ -76,6 +79,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
return (
<View style={{ flex: 1, backgroundColor: c.background }}>
<UpdateBanner />
<OfflineCacheBanner />
<Tab.Navigator
screenOptions={{
headerShown: false,
@@ -193,6 +197,18 @@ export default function App() {
return useNetworkStore.getState().init();
}, []);
// When the network flips back on while we're authenticated-but-offline
// (no live JMAP session), retry the session so the user lands back on
// live data without needing to relaunch.
React.useEffect(() => {
if (!isAuthenticated) return;
return useNetworkStore.subscribe((state, prev) => {
if (state.online && !prev.online && !useAuthStore.getState().session) {
void useAuthStore.getState().retrySession();
}
});
}, [isAuthenticated]);
React.useEffect(() => {
let cancelled = false;
void (async () => {
@@ -206,6 +222,24 @@ export default function App() {
return () => { cancelled = true; };
}, []);
// Offline mail cache: hydrate the cache index on launch, and kick off a
// background sync once we have a live JMAP session and the user has the
// feature enabled. Re-runs whenever the user changes the days window.
const offlineCacheEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
const offlineCacheDays = useSettingsStore((s) => s.offlineCacheDays);
const haveLiveSession = useAuthStore((s) => s.session != null);
React.useEffect(() => {
void useOfflineCacheStore.getState().hydrate();
}, []);
React.useEffect(() => {
if (!offlineCacheEnabled || !haveLiveSession) return;
// Slight delay so cold start doesn't compete with the inbox load.
const t = setTimeout(() => {
void runOfflineSync({ days: offlineCacheDays });
}, 2000);
return () => clearTimeout(t);
}, [offlineCacheEnabled, offlineCacheDays, haveLiveSession]);
// Listen for FCM messages so the app can refresh state even when woken
// from the background - the FirebaseMessagingService also posts the system
// notification so the user sees it regardless of RN runtime state.
+1 -1
View File
@@ -1 +1 @@
0.1.18
0.1.22
@@ -0,0 +1,286 @@
package com.anonymous.bulwarkmobile
import android.content.Context
import android.content.SharedPreferences
import android.security.KeyChain
import android.security.KeyChainAliasCallback
import android.util.Base64
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableMap
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.Socket
import java.net.URL
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedKeyManager
import javax.net.ssl.X509TrustManager
import kotlin.concurrent.thread
/**
* Bridges Android's KeyChain client-certificate machinery into JS so the
* mobile app can authenticate to a reverse proxy that asks for mTLS.
*
* Two surfaces:
* 1. `pickAlias` / `getAlias` / `clearAlias` — UI flow that lets the user
* pick a cert installed in Android's system credential store. The
* selected alias is persisted in SharedPreferences so the choice
* survives app restarts.
* 2. `fetchSecure` — a fetch shim built on `HttpsURLConnection` that
* injects the picked cert into the TLS handshake. JS-side `fetch`
* can't present a client cert because RN's bundled OkHttp client
* doesn't expose its KeyManager hook to userland; this method is
* the workaround.
*/
class BulwarkClientCertModule(reactContext: ReactApplicationContext)
: ReactContextBaseJavaModule(reactContext) {
private val prefs: SharedPreferences =
reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
@Volatile private var cachedAlias: String? = prefs.getString(PREF_ALIAS, null)
@Volatile private var cachedFactory: SSLSocketFactory? = null
@Volatile private var cachedFactoryForAlias: String? = null
override fun getName(): String = MODULE_NAME
// ── alias management ────────────────────────────────────────
@ReactMethod
fun getAlias(promise: Promise) {
promise.resolve(cachedAlias)
}
@ReactMethod
fun clearAlias(promise: Promise) {
prefs.edit().remove(PREF_ALIAS).apply()
cachedAlias = null
invalidateFactory()
promise.resolve(null)
}
/**
* Show the system "Choose a certificate" picker. Resolves with the
* selected alias, or `null` when the user dismisses the picker.
*/
@ReactMethod
fun pickAlias(host: String?, promise: Promise) {
// ReactContextBaseJavaModule no longer exposes a Kotlin synthetic
// `currentActivity` property in RN 0.80+ (compiles as an unresolved
// reference). The explicit Java-style getter on the application
// context is the supported call site.
val activity: android.app.Activity? = reactApplicationContext.getCurrentActivity()
if (activity == null) {
promise.reject("no_activity", "No current activity to host the cert picker")
return
}
val callback = KeyChainAliasCallback { alias ->
// Callback runs off the main thread; persist + resolve directly.
if (alias.isNullOrBlank()) {
promise.resolve(null)
return@KeyChainAliasCallback
}
prefs.edit().putString(PREF_ALIAS, alias).apply()
cachedAlias = alias
invalidateFactory()
promise.resolve(alias)
}
try {
KeyChain.choosePrivateKeyAlias(
activity,
callback,
/* keyTypes */ arrayOf("RSA", "EC"),
/* issuers */ null,
/* host */ host,
/* port */ -1,
/* alias */ cachedAlias,
)
} catch (e: Exception) {
promise.reject("pick_failed", e.message, e)
}
}
// ── secure fetch ────────────────────────────────────────────
/**
* Perform an HTTP request that presents the persisted client cert (if
* any) during the TLS handshake.
*
* @param request {url, method, headers: {k:v}, bodyBase64: string|null, timeoutMs: number}
*
* Resolves with `{status, statusText, headers, bodyBase64}` so JS can
* reconstruct a standard `Response`-like object.
*/
@ReactMethod
fun fetchSecure(request: ReadableMap, promise: Promise) {
val url = request.getString("url")
if (url.isNullOrBlank()) {
promise.reject("bad_args", "url is required")
return
}
val method = (request.getString("method") ?: "GET").uppercase()
val timeoutMs = if (request.hasKey("timeoutMs")) request.getInt("timeoutMs") else 30_000
val headersMap = request.getMap("headers")
val bodyBase64 = request.getString("bodyBase64")
val bodyBytes = bodyBase64?.let { Base64.decode(it, Base64.NO_WRAP) }
thread(name = "BulwarkClientCert.fetch") {
try {
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = timeoutMs
readTimeout = timeoutMs
requestMethod = method
instanceFollowRedirects = true
if (bodyBytes != null) {
doOutput = true
setFixedLengthStreamingMode(bodyBytes.size)
}
}
if (conn is HttpsURLConnection && cachedAlias != null) {
conn.sslSocketFactory = buildOrReuseSocketFactory()
}
headersMap?.let { h ->
val it = h.keySetIterator()
while (it.hasNextKey()) {
val k = it.nextKey()
val v = h.getString(k)
if (v != null) conn.setRequestProperty(k, v)
}
}
conn.connect()
if (bodyBytes != null) {
conn.outputStream.use { out -> out.write(bodyBytes) }
}
val status = conn.responseCode
val statusText = conn.responseMessage ?: ""
val responseStream = try {
conn.inputStream
} catch (_: Exception) {
conn.errorStream
}
val responseBytes = if (responseStream != null) {
val buf = ByteArrayOutputStream()
responseStream.copyTo(buf)
responseStream.close()
buf.toByteArray()
} else {
ByteArray(0)
}
val out = Arguments.createMap()
out.putInt("status", status)
out.putString("statusText", statusText)
val headers = Arguments.createMap()
// headerFields is a `Map<String?, List<String>>`; the null
// key is the HTTP status line, which we don't need to copy.
for ((name, values) in conn.headerFields) {
if (name == null || values == null) continue
headers.putString(name, values.joinToString(", "))
}
out.putMap("headers", headers)
out.putString("bodyBase64", Base64.encodeToString(responseBytes, Base64.NO_WRAP))
conn.disconnect()
promise.resolve(out)
} catch (e: Exception) {
promise.reject("fetch_failed", e.message ?: e.javaClass.simpleName, e)
}
}
}
// ── internals ───────────────────────────────────────────────
private fun invalidateFactory() {
cachedFactory = null
cachedFactoryForAlias = null
}
private fun buildOrReuseSocketFactory(): SSLSocketFactory {
val alias = cachedAlias ?: throw IllegalStateException("No client-cert alias selected")
val existing = cachedFactory
if (existing != null && cachedFactoryForAlias == alias) return existing
val ctx = reactApplicationContext
val km = KeyChainKeyManager(ctx, alias)
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(null as java.security.KeyStore?)
val trustManagers = tmf.trustManagers
val tm = trustManagers.firstOrNull { it is X509TrustManager } as X509TrustManager?
?: throw IllegalStateException("No X509TrustManager available")
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(arrayOf(km), arrayOf<TrustManager>(tm), null)
val factory = sslContext.socketFactory
cachedFactory = factory
cachedFactoryForAlias = alias
return factory
}
/**
* X509ExtendedKeyManager that always returns the same KeyChain alias.
* KeyChain.getPrivateKey / getCertificateChain are blocking; we call
* them lazily during the TLS handshake, which is fine since the
* fetch already runs off-main-thread.
*/
private class KeyChainKeyManager(
private val context: Context,
private val alias: String,
) : X509ExtendedKeyManager() {
override fun chooseClientAlias(
keyTypes: Array<out String>?,
issuers: Array<out Principal>?,
socket: Socket?,
): String = alias
override fun chooseEngineClientAlias(
keyType: Array<out String>?,
issuers: Array<out Principal>?,
engine: SSLEngine?,
): String = alias
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
return try {
KeyChain.getCertificateChain(context, this.alias)
} catch (_: Exception) {
null
}
}
override fun getPrivateKey(alias: String?): PrivateKey? {
return try {
KeyChain.getPrivateKey(context, this.alias)
} catch (_: Exception) {
null
}
}
override fun getClientAliases(keyType: String?, issuers: Array<out Principal>?): Array<String> =
arrayOf(alias)
override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String> =
emptyArray()
override fun chooseServerAlias(
keyType: String?,
issuers: Array<out Principal>?,
socket: Socket?,
): String? = null
}
companion object {
const val MODULE_NAME = "BulwarkClientCert"
private const val PREFS_NAME = "bulwark_client_cert"
private const val PREF_ALIAS = "alias"
}
}
@@ -7,7 +7,10 @@ import com.facebook.react.uimanager.ViewManager
class BulwarkFcmPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
listOf(BulwarkFcmModule(reactContext))
listOf(
BulwarkFcmModule(reactContext),
BulwarkClientCertModule(reactContext),
)
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
emptyList()
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.18",
"version": "0.1.22",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.18",
"version": "0.1.22",
"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.18",
"version": "0.1.22",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+2 -1
View File
@@ -1,5 +1,6 @@
import { File } from 'expo-file-system';
import { jmapClient } from './jmap-client';
import { secureFetch } from '../lib/client-cert';
export async function uploadBlob(
uri: string,
@@ -20,7 +21,7 @@ export async function uploadBlob(
// empty/garbage upload.
const bytes = await new File(uri).bytes();
const response = await fetch(uploadUrl, {
const response = await secureFetch(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': type || 'application/octet-stream',
+20
View File
@@ -135,6 +135,26 @@ export async function getFullEmail(id: string): Promise<Email> {
return email;
}
// Batch variant for offline sync. JMAP servers cap how many objects can be
// returned in a single Email/get; the caller should chunk to that ceiling
// (the client exposes maxObjectsInGet via getMaxObjectsInGet()).
export async function getFullEmails(ids: string[]): Promise<Email[]> {
if (ids.length === 0) return [];
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Email/get', {
accountId,
ids,
properties: EMAIL_FULL_PROPERTIES,
fetchHTMLBodyValues: true,
fetchTextBodyValues: true,
fetchAllBodyValues: true,
maxBodyValueBytes: 512000,
}, '0'],
]);
return res.methodResponses[0][1].list as Email[];
}
export async function getThread(threadId: string): Promise<Thread> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
+39 -9
View File
@@ -7,6 +7,7 @@ import type {
} from './types';
import { CAPABILITIES } from './types';
import { generateAccountId } from '../lib/account-utils';
import { secureFetch } from '../lib/client-cert';
const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
const CREDENTIALS_PREFIX = 'jmap_credentials__';
@@ -117,18 +118,38 @@ export class JMAPClient {
const stored = await SecureStore.getItemAsync(credentialsKey(accountId));
if (!stored) return false;
let creds: StoredCredentials;
try {
creds = JSON.parse(stored);
} catch {
// Corrupt entry — credentials can't be used. Caller should evict.
return false;
}
// Errors past this point propagate so callers can distinguish
// unrecoverable (AuthenticationError) from transient (NetworkError) and
// avoid clearing credentials when the server is just unreachable.
this.credentials = creds;
try {
const creds: StoredCredentials = JSON.parse(stored);
this.credentials = creds;
this.session = this.rewriteSessionUrls(
await this.fetchSession(creds.serverUrl),
creds.serverUrl,
);
this._accountId = this.resolveAccountId(this.session);
return true;
} catch {
return false;
} catch (err) {
// Don't leave a half-populated client behind; the caller needs to know
// the session is unavailable. Credentials stay in memory so a retry
// after the network comes back doesn't need a re-login.
this.session = null;
this._accountId = null;
if (err instanceof AuthenticationError) throw err;
// Anything else is treated as transport-level. Wrapping rather than
// rethrowing the raw fetch error gives callers a single check.
throw new NetworkError(
err instanceof Error ? err.message : 'Server unreachable',
);
}
this._accountId = this.resolveAccountId(this.session);
return true;
}
async clearAccountCredentials(accountId: string): Promise<void> {
@@ -214,7 +235,7 @@ export class JMAPClient {
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
const url = `${baseUrl}/.well-known/jmap`;
const response = await fetch(url, {
const response = await secureFetch(url, {
headers: {
Authorization: this.authHeader,
Accept: 'application/json',
@@ -259,7 +280,7 @@ export class JMAPClient {
methodCalls,
};
const response = await fetch(this.session.apiUrl, {
const response = await secureFetch(this.session.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -314,7 +335,7 @@ export class JMAPClient {
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
const url = this.getBlobDownloadUrl(blobId, name, type);
const response = await fetch(url, {
const response = await secureFetch(url, {
headers: { Authorization: this.authHeader },
});
if (response.status === 401) throw new AuthenticationError('Session expired');
@@ -332,6 +353,15 @@ export class AuthenticationError extends Error {
}
}
// Server unreachable / transient transport failure. Callers should keep
// stored credentials and surface an offline state rather than logging out.
export class NetworkError extends Error {
constructor(message: string) {
super(message);
this.name = 'NetworkError';
}
}
export class RateLimitError extends Error {
retryAfterMs: number;
constructor(retryAfterMs: number) {
+132
View File
@@ -0,0 +1,132 @@
// Live banner that mirrors the UpdateBanner's footprint, surfaced while the
// offline mail sync is running. Hidden once the sync settles to idle/done.
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { CloudDownload, X } from 'lucide-react-native';
import { useOfflineCacheStore } from '../stores/offline-cache-store';
import { formatBytes } from '../lib/offline-sync';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
export function OfflineCacheBanner(): React.ReactElement | null {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const sync = useOfflineCacheStore((s) => s.sync);
const requestAbort = useOfflineCacheStore((s) => s.requestAbort);
const resetSync = useOfflineCacheStore((s) => s.resetSync);
const [hideTimer, setHideTimer] = React.useState<ReturnType<typeof setTimeout> | null>(null);
// Auto-dismiss the "done" state after a few seconds so the bar isn't a
// permanent fixture; the Settings screen still shows the cache stats.
React.useEffect(() => {
if (sync.phase === 'done' || sync.phase === 'cancelled') {
const t = setTimeout(() => resetSync(), 4000);
setHideTimer(t);
return () => clearTimeout(t);
}
if (hideTimer) clearTimeout(hideTimer);
return undefined;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sync.phase, resetSync]);
if (sync.phase === 'idle') return null;
const pct =
sync.total > 0
? Math.min(100, Math.round((sync.completed / sync.total) * 100))
: sync.phase === 'done' ? 100 : 0;
let title = 'Offline sync';
let subtitle = '';
switch (sync.phase) {
case 'scanning':
title = 'Syncing offline mail';
subtitle = 'Scanning recent messages…';
break;
case 'fetching':
title = 'Syncing offline mail';
subtitle = `${sync.completed}/${sync.total} • ${formatBytes(sync.bytes)}`;
break;
case 'done':
title = 'Offline mail ready';
subtitle = sync.fetched > 0
? `${sync.fetched} new message${sync.fetched === 1 ? '' : 's'} cached • ${formatBytes(sync.bytes)}`
: 'Already up to date';
break;
case 'cancelled':
title = 'Sync cancelled';
subtitle = `${sync.completed}/${sync.total} processed`;
break;
case 'error':
title = 'Offline sync failed';
subtitle = sync.message ?? 'Unable to download';
break;
default:
return null;
}
const showCancel = sync.phase === 'scanning' || sync.phase === 'fetching';
const showDismiss = sync.phase === 'done' || sync.phase === 'cancelled' || sync.phase === 'error';
const isError = sync.phase === 'error';
return (
<View style={[styles.banner, isError && styles.bannerError]}>
<CloudDownload size={16} color={c.primaryForeground} />
<View style={{ flex: 1 }}>
<Text style={styles.title}>{title}</Text>
{!!subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
{(sync.phase === 'fetching' || sync.phase === 'scanning') && (
<View style={styles.progressTrack}>
<View style={[styles.progressFill, { width: `${pct}%` }]} />
</View>
)}
</View>
{showCancel && (
<Pressable style={styles.cancelButton} onPress={requestAbort} hitSlop={6}>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
)}
{showDismiss && (
<Pressable style={styles.dismiss} onPress={resetSync} hitSlop={8}>
<X size={14} color={c.primaryForeground} />
</Pressable>
)}
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
banner: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
backgroundColor: c.primary,
},
bannerError: { backgroundColor: c.error },
title: { ...typography.bodyMedium, color: c.primaryForeground },
subtitle: { ...typography.caption, color: c.primaryForeground, opacity: 0.85, marginTop: 2 },
progressTrack: {
marginTop: 6,
height: 3,
borderRadius: radius.full,
backgroundColor: 'rgba(255,255,255,0.25)',
overflow: 'hidden',
},
progressFill: {
height: '100%',
backgroundColor: c.primaryForeground,
},
cancelButton: {
paddingHorizontal: spacing.md,
paddingVertical: 6,
borderRadius: radius.sm,
backgroundColor: 'rgba(255,255,255,0.2)',
},
cancelText: { ...typography.captionMedium, color: c.primaryForeground },
dismiss: { padding: 4 },
});
}
+4 -2
View File
@@ -487,7 +487,9 @@ function makeStyles(c: ThemePalette) {
top: 0, bottom: 0, left: 0,
width: '85%',
maxWidth: 340,
backgroundColor: '#262626', // matches webmail bg-secondary (dark mode)
// Mirror webmail's bg-secondary in both palettes - was hardcoded to the
// dark-mode value, which left light mode unreadable (dark bg + dark text).
backgroundColor: c.secondary,
borderRightWidth: 1,
borderRightColor: c.border,
},
@@ -537,7 +539,7 @@ function makeStyles(c: ThemePalette) {
marginHorizontal: spacing.sm,
marginTop: spacing.xs,
marginBottom: spacing.sm,
backgroundColor: '#1f1f1f',
backgroundColor: c.popover,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
@@ -191,7 +191,7 @@ function makeStyles(c: ThemePalette) {
left: 0,
width: '85%',
maxWidth: 340,
backgroundColor: '#262626',
backgroundColor: c.secondary,
borderRightWidth: 1,
borderRightColor: c.border,
},
@@ -228,7 +228,7 @@ function makeStyles(c: ThemePalette) {
top: 0, bottom: 0, left: 0,
width: '85%',
maxWidth: 340,
backgroundColor: '#262626',
backgroundColor: c.secondary,
borderRightWidth: 1,
borderRightColor: c.border,
},
+149 -4
View File
@@ -1,16 +1,36 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, Linking, Alert } from 'react-native';
import { View, Text, StyleSheet, Pressable, Linking, Alert, ActivityIndicator } from 'react-native';
import Constants from 'expo-constants';
import { ExternalLink } from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { CloudDownload, ExternalLink } from 'lucide-react-native';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import { useOfflineCacheStore } from '../../stores/offline-cache-store';
import { runOfflineSync, formatBytes } from '../../lib/offline-sync';
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
const GIT_COMMIT = (Constants.expoConfig?.extra as { commit?: string } | undefined)?.commit ?? 'dev';
const DAY_OPTIONS = [
{ value: '1', label: 'Last 24 hours' },
{ value: '3', label: 'Last 3 days' },
{ value: '7', label: 'Last 7 days' },
{ value: '14', label: 'Last 14 days' },
{ value: '30', label: 'Last 30 days' },
{ value: '90', label: 'Last 90 days' },
];
function formatRelativeTime(ms: number | undefined): string {
if (!ms) return 'never';
const diff = Date.now() - ms;
if (diff < 60_000) return 'just now';
if (diff < 60 * 60_000) return `${Math.round(diff / 60_000)} min ago`;
if (diff < 24 * 60 * 60_000) return `${Math.round(diff / (60 * 60_000))} h ago`;
return `${Math.round(diff / (24 * 60 * 60_000))} d ago`;
}
const DEBUG_CATEGORIES = [
{ id: 'jmap', label: 'JMAP', description: 'JMAP request and response logs.' },
{ id: 'auth', label: 'Auth', description: 'Authentication and session events.' },
@@ -36,9 +56,36 @@ export function AboutDataSettings() {
const [syncEnabled, setSyncEnabled] = useState(true);
const [confirmReset, setConfirmReset] = useState(false);
const offlineEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
const offlineDays = useSettingsStore((s) => s.offlineCacheDays);
const updateSetting = useSettingsStore((s) => s.updateSetting);
const cacheCount = useOfflineCacheStore((s) => s.totalCount());
const cacheBytes = useOfflineCacheStore((s) => s.totalSize());
const cacheHydrated = useOfflineCacheStore((s) => s.hydrated);
const cacheHydrate = useOfflineCacheStore((s) => s.hydrate);
const sync = useOfflineCacheStore((s) => s.sync);
const clearAllCache = useOfflineCacheStore((s) => s.clearAll);
const syncBusy = sync.phase === 'scanning' || sync.phase === 'fetching';
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
if (!cacheHydrated) void cacheHydrate();
}, [hydrated, hydrate, cacheHydrated, cacheHydrate]);
const handleSyncNow = () => {
void runOfflineSync({ days: offlineDays });
};
const handleClearCache = () => {
Alert.alert(
'Clear offline mail',
`Remove ${cacheCount} cached message${cacheCount === 1 ? '' : 's'} (${formatBytes(cacheBytes)})?`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Clear', style: 'destructive', onPress: () => { void clearAllCache(); } },
],
);
};
const handleReset = () => {
resetExternalContentPolicy('ask');
@@ -72,6 +119,80 @@ export function AboutDataSettings() {
</View>
</View>
<SettingsSection
title="Offline mail"
description="Download recent messages so they open instantly and remain readable without a connection. Bodies only — attachments are still fetched on demand."
>
<SettingItem
label="Cache recent mail"
description="When on, the app keeps message bodies for the selected window on this device."
>
<ToggleSwitch
checked={offlineEnabled}
onChange={(v) => updateSetting('offlineCacheEnabled', v)}
/>
</SettingItem>
<SettingItem
label="Window"
description="How far back to cache, measured by message receipt date."
>
<Select
value={String(offlineDays)}
onChange={(v) => updateSetting('offlineCacheDays', Number(v))}
options={DAY_OPTIONS}
/>
</SettingItem>
<View style={styles.cacheStatsBox}>
<View style={styles.cacheStatsHeader}>
<CloudDownload size={16} color={c.textSecondary} />
<Text style={styles.cacheStatsTitle}>
{cacheCount === 0
? 'Nothing cached yet'
: `${cacheCount} message${cacheCount === 1 ? '' : 's'} • ${formatBytes(cacheBytes)}`}
</Text>
</View>
<Text style={styles.cacheStatsSub}>
{sync.phase === 'fetching'
? `Downloading ${sync.completed}/${sync.total}…`
: sync.phase === 'scanning'
? 'Scanning recent mail…'
: sync.phase === 'error'
? `Last sync failed: ${sync.message ?? 'unknown error'}`
: `Last sync ${formatRelativeTime(sync.finishedAt)}`}
</Text>
{syncBusy && sync.total > 0 && (
<View style={styles.progressTrack}>
<View
style={[
styles.progressFill,
{ width: `${Math.min(100, Math.round((sync.completed / sync.total) * 100))}%` },
]}
/>
</View>
)}
<View style={styles.cacheActions}>
<Button
variant="outline"
size="sm"
onPress={handleSyncNow}
disabled={!offlineEnabled || syncBusy}
icon={syncBusy ? <ActivityIndicator size="small" color={c.text} /> : undefined}
>
{syncBusy ? 'Syncing…' : 'Sync now'}
</Button>
<Button
variant="outline"
size="sm"
onPress={handleClearCache}
disabled={cacheCount === 0 || syncBusy}
>
Clear cache
</Button>
</View>
</View>
</SettingsSection>
<SettingsSection title="Advanced" description="Low-level options and troubleshooting.">
<SettingItem label="Debug mode" description="Enable verbose logging for support and development.">
<ToggleSwitch checked={debugMode} onChange={setDebugMode} />
@@ -170,5 +291,29 @@ function makeStyles(c: ThemePalette) {
color: c.mutedForeground,
marginBottom: spacing.sm,
},
cacheStatsBox: {
marginTop: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.muted,
gap: spacing.sm,
},
cacheStatsHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
cacheStatsTitle: { ...typography.bodyMedium, color: c.text, flex: 1 },
cacheStatsSub: { ...typography.caption, color: c.mutedForeground },
progressTrack: {
height: 4,
borderRadius: radius.full,
backgroundColor: c.border,
overflow: 'hidden',
},
progressFill: { height: '100%', backgroundColor: c.primary },
cacheActions: { flexDirection: 'row', gap: spacing.sm, marginTop: 4 },
});
}
@@ -1,11 +1,18 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { Key, Smartphone, Lock, Eye, EyeOff, Shield, Monitor, Trash2 } from 'lucide-react-native';
import React, { useEffect, useState } from 'react';
import { Alert, View, Text, StyleSheet, Pressable } from 'react-native';
import { Key, Smartphone, Lock, Eye, EyeOff, ShieldCheck, Monitor, Trash2 } from 'lucide-react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Input from '../Input';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { jmapClient } from '../../api/jmap-client';
import {
clearClientCertAlias,
getClientCertAlias,
isClientCertSupported,
pickClientCertAlias,
} from '../../lib/client-cert';
interface Session {
id: string;
@@ -30,8 +37,96 @@ export function AccountSecuritySettings() {
const [twoFactor, setTwoFactor] = useState(false);
const [appPwds] = useState<{ id: string; label: string; created: string }[]>([]);
const certSupported = isClientCertSupported();
const [certAlias, setCertAlias] = useState<string | null>(null);
const [certBusy, setCertBusy] = useState(false);
useEffect(() => {
if (!certSupported) return;
void getClientCertAlias().then(setCertAlias);
}, [certSupported]);
const onPickCert = async () => {
if (certBusy) return;
setCertBusy(true);
try {
// Hint the picker with the JMAP server host so it can pre-filter to certs
// issued for it when the OEM picker honours that.
const host = jmapClient.serverUrl ? new URL(jmapClient.serverUrl).hostname : null;
const alias = await pickClientCertAlias(host);
setCertAlias(alias);
} catch (err) {
Alert.alert('Pick failed', err instanceof Error ? err.message : String(err));
} finally {
setCertBusy(false);
}
};
const onClearCert = async () => {
if (certBusy) return;
Alert.alert(
'Stop using client certificate?',
'New requests will no longer present a certificate. The certificate stays installed in Android.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Clear',
style: 'destructive',
onPress: async () => {
setCertBusy(true);
try {
await clearClientCertAlias();
setCertAlias(null);
} finally {
setCertBusy(false);
}
},
},
],
);
};
return (
<View style={styles.container}>
{certSupported && (
<SettingsSection
title="TLS client certificate"
description="Picks an installed Android certificate to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS."
>
<View style={styles.certRow}>
<ShieldCheck size={18} color={certAlias ? c.success : c.mutedForeground} />
<View style={{ flex: 1 }}>
<Text style={styles.certStatus}>
{certAlias ? `Active: ${certAlias}` : 'Not set'}
</Text>
<Text style={styles.certHint}>
Install the certificate via Android Settings → Security → Encryption &amp; credentials, then pick it here.
</Text>
</View>
</View>
<View style={styles.certActions}>
<Button
variant="outline"
size="sm"
disabled={certBusy}
onPress={() => { void onPickCert(); }}
>
{certAlias ? 'Choose another' : 'Pick certificate'}
</Button>
{certAlias && (
<Button
variant="outline"
size="sm"
disabled={certBusy}
onPress={() => { void onClearCert(); }}
>
Clear
</Button>
)}
</View>
</SettingsSection>
)}
<SettingsSection title="Password" description="Change the password for this account.">
<View style={{ gap: spacing.md }}>
<View style={styles.pwField}>
@@ -196,5 +291,14 @@ function makeStyles(c: ThemePalette) {
justifyContent: 'center',
borderRadius: radius.sm,
},
certRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.md,
paddingVertical: spacing.md,
},
certStatus: { ...typography.bodyMedium, color: c.text },
certHint: { ...typography.caption, color: c.mutedForeground, marginTop: 4 },
certActions: { flexDirection: 'row', gap: spacing.sm, flexWrap: 'wrap' },
});
}
+185
View File
@@ -0,0 +1,185 @@
// Bridge to the native BulwarkClientCert module. Wraps the alias pick / get /
// clear flow and exposes a `secureFetch` that mirrors the global `fetch` API
// closely enough that callers can swap them out.
//
// On non-Android platforms (or when the user has not picked a cert) we fall
// straight back to the global `fetch` so we don't pay any bridge overhead.
type Native = {
getAlias(): Promise<string | null>;
pickAlias(host: string | null): Promise<string | null>;
clearAlias(): Promise<void>;
fetchSecure(request: {
url: string;
method: string;
headers: Record<string, string>;
bodyBase64: string | null;
timeoutMs: number;
}): Promise<{
status: number;
statusText: string;
headers: Record<string, string>;
bodyBase64: string;
}>;
};
// Lazy-loaded so unit tests (which run in plain Node) don't have to parse the
// `react-native` entry point. We resolve the module on first access; the
// result is cached by the require cache.
let nativeProbed = false;
let nativeModule: Native | null = null;
function getNative(): Native | null {
if (nativeProbed) return nativeModule;
nativeProbed = true;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const rn = require('react-native') as {
Platform: { OS: string };
NativeModules: Record<string, unknown>;
};
if (rn.Platform.OS !== 'android') return (nativeModule = null);
nativeModule = (rn.NativeModules.BulwarkClientCert as Native | undefined) ?? null;
} catch {
nativeModule = null;
}
return nativeModule;
}
// Cached so the hot path can short-circuit without a bridge round-trip.
let cachedAlias: string | null | undefined;
export function isClientCertSupported(): boolean {
return getNative() != null;
}
export async function getClientCertAlias(): Promise<string | null> {
const native = getNative();
if (!native) return null;
if (cachedAlias !== undefined) return cachedAlias;
cachedAlias = await native.getAlias();
return cachedAlias;
}
export async function pickClientCertAlias(host: string | null): Promise<string | null> {
const native = getNative();
if (!native) throw new Error('Client certificates require Android');
const alias = await native.pickAlias(host);
cachedAlias = alias;
return alias;
}
export async function clearClientCertAlias(): Promise<void> {
const native = getNative();
if (!native) return;
await native.clearAlias();
cachedAlias = null;
}
// ── secureFetch ───────────────────────────────────────────────
function bytesToBase64(bytes: Uint8Array): string {
let bin = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
bin += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)));
}
return globalThis.btoa ? globalThis.btoa(bin) : btoa(bin);
}
function base64ToBytes(b64: string): Uint8Array {
const bin = globalThis.atob ? globalThis.atob(b64) : atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function bodyToBase64(body: BodyInit | null | undefined): Promise<string | null> {
if (body == null) return null;
if (typeof body === 'string') {
// UTF-8 encode then base64.
const encoder = new TextEncoder();
return bytesToBase64(encoder.encode(body));
}
if (body instanceof ArrayBuffer) {
return bytesToBase64(new Uint8Array(body));
}
if (ArrayBuffer.isView(body)) {
const view = body as ArrayBufferView;
return bytesToBase64(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
}
// Blob / FormData / URLSearchParams / ReadableStream are not used in the
// current code paths. If they appear, the caller should pre-serialize.
throw new Error(`secureFetch: unsupported body type ${(body as object).constructor?.name}`);
}
function flattenHeaders(input: HeadersInit | undefined): Record<string, string> {
const out: Record<string, string> = {};
if (!input) return out;
if (Array.isArray(input)) {
for (const [k, v] of input) out[k] = v;
return out;
}
if (typeof Headers !== 'undefined' && input instanceof Headers) {
input.forEach((v, k) => { out[k] = v; });
return out;
}
return { ...(input as Record<string, string>) };
}
function buildResponse(raw: {
status: number;
statusText: string;
headers: Record<string, string>;
bodyBase64: string;
}): Response {
const bytes = raw.bodyBase64 ? base64ToBytes(raw.bodyBase64) : new Uint8Array(0);
const blob = new Blob([bytes as unknown as BlobPart]);
return new Response(blob, {
status: raw.status,
statusText: raw.statusText,
headers: raw.headers,
});
}
/**
* Drop-in replacement for `fetch` that routes through the native client when
* the user has selected a client certificate. When no cert is set (or we're
* on iOS), it just calls the platform `fetch` and the caller pays no
* bridge overhead.
*/
export async function secureFetch(
url: string,
init?: RequestInit,
): Promise<Response> {
const native = getNative();
if (!native) return fetch(url, init);
const alias = await getClientCertAlias();
if (!alias) return fetch(url, init);
const method = init?.method ?? 'GET';
const headers = flattenHeaders(init?.headers);
const bodyBase64 = await bodyToBase64(init?.body);
// Force Content-Length to come from the body bytes - some servers (Stalwart
// behind nginx in particular) reject chunked uploads when an explicit
// length isn't provided alongside the bytes.
if (bodyBase64 != null) {
const decoded = base64ToBytes(bodyBase64);
headers['Content-Length'] = String(decoded.byteLength);
}
try {
const raw = await native.fetchSecure({
url,
method,
headers,
bodyBase64,
timeoutMs: 30_000,
});
return buildResponse(raw);
} catch (err) {
// Surface as a TypeError to mimic fetch's network-error contract; keep
// the original message for diagnostics.
const msg = err instanceof Error ? err.message : String(err);
throw new TypeError(`secureFetch failed: ${msg}`);
}
}
+46 -25
View File
@@ -1,8 +1,8 @@
import { Platform } from 'react-native';
import { File, Paths } from 'expo-file-system';
import { Directory, File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import { jmapClient } from '../api/jmap-client';
import { getDownloadUrl } from '../api/blob';
import { getClientCertAlias, secureFetch } from './client-cert';
const RFC822 = 'message/rfc822';
@@ -17,13 +17,43 @@ function safeAttachmentName(name: string | undefined, type: string | undefined):
return `attachment.${fallbackExt}`;
}
// On Android, sharing a `file://` URI from the cache directory throws a
// FileUriExposedException on modern OS versions; the system requires a
// FileProvider-backed `content://` URI instead. expo-file-system exposes that
// via `file.contentUri`, which is the only field iOS doesn't expose.
function shareableUri(file: { uri: string; contentUri?: string }): string {
if (Platform.OS === 'android' && file.contentUri) return file.contentUri;
return file.uri;
// expo-sharing only accepts `file://` URLs and rejects `content://` with
// "Only local file URLs are supported". On Android it then wraps the file
// itself with its bundled SharingFileProvider before launching the share
// intent, so we must NOT pre-translate to `file.contentUri`.
// Routes the download via the client-cert-aware native module when the user
// has picked a cert, and via expo-file-system's native streaming downloader
// otherwise. The streaming path scales to large attachments without buffering
// in JS, so we keep using it as the default.
async function downloadInto(
url: string,
dest: File,
parent: Directory,
): Promise<File> {
const alias = await getClientCertAlias();
if (!alias) {
// The static returns a separately-typed `FileSystemFile`; we already
// have a fully-typed `File` referencing the same uri, so we ignore the
// return value and re-use our `dest` reference for downstream code.
await File.downloadFileAsync(url, dest, {
headers: { Authorization: authHeader() },
idempotent: true,
});
return dest;
}
const response = await secureFetch(url, {
headers: { Authorization: authHeader() },
});
if (!response.ok) {
throw new Error(`Download failed: ${response.status}`);
}
if (!parent.exists) parent.create({ intermediates: true, idempotent: true });
if (dest.exists) dest.delete();
const buffer = await response.arrayBuffer();
dest.create();
dest.write(new Uint8Array(buffer));
return dest;
}
export async function shareAttachment(
@@ -35,14 +65,11 @@ export async function shareAttachment(
const mimeType = type || 'application/octet-stream';
const dest = new File(Paths.cache, filename);
const url = getDownloadUrl(blobId, filename, mimeType);
const downloaded = await File.downloadFileAsync(url, dest, {
headers: { Authorization: authHeader() },
idempotent: true,
});
const downloaded = await downloadInto(url, dest, Paths.cache);
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
await Sharing.shareAsync(shareableUri(downloaded), {
await Sharing.shareAsync(downloaded.uri, {
mimeType,
dialogTitle: filename,
});
@@ -62,14 +89,11 @@ export async function downloadAttachment(
const mimeType = type || 'application/octet-stream';
const dest = new File(Paths.document, filename);
const url = getDownloadUrl(blobId, filename, mimeType);
const downloaded = await File.downloadFileAsync(url, dest, {
headers: { Authorization: authHeader() },
idempotent: true,
});
const downloaded = await downloadInto(url, dest, Paths.document);
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
await Sharing.shareAsync(shareableUri(downloaded), {
await Sharing.shareAsync(downloaded.uri, {
mimeType,
dialogTitle: filename,
});
@@ -77,7 +101,7 @@ export async function downloadAttachment(
export async function fetchRawEmail(blobId: string): Promise<string> {
const url = getDownloadUrl(blobId, 'email.eml', RFC822);
const r = await fetch(url, { headers: { Authorization: authHeader() } });
const r = await secureFetch(url, { headers: { Authorization: authHeader() } });
if (!r.ok) throw new Error(`Download failed: ${r.status}`);
return r.text();
}
@@ -90,14 +114,11 @@ function safeFilename(subject: string | undefined): string {
export async function shareEmailEml(blobId: string, subject?: string): Promise<void> {
const dest = new File(Paths.cache, safeFilename(subject));
const url = getDownloadUrl(blobId, dest.name, RFC822);
const downloaded = await File.downloadFileAsync(url, dest, {
headers: { Authorization: authHeader() },
idempotent: true,
});
const downloaded = await downloadInto(url, dest, Paths.cache);
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
await Sharing.shareAsync(shareableUri(downloaded), {
await Sharing.shareAsync(downloaded.uri, {
mimeType: RFC822,
dialogTitle: 'Share email',
UTI: 'public.email-message',
+148
View File
@@ -0,0 +1,148 @@
// Background sync that fills the offline mail cache. Runs the discovery
// query (Email/query filtered by `after`), then fetches full bodies in
// batches, reporting progress to the offline-cache-store so the
// OfflineCacheBanner and the Settings screen can show live updates.
import { jmapClient } from '../api/jmap-client';
import { queryEmailsByFilter, getFullEmails } from '../api/email';
import { useOfflineCacheStore } from '../stores/offline-cache-store';
import type { Email } from '../api/types';
// Approximate the on-disk size of a serialised Email so we can show "Y MB
// downloaded" without measuring AsyncStorage usage. Body values dominate; the
// envelope is small. Using JSON.stringify().length is correct in code-units
// not bytes, but it's close enough for a UI-facing estimate.
function approxSize(email: Email): number {
try {
return JSON.stringify(email).length;
} catch {
return 0;
}
}
// Hard cap so a misconfigured "30 days" against a noisy account doesn't try
// to enumerate 100k messages.
const DISCOVERY_LIMIT = 5000;
// Each Email/get request is bounded by the server's maxObjectsInGet. We
// further chunk to keep individual responses small (a 50-message response
// with full bodies + body values is already several MB).
const FETCH_CHUNK_FALLBACK = 25;
export interface RunOptions {
days: number;
}
export async function runOfflineSync(opts: RunOptions): Promise<void> {
const cache = useOfflineCacheStore.getState();
// If a sync is in flight, request its abort and wait for the next caller
// to start fresh. We don't queue — the most recent intent wins.
if (cache.sync.phase === 'scanning' || cache.sync.phase === 'fetching') {
cache.requestAbort();
return;
}
if (!cache.hydrated) await cache.hydrate();
cache.resetSync();
const startedAt = Date.now();
cache.setSyncState({ phase: 'scanning', startedAt });
const since = new Date(startedAt - opts.days * 24 * 60 * 60 * 1000).toISOString();
let ids: string[] = [];
try {
ids = await queryEmailsByFilter({ after: since }, DISCOVERY_LIMIT);
} catch (err) {
cache.setSyncState({
phase: 'error',
message: err instanceof Error ? err.message : 'Discovery query failed',
finishedAt: Date.now(),
});
return;
}
// Drop entries from the cache that fell out of the lookback window — the
// user expects the cache to track "the last X days", not grow forever.
const keepSet = new Set(ids);
const stale = Object.keys(cache.index.entries).filter((id) => !keepSet.has(id));
if (stale.length > 0) {
await cache.remove(stale);
}
const total = ids.length;
cache.setSyncState({ phase: 'fetching', total, completed: 0, fetched: 0, bytes: 0 });
// Skip already-cached ids — bodies on disk are immutable per messageId.
const toFetch = ids.filter((id) => !cache.has(id));
const skipped = total - toFetch.length;
cache.setSyncState({ completed: skipped });
if (toFetch.length === 0) {
cache.setSyncState({
phase: 'done',
finishedAt: Date.now(),
});
return;
}
const chunkSize = Math.min(
FETCH_CHUNK_FALLBACK,
Math.max(1, jmapClient.getMaxObjectsInGet()),
);
let fetched = 0;
let bytes = 0;
let completed = skipped;
for (let i = 0; i < toFetch.length; i += chunkSize) {
if (useOfflineCacheStore.getState().consumeAbort()) {
cache.setSyncState({
phase: 'cancelled',
finishedAt: Date.now(),
});
return;
}
const chunk = toFetch.slice(i, i + chunkSize);
let emails: Email[];
try {
emails = await getFullEmails(chunk);
} catch (err) {
cache.setSyncState({
phase: 'error',
message: err instanceof Error ? err.message : 'Fetch failed',
finishedAt: Date.now(),
});
return;
}
for (const email of emails) {
const size = approxSize(email);
await cache.put(email, size);
fetched += 1;
bytes += size;
completed += 1;
}
// Some chunked ids may have been deleted server-side between query and
// fetch — they don't come back. Account for that in the completed count
// so the progress bar still finishes at 100%.
completed += chunk.length - emails.length;
cache.setSyncState({ completed, fetched, bytes });
}
cache.setSyncState({
phase: 'done',
completed: total,
fetched,
bytes,
finishedAt: Date.now(),
});
}
export function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
+95 -7
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import { jmapClient, AuthenticationError } from '../api/jmap-client';
import { jmapClient, AuthenticationError, NetworkError } from '../api/jmap-client';
import type { JMAPSession } from '../api/types';
import { useAccountStore } from './account-store';
import { useEmailStore } from './email-store';
@@ -39,6 +39,7 @@ export interface AuthState {
logoutAll: () => Promise<void>;
switchAccount: (accountId: string) => Promise<void>;
restoreSession: () => Promise<boolean>;
retrySession: () => Promise<boolean>;
clearError: () => void;
}
@@ -286,15 +287,56 @@ export const useAuthStore = create<AuthState>((set, get) => ({
return false;
}
const ok = await jmapClient.loadAccount(target.id);
if (!ok) {
accountStore.removeAccount(target.id);
set({ isLoading: false, hasRestoredSession: true });
return false;
try {
const ok = await jmapClient.loadAccount(target.id);
if (!ok) {
// No stored credentials (or corrupt) — genuine logout.
accountStore.removeAccount(target.id);
set({ isLoading: false, hasRestoredSession: true });
return false;
}
} catch (err) {
if (err instanceof NetworkError) {
// Server unreachable. Keep credentials, mark account offline, and
// surface the cached UI so the user can still browse persisted
// mail / contacts / calendar. The login screen would lose their
// settings without recourse, which is the bug we're fixing here.
accountStore.setActiveAccount(target.id);
accountStore.updateAccount(target.id, {
isConnected: false,
hasError: true,
errorMessage: err.message,
});
set({
isAuthenticated: true,
isLoading: false,
hasRestoredSession: true,
error: null,
serverUrl: target.serverUrl,
username: target.username,
session: null,
accountId: null,
activeAccountId: target.id,
client: jmapClient,
});
return true;
}
if (err instanceof AuthenticationError) {
// Server reachable but credentials rejected — drop them.
await jmapClient.clearAccountCredentials(target.id).catch(() => undefined);
accountStore.removeAccount(target.id);
set({ isLoading: false, hasRestoredSession: true, error: 'Session expired' });
return false;
}
throw err;
}
accountStore.setActiveAccount(target.id);
accountStore.updateAccount(target.id, { isConnected: true, hasError: false });
accountStore.updateAccount(target.id, {
isConnected: true,
hasError: false,
errorMessage: undefined,
});
const session = jmapClient.currentSession!;
applyConnectedState(set, session, target.serverUrl, target.username, target.id);
@@ -309,5 +351,51 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
},
// Re-attempt session establishment for the currently active account
// without disturbing UI state on failure. Used by the network-recovery
// watcher and any explicit "retry" button. Idempotent: returns true if
// a session is already live.
retrySession: async () => {
const { activeAccountId, session } = get();
if (!activeAccountId) return false;
if (session) return true;
const accountStore = useAccountStore.getState();
const target = accountStore.getAccountById(activeAccountId);
if (!target) return false;
try {
const ok = await jmapClient.loadAccount(activeAccountId);
if (!ok) return false;
accountStore.updateAccount(activeAccountId, {
isConnected: true,
hasError: false,
errorMessage: undefined,
});
const fresh = jmapClient.currentSession!;
applyConnectedState(set, fresh, target.serverUrl, target.username, activeAccountId);
refetchFeatureStores();
return true;
} catch (err) {
if (err instanceof AuthenticationError) {
// Now we know the credentials are bad — fall back to logout flow.
await jmapClient.clearAccountCredentials(activeAccountId).catch(() => undefined);
accountStore.removeAccount(activeAccountId);
set({
isAuthenticated: false,
isLoading: false,
hasRestoredSession: true,
error: 'Session expired',
serverUrl: null,
username: null,
session: null,
accountId: null,
activeAccountId: null,
client: null,
});
}
// NetworkError or anything else: stay where we are.
return false;
}
},
clearError: () => set({ error: null }),
}));
+22 -1
View File
@@ -16,6 +16,7 @@ import {
} from '../api/email';
import { toWildcardQuery } from '../lib/search-utils';
import { useSettingsStore } from './settings-store';
import { useOfflineCacheStore } from './offline-cache-store';
export interface EmailFilters {
from?: string;
@@ -217,7 +218,27 @@ export const useEmailStore = create<EmailState>()(
},
getEmailDetail: async (id) => {
return getFullEmail(id);
// Try the network first so the user sees fresh keywords/flags. If that
// fails (offline / server unreachable), fall back to the offline cache
// when the message is in it. Without the cache hit, propagate the error
// so the caller can surface it.
try {
const fresh = await getFullEmail(id);
// Opportunistically refresh the cached copy so the next offline open
// reflects the latest keywords without needing a full sync.
const cache = useOfflineCacheStore.getState();
if (cache.has(id)) {
try {
const size = JSON.stringify(fresh).length;
await cache.put(fresh, size);
} catch { /* ignore — best-effort refresh */ }
}
return fresh;
} catch (err) {
const cached = await useOfflineCacheStore.getState().get(id);
if (cached) return cached;
throw err;
}
},
markRead: async (emailId) => {
+176
View File
@@ -0,0 +1,176 @@
// Persistent cache for full email bodies (and optional attachment blobs)
// powering offline mail viewing. Bodies are JSON-serialised and kept in
// AsyncStorage; attachment blobs go to expo-file-system under
// `Paths.document/offline-attachments/`. The store also exposes the live
// progress state the OfflineCacheBanner reads from.
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Email } from '../api/types';
const INDEX_KEY = 'webmail:offline-cache:index:v1';
const ENTRY_KEY_PREFIX = 'webmail:offline-cache:entry:v1:';
function entryKey(id: string): string {
return `${ENTRY_KEY_PREFIX}${id}`;
}
export interface OfflineCacheIndexEntry {
id: string;
receivedAt: string;
size: number; // approx bytes of cached payload
cachedAt: number;
}
interface CacheIndex {
// Per-account is keyed at the storage layer (one app session = one account
// active at a time). Cross-account separation is left to a future change.
entries: Record<string, OfflineCacheIndexEntry>;
}
export type SyncPhase = 'idle' | 'scanning' | 'fetching' | 'done' | 'error' | 'cancelled';
export interface SyncState {
phase: SyncPhase;
total: number; // total emails the sync expects to process
completed: number; // emails processed (skipped or fetched)
fetched: number; // emails actually downloaded (not already cached)
bytes: number; // bytes downloaded this run
message?: string; // human-readable status (errors etc.)
startedAt?: number;
finishedAt?: number;
}
const IDLE: SyncState = { phase: 'idle', total: 0, completed: 0, fetched: 0, bytes: 0 };
interface OfflineCacheState {
index: CacheIndex;
hydrated: boolean;
sync: SyncState;
// Allows the sync orchestrator to abort mid-run when the user disables the
// setting or kicks off a fresh sync. Stored on the singleton because
// AbortControllers need to survive across the function boundary.
abortRequested: boolean;
hydrate: () => Promise<void>;
has: (id: string) => boolean;
get: (id: string) => Promise<Email | null>;
put: (email: Email, approxBytes: number) => Promise<void>;
remove: (ids: string[]) => Promise<void>;
clearAll: () => Promise<void>;
totalSize: () => number;
totalCount: () => number;
setSyncState: (next: Partial<SyncState>) => void;
resetSync: () => void;
requestAbort: () => void;
consumeAbort: () => boolean;
}
function persistIndex(index: CacheIndex): void {
void AsyncStorage.setItem(INDEX_KEY, JSON.stringify(index)).catch((err) => {
console.warn('[offline-cache] persist index failed', err);
});
}
export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
index: { entries: {} },
hydrated: false,
sync: { ...IDLE },
abortRequested: false,
hydrate: async () => {
if (get().hydrated) return;
try {
const raw = await AsyncStorage.getItem(INDEX_KEY);
if (raw) {
const parsed = JSON.parse(raw) as CacheIndex;
if (parsed && typeof parsed === 'object' && parsed.entries) {
set({ index: parsed, hydrated: true });
return;
}
}
} catch (err) {
console.warn('[offline-cache] hydrate failed', err);
}
set({ hydrated: true });
},
has: (id) => Boolean(get().index.entries[id]),
get: async (id) => {
if (!get().index.entries[id]) return null;
try {
const raw = await AsyncStorage.getItem(entryKey(id));
if (!raw) return null;
return JSON.parse(raw) as Email;
} catch (err) {
console.warn('[offline-cache] get failed', id, err);
return null;
}
},
put: async (email, approxBytes) => {
try {
await AsyncStorage.setItem(entryKey(email.id), JSON.stringify(email));
} catch (err) {
console.warn('[offline-cache] put failed', email.id, err);
return;
}
const nextEntries = {
...get().index.entries,
[email.id]: {
id: email.id,
receivedAt: email.receivedAt,
size: approxBytes,
cachedAt: Date.now(),
},
};
const index = { entries: nextEntries };
set({ index });
persistIndex(index);
},
remove: async (ids) => {
if (ids.length === 0) return;
await Promise.all(
ids.map((id) => AsyncStorage.removeItem(entryKey(id)).catch(() => undefined)),
);
const next = { ...get().index.entries };
for (const id of ids) delete next[id];
const index = { entries: next };
set({ index });
persistIndex(index);
},
clearAll: async () => {
const ids = Object.keys(get().index.entries);
await Promise.all(
ids.map((id) => AsyncStorage.removeItem(entryKey(id)).catch(() => undefined)),
);
const index = { entries: {} };
set({ index, sync: { ...IDLE } });
persistIndex(index);
},
totalSize: () => {
let n = 0;
for (const e of Object.values(get().index.entries)) n += e.size;
return n;
},
totalCount: () => Object.keys(get().index.entries).length,
setSyncState: (next) => set({ sync: { ...get().sync, ...next } }),
resetSync: () => set({ sync: { ...IDLE }, abortRequested: false }),
requestAbort: () => set({ abortRequested: true }),
consumeAbort: () => {
const v = get().abortRequested;
if (v) set({ abortRequested: false });
return v;
},
}));
+12
View File
@@ -131,6 +131,14 @@ interface PersistedSettings {
// Plugins (UI-level enable map)
pluginEnabled: Record<string, boolean>;
// Offline mail cache: download recent message bodies in the background so
// they can be opened without network. Days windows the lookback; the
// attachments toggle controls whether file blobs are cached too (off by
// default — bodies-only is much smaller).
offlineCacheEnabled: boolean;
offlineCacheDays: number;
offlineCacheIncludeAttachments: boolean;
}
const DEFAULT_PERSISTED: PersistedSettings = {
@@ -207,6 +215,10 @@ const DEFAULT_PERSISTED: PersistedSettings = {
filtersExpandedView: false,
pluginEnabled: {},
offlineCacheEnabled: false,
offlineCacheDays: 7,
offlineCacheIncludeAttachments: false,
};
export interface SettingsState extends PersistedSettings {