Compare commits

...
6 Commits
11 changed files with 351 additions and 64 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.34
0.1.37
+1
View File
@@ -20,6 +20,7 @@ module.exports = {
expo: {
name: 'Bulwark Mobile',
slug: 'bulwark-mobile',
scheme: 'bulwarkmobile',
version: VERSION,
orientation: 'portrait',
icon: './assets/icon.png',
+13 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.34",
"version": "0.1.37",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.34",
"version": "0.1.37",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@@ -25,6 +25,7 @@
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.8",
"lucide-react-native": "^1.7.0",
"react": "19.1.0",
"react-native": "0.81.5",
@@ -5418,6 +5419,16 @@
"react-native": "*"
}
},
"node_modules/expo-web-browser": {
"version": "15.0.11",
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz",
"integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo/node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.34",
"version": "0.1.37",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
@@ -31,6 +31,7 @@
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.8",
"lucide-react-native": "^1.7.0",
"react": "19.1.0",
"react-native": "0.81.5",
+83
View File
@@ -0,0 +1,83 @@
import * as WebBrowser from 'expo-web-browser';
// Webmail-mediated login. Instead of doing OAuth directly against the JMAP
// server (which would need a registered client_id and a mobile redirect URI
// allow-listed on the IdP), the app opens the webmail's /mobile-handoff page,
// the user signs in there with their normal credentials, and the webmail
// redirects back to the app's custom scheme with the verified credentials in
// the URL fragment. Fragments aren't sent to the server, so the password
// never appears in HTTP access logs along the way.
export const HANDOFF_REDIRECT_URI = 'bulwarkmobile://auth/callback';
export interface HandoffCredentials {
serverUrl: string;
username: string;
password: string;
}
export class HandoffError extends Error {}
export class HandoffCancelledError extends HandoffError {
constructor() {
super('Sign-in cancelled');
}
}
function randomState(): string {
const bytes = new Uint8Array(16);
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
let s = '';
for (const b of bytes) s += b.toString(16).padStart(2, '0');
return s;
}
function buildHandoffUrl(webmailUrl: string, state: string): string {
const base = webmailUrl.replace(/\/+$/, '');
const params = new URLSearchParams({
redirect_uri: HANDOFF_REDIRECT_URI,
state,
});
return `${base}/mobile-handoff?${params.toString()}`;
}
function parseFragment(url: string): URLSearchParams {
const hashIdx = url.indexOf('#');
if (hashIdx === -1) return new URLSearchParams();
return new URLSearchParams(url.slice(hashIdx + 1));
}
export async function runWebmailHandoff(webmailUrl: string): Promise<HandoffCredentials> {
const state = randomState();
const handoffUrl = buildHandoffUrl(webmailUrl, state);
const result = await WebBrowser.openAuthSessionAsync(handoffUrl, HANDOFF_REDIRECT_URI);
if (result.type === 'cancel' || result.type === 'dismiss') {
throw new HandoffCancelledError();
}
if (result.type !== 'success' || !result.url) {
throw new HandoffError(`Sign-in failed: ${result.type}`);
}
const params = parseFragment(result.url);
const err = params.get('error');
if (err) throw new HandoffError(err);
// CSRF guard: the state we generated must round-trip through the webmail
// unchanged. A mismatch means the redirect didn't come from the flow we
// started, and the rest of the fragment shouldn't be trusted.
if (params.get('state') !== state) {
throw new HandoffError('State mismatch');
}
const serverUrl = params.get('server_url');
const username = params.get('username');
const password = params.get('password');
if (!serverUrl || !username || !password) {
throw new HandoffError('Sign-in response missing credentials');
}
return { serverUrl, username, password };
}
WebBrowser.maybeCompleteAuthSession();
+31 -3
View File
@@ -20,6 +20,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const login = useAuthStore((state) => state.login);
const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail);
const isLoading = useAuthStore((state) => state.isLoading);
const error = useAuthStore((state) => state.error);
const clearError = useAuthStore((state) => state.clearError);
@@ -30,6 +31,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
const [showPassword, setShowPassword] = React.useState(false);
const canSubmit = Boolean(serverUrl.trim() && email.trim() && password);
const canHandoff = Boolean(serverUrl.trim());
const handleLogin = async () => {
if (!canSubmit) {
@@ -44,6 +46,25 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
}
};
const handleWebmailHandoff = async () => {
if (!canHandoff) return;
try {
const before = useAuthStore.getState().isAuthenticated;
await loginViaWebmail(serverUrl.trim(), { addAccount: isAddMode });
// loginViaWebmail swallows the "user cancelled the browser" case
// silently — only fire the navigation hook when authentication
// actually completed.
const after = useAuthStore.getState().isAuthenticated;
if (after && !before) {
onLogin?.();
} else if (isAddMode && after) {
onLogin?.();
}
} catch {
// Store state already contains the user-facing error.
}
};
const updateField = React.useCallback(
(setter: React.Dispatch<React.SetStateAction<string>>) => (value: string) => {
if (useAuthStore.getState().error) {
@@ -149,10 +170,17 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
<View style={styles.dividerLine} />
</View>
{/* OAuth buttons */}
{/* Webmail-mediated login */}
<View style={styles.oauthRow}>
<Button variant="outline" size="md" disabled>
SSO Provider
<Button
variant="outline"
size="md"
onPress={() => {
void handleWebmailHandoff();
}}
disabled={!canHandoff || isLoading}
>
Sign in via webmail
</Button>
</View>
</View>
+21 -6
View File
@@ -28,14 +28,17 @@ vi.mock('../settings-store', () => ({
useSettingsStore: { getState: () => ({ archiveMode: 'single', emailsPerPage: 25 }) },
}));
// offline-cache-store is touched by selectMailbox (cache-seed fallback) and
// getEmailDetail (best-effort body refresh). Stub it as an empty cache so
// tests don't need to set up AsyncStorage.
// offline-cache-store is touched by selectMailbox (cache-seed fallback),
// getEmailDetail (best-effort body refresh), and setActiveAccount (account
// switch). Stub it as an empty cache so tests don't need to set up
// AsyncStorage.
vi.mock('../offline-cache-store', () => ({
useOfflineCacheStore: {
getState: () => ({
activeAccountId: null,
hydrated: true,
hydrate: vi.fn(),
setAccount: vi.fn(async () => undefined),
totalCount: () => 0,
getEmailsInMailbox: vi.fn(async () => []),
has: () => false,
@@ -45,19 +48,27 @@ vi.mock('../offline-cache-store', () => ({
},
}));
// email-store now early-returns from fetch actions when jmapClient.isConnected
// is false (cold-start guard). The tests exercise those actions, so present
// a connected stub.
// email-store now early-returns from fetch actions unless jmapClient is
// connected AND serving the same logical account the store has active
// (`generateAccountId(username, serverUrl) === activeAccountId`). The
// tests exercise those actions, so present a fully connected stub plus
// the matching username/serverUrl pair. beforeEach() syncs the store's
// activeAccountId to the id these credentials produce.
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
isConnected: true,
accountId: 'acc-1',
username: 'test@example.com',
serverUrl: 'https://mail.example.com',
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
// refreshEmails / loadMoreEmails chunk by this value when fetching ids.
getMaxObjectsInGet: () => 500,
},
}));
import { generateAccountId } from '../../lib/account-utils';
const TEST_ACCOUNT_ID = generateAccountId('test@example.com', 'https://mail.example.com');
import * as emailApi from '../../api/email';
import { useEmailStore } from '../email-store';
@@ -74,6 +85,10 @@ const mockSearchEmails = emailApi.searchEmails as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
useEmailStore.getState().reset();
// Wire the store's active account to the one the mocked jmapClient is
// serving, so the guard inside fetchMailboxes / refreshEmails / etc.
// doesn't short-circuit the tests.
useEmailStore.setState({ activeAccountId: TEST_ACCOUNT_ID });
});
describe('email-store', () => {
+23
View File
@@ -6,6 +6,7 @@ import { useEmailStore } from './email-store';
import { useContactsStore } from './contacts-store';
import { useCalendarStore } from './calendar-store';
import { generateAccountId } from '../lib/account-utils';
import { runWebmailHandoff, HandoffCancelledError } from '../lib/oauth';
import {
teardownPushNotifications,
teardownPushNotificationsForAccount,
@@ -38,6 +39,7 @@ export interface AuthState {
client: typeof jmapClient | null;
login: (serverUrl: string, username: string, password: string, opts?: { addAccount?: boolean }) => Promise<void>;
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
logout: () => Promise<void>;
logoutAll: () => Promise<void>;
switchAccount: (accountId: string) => Promise<void>;
@@ -167,6 +169,27 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
},
loginViaWebmail: async (webmailUrl, opts) => {
set({ isLoading: true, error: null });
let creds;
try {
creds = await runWebmailHandoff(webmailUrl);
} catch (err) {
if (err instanceof HandoffCancelledError) {
// User closed the browser tab — quiet exit, no error banner.
set({ isLoading: false, error: null });
return;
}
const message = err instanceof Error ? err.message : 'Sign-in failed';
set({ isLoading: false, error: message });
throw err;
}
// Hand the verified credentials to the existing password login path so
// session establishment, account registration, and feature-store wiring
// all behave identically to a manual sign-in.
await get().login(creds.serverUrl, creds.username, creds.password, opts);
},
logout: async () => {
const accountStore = useAccountStore.getState();
const currentId = get().activeAccountId;
+65 -19
View File
@@ -22,9 +22,27 @@ import {
searchEmails as apiSearchEmails,
} from '../api/email';
import { toWildcardQuery } from '../lib/search-utils';
import { generateAccountId } from '../lib/account-utils';
import { useSettingsStore } from './settings-store';
import { useOfflineCacheStore } from './offline-cache-store';
// True only when the JMAP client is actually serving the email-store's active
// account. During an account switch there's a window between
// setActiveAccount() (which swaps the email-store view immediately) and
// jmapClient.loadAccount() resolving, when the client is still on the
// *previous* account. Without this guard, any fetchMailboxes/refreshEmails
// fired in that window (e.g. by an EmailListScreen useEffect reacting to
// the empty new-account view) would return the previous account's data and
// stamp it into the new account's snapshot.
function jmapClientServesActiveAccount(activeAccountId: string | null): boolean {
if (!activeAccountId) return false;
if (!jmapClient.isConnected) return false;
const username = jmapClient.username;
const serverUrl = jmapClient.serverUrl;
if (!username || !serverUrl) return false;
return generateAccountId(username, serverUrl) === activeAccountId;
}
export interface EmailFilters {
from?: string;
to?: string;
@@ -315,6 +333,12 @@ export const useEmailStore = create<EmailState>()(
error: null,
loading: false,
});
// Point the offline body cache at the same account so getEmailDetail's
// fallback and selectMailbox's seed read from the right bucket. Fire-
// and-forget — the cache returns empty until hydration completes,
// which is the correct degraded behaviour.
void useOfflineCacheStore.getState().setAccount(accountId);
},
removeAccount: (accountId) => {
@@ -336,6 +360,7 @@ export const useEmailStore = create<EmailState>()(
filters: {},
pendingUndo: null,
});
void useOfflineCacheStore.getState().setAccount(null);
} else {
set({ accountSnapshots: rest });
}
@@ -359,16 +384,20 @@ export const useEmailStore = create<EmailState>()(
error: null,
loading: false,
});
void useOfflineCacheStore.getState().setAccount(null);
},
fetchMailboxes: async () => {
// Skip silently when there's no live session. Screens fire this from
// mount-time useEffects, and on cold start App.tsx renders MainTabs
// before restoreSession() finishes; without this guard the underlying
// API call would throw "Not authenticated - call connect() first" and
// surface as a user-visible error before the real fetch (kicked off by
// refetchFeatureStores() once the session is live) replaces it.
if (!jmapClient.isConnected) return;
// Skip silently when there's no live session, or when jmapClient is
// mid-transition to a different account (see jmapClientServesActiveAccount).
// Screens fire this from mount-time useEffects, and on cold start
// App.tsx renders MainTabs before restoreSession() finishes; without
// this guard the underlying API call would either throw "Not
// authenticated - call connect() first" or — worse, during an account
// switch — return the *previous* account's mailboxes and stamp them
// into the new account's snapshot.
const activeAccountId = get().activeAccountId;
if (!jmapClientServesActiveAccount(activeAccountId)) return;
const prevState = get().mailboxState;
try {
@@ -377,6 +406,9 @@ export const useEmailStore = create<EmailState>()(
// we have no previous state to compare against.
if (prevState) {
const changes = await getMailboxChanges(prevState);
// Bail if the user switched accounts during the await — anything we
// set() now would land in the wrong account's bucket.
if (get().activeAccountId !== activeAccountId) return;
if (changes) {
// No changes at all — keep the cached list, just bump the state.
if (
@@ -391,6 +423,7 @@ export const useEmailStore = create<EmailState>()(
const fetched = toFetch.length > 0
? (await getMailboxesByIds(toFetch)).list
: [];
if (get().activeAccountId !== activeAccountId) return;
const destroyed = new Set(changes.destroyed);
const byId = new Map<string, Mailbox>();
for (const m of get().mailboxes) byId.set(m.id, m);
@@ -411,9 +444,11 @@ export const useEmailStore = create<EmailState>()(
}
const { list, state } = await getMailboxesWithState();
if (get().activeAccountId !== activeAccountId) return;
set({ mailboxes: list, mailboxState: state });
} catch (err) {
console.warn('[email-store] fetchMailboxes failed:', err);
if (get().activeAccountId !== activeAccountId) return;
// Don't overwrite the cached list on a transient failure — the user
// can still navigate folders. Only surface the error when we have no
// mailboxes at all to show.
@@ -481,11 +516,11 @@ export const useEmailStore = create<EmailState>()(
pendingUndo: null,
});
// Stop here if there's no live session yet — the cached seed already
// gave the user something to look at, and the refetch driven by
// restoreSession() / switchAccount will run the network half once the
// client is connected.
if (!jmapClient.isConnected) {
// Stop here if there's no live session OR jmapClient is mid-transition
// to a different account. The cached seed already gave the user
// something to look at, and the refetch driven by restoreSession() /
// switchAccount will run the network half once the client catches up.
if (!jmapClientServesActiveAccount(get().activeAccountId)) {
set({ loading: false });
return;
}
@@ -494,9 +529,9 @@ export const useEmailStore = create<EmailState>()(
},
loadMoreEmails: async () => {
const { currentMailboxId, emails, totalEmails, loading, searchQuery, filters } = get();
const { currentMailboxId, emails, totalEmails, loading, searchQuery, filters, activeAccountId } = get();
if (!currentMailboxId || loading || emails.length >= totalEmails) return;
if (!jmapClient.isConnected) return;
if (!jmapClientServesActiveAccount(activeAccountId)) return;
set({ loading: true });
try {
@@ -507,7 +542,9 @@ export const useEmailStore = create<EmailState>()(
limit,
filter,
});
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
const newEmails = ids.length > 0 ? await fetchEmailsChunked(ids) : [];
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
const merged = [...emails, ...newEmails];
const updates: Partial<EmailState> = { emails: merged, loading: false };
if (isBaseView(searchQuery, filters)) {
@@ -522,15 +559,16 @@ export const useEmailStore = create<EmailState>()(
}
set(updates);
} catch (err) {
if (get().activeAccountId !== activeAccountId) return;
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load more' });
}
},
refreshEmails: async () => {
const state = get();
const { currentMailboxId, searchQuery, filters, emails: existing, queryState, emailState } = state;
const { currentMailboxId, searchQuery, filters, emails: existing, queryState, emailState, activeAccountId } = state;
if (!currentMailboxId) return;
if (!jmapClient.isConnected) return;
if (!jmapClientServesActiveAccount(activeAccountId)) return;
set({ loading: true, error: null });
const filter = buildJmapFilter(currentMailboxId, searchQuery, filters);
@@ -613,7 +651,7 @@ export const useEmailStore = create<EmailState>()(
const nextQueryState = queryChanges.newQueryState;
const nextTotal = queryChanges.total;
if (get().currentMailboxId !== currentMailboxId) return;
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
set({
emails: trimmed,
@@ -666,7 +704,7 @@ export const useEmailStore = create<EmailState>()(
set(updates);
} catch (err) {
console.warn('[email-store] refreshEmails failed:', err);
if (get().currentMailboxId !== currentMailboxId) return;
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;
// Keep whatever's visible; only surface the error when the list is
// empty. With cached emails on screen the OfflineBanner already
// tells the user the data is stale.
@@ -679,7 +717,11 @@ export const useEmailStore = create<EmailState>()(
currentMailboxId,
Math.max(limit, 50),
);
if (get().currentMailboxId === currentMailboxId && cached.length > 0) {
if (
get().activeAccountId === activeAccountId &&
get().currentMailboxId === currentMailboxId &&
cached.length > 0
) {
set({ emails: cached, totalEmails: cached.length, loading: false, error: null });
return;
}
@@ -697,6 +739,10 @@ export const useEmailStore = create<EmailState>()(
handleStateChange: async (change) => {
if (!jmapClient.currentSession) return;
// Drop changes that arrived for a different account than the one we're
// currently showing (e.g. push notifications received during/just after
// an account switch).
if (!jmapClientServesActiveAccount(get().activeAccountId)) return;
const accountId = jmapClient.accountId;
const accountChanges = change.changed?.[accountId];
if (!accountChanges) return;
+103 -32
View File
@@ -3,16 +3,25 @@
// 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.
//
// Storage is keyed by the email-store's `activeAccountId` so logged-in
// accounts don't leak cached bodies into each other. The store loads one
// account's bucket at a time (the active one); switching accounts via
// setAccount() persists the current bucket then hydrates the new one.
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:';
const INDEX_KEY_PREFIX = 'webmail:offline-cache:index:v2:';
const ENTRY_KEY_PREFIX = 'webmail:offline-cache:entry:v2:';
function entryKey(id: string): string {
return `${ENTRY_KEY_PREFIX}${id}`;
function indexKey(accountId: string): string {
return `${INDEX_KEY_PREFIX}${accountId}`;
}
function entryKey(accountId: string, emailId: string): string {
return `${ENTRY_KEY_PREFIX}${accountId}:${emailId}`;
}
export interface OfflineCacheIndexEntry {
@@ -23,8 +32,6 @@ export interface OfflineCacheIndexEntry {
}
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>;
}
@@ -42,8 +49,11 @@ export interface SyncState {
}
const IDLE: SyncState = { phase: 'idle', total: 0, completed: 0, fetched: 0, bytes: 0 };
const EMPTY_INDEX: CacheIndex = { entries: {} };
interface OfflineCacheState {
// Currently-loaded bucket. null = no account active (no reads/writes).
activeAccountId: string | null;
index: CacheIndex;
hydrated: boolean;
sync: SyncState;
@@ -52,6 +62,9 @@ interface OfflineCacheState {
// AbortControllers need to survive across the function boundary.
abortRequested: boolean;
// Point the store at the given account, hydrating its bucket from
// AsyncStorage. Pass null to detach (no reads/writes will hit storage).
setAccount: (accountId: string | null) => Promise<void>;
hydrate: () => Promise<void>;
has: (id: string) => boolean;
get: (id: string) => Promise<Email | null>;
@@ -74,41 +87,83 @@ interface OfflineCacheState {
consumeAbort: () => boolean;
}
function persistIndex(index: CacheIndex): void {
void AsyncStorage.setItem(INDEX_KEY, JSON.stringify(index)).catch((err) => {
function persistIndex(accountId: string, index: CacheIndex): void {
void AsyncStorage.setItem(indexKey(accountId), JSON.stringify(index)).catch((err) => {
console.warn('[offline-cache] persist index failed', err);
});
}
async function loadIndex(accountId: string): Promise<CacheIndex> {
try {
const raw = await AsyncStorage.getItem(indexKey(accountId));
if (raw) {
const parsed = JSON.parse(raw) as CacheIndex;
if (parsed && typeof parsed === 'object' && parsed.entries) {
return parsed;
}
}
} catch (err) {
console.warn('[offline-cache] hydrate failed', err);
}
return { entries: {} };
}
export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
activeAccountId: null,
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);
setAccount: async (accountId) => {
const state = get();
if (state.activeAccountId === accountId) {
// Same account — make sure we've hydrated.
if (!state.hydrated) await get().hydrate();
return;
}
set({ hydrated: true });
// Drop any in-flight sync state from the previous account; the abort flag
// makes runOfflineSync stop on its next chunk boundary.
set({
activeAccountId: accountId,
index: { ...EMPTY_INDEX },
hydrated: false,
sync: { ...IDLE },
abortRequested: true,
});
if (accountId) {
const index = await loadIndex(accountId);
// Re-check in case another setAccount call raced past us.
if (get().activeAccountId !== accountId) return;
set({ index, hydrated: true, abortRequested: false });
} else {
set({ hydrated: true, abortRequested: false });
}
},
hydrate: async () => {
const state = get();
if (state.hydrated) return;
const accountId = state.activeAccountId;
if (!accountId) {
// No active account yet — mark hydrated so callers that block on this
// (selectMailbox cache-seed path) don't spin. Reads will return empty.
set({ hydrated: true });
return;
}
const index = await loadIndex(accountId);
if (get().activeAccountId !== accountId) return;
set({ index, hydrated: true });
},
has: (id) => Boolean(get().index.entries[id]),
get: async (id) => {
if (!get().index.entries[id]) return null;
const state = get();
if (!state.activeAccountId) return null;
if (!state.index.entries[id]) return null;
try {
const raw = await AsyncStorage.getItem(entryKey(id));
const raw = await AsyncStorage.getItem(entryKey(state.activeAccountId, id));
if (!raw) return null;
return JSON.parse(raw) as Email;
} catch (err) {
@@ -118,12 +173,19 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
},
put: async (email, approxBytes) => {
const state = get();
const accountId = state.activeAccountId;
if (!accountId) return;
try {
await AsyncStorage.setItem(entryKey(email.id), JSON.stringify(email));
await AsyncStorage.setItem(entryKey(accountId, email.id), JSON.stringify(email));
} catch (err) {
console.warn('[offline-cache] put failed', email.id, err);
return;
}
// Re-read the active account in case it changed while we were awaiting
// AsyncStorage — don't stamp the new account's index with the old
// account's entry.
if (get().activeAccountId !== accountId) return;
const nextEntries = {
...get().index.entries,
[email.id]: {
@@ -135,35 +197,44 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
};
const index = { entries: nextEntries };
set({ index });
persistIndex(index);
persistIndex(accountId, index);
},
remove: async (ids) => {
if (ids.length === 0) return;
const accountId = get().activeAccountId;
if (!accountId) return;
await Promise.all(
ids.map((id) => AsyncStorage.removeItem(entryKey(id)).catch(() => undefined)),
ids.map((id) => AsyncStorage.removeItem(entryKey(accountId, id)).catch(() => undefined)),
);
if (get().activeAccountId !== accountId) return;
const next = { ...get().index.entries };
for (const id of ids) delete next[id];
const index = { entries: next };
set({ index });
persistIndex(index);
persistIndex(accountId, index);
},
clearAll: async () => {
const accountId = get().activeAccountId;
if (!accountId) return;
const ids = Object.keys(get().index.entries);
await Promise.all(
ids.map((id) => AsyncStorage.removeItem(entryKey(id)).catch(() => undefined)),
ids.map((id) => AsyncStorage.removeItem(entryKey(accountId, id)).catch(() => undefined)),
);
if (get().activeAccountId !== accountId) return;
const index = { entries: {} };
set({ index, sync: { ...IDLE } });
persistIndex(index);
persistIndex(accountId, index);
},
getEmailsInMailbox: async (mailboxId, limit = 200) => {
const state = get();
const accountId = state.activeAccountId;
if (!accountId) return [];
// Sort the index by receivedAt descending first so we don't have to read
// entries we won't return — the index already carries receivedAt.
const sorted = Object.values(get().index.entries).sort((a, b) => {
const sorted = Object.values(state.index.entries).sort((a, b) => {
const at = a.receivedAt ? new Date(a.receivedAt).getTime() : 0;
const bt = b.receivedAt ? new Date(b.receivedAt).getTime() : 0;
return bt - at;
@@ -172,7 +243,7 @@ export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
for (const entry of sorted) {
if (matches.length >= limit) break;
try {
const raw = await AsyncStorage.getItem(entryKey(entry.id));
const raw = await AsyncStorage.getItem(entryKey(accountId, entry.id));
if (!raw) continue;
const email = JSON.parse(raw) as Email;
if (email.mailboxIds?.[mailboxId]) {
+8
View File
@@ -64,3 +64,11 @@ vi.mock('expo-secure-store', () => ({
getItemAsync: vi.fn(async () => null),
deleteItemAsync: vi.fn(async () => undefined),
}));
// expo-web-browser transitively pulls in expo-modules-core, which evaluates
// RN-only globals at module load. The unit tests don't exercise the real
// browser handoff flow, so stubbing the surface is enough.
vi.mock('expo-web-browser', () => ({
openAuthSessionAsync: vi.fn(async () => ({ type: 'cancel' as const })),
maybeCompleteAuthSession: vi.fn(),
}));